Compare commits

..
Author SHA1 Message Date
Anthony Stirling 7111f0d2df refactor: improve security and code quality with Pattern instances
Replace regex string literals with compiled Pattern instances for better
performance and readability.

Security improvements:
- MobileScannerService: Add input validation patterns
  - Filename sanitization pattern
  - Session ID validation pattern
  - File extension validation pattern
  - Prevents path traversal and injection attacks

- SvgSanitizer: Enhanced SVG sanitization

Code quality improvements:
- Convert regex string literals to Pattern.compile() across codebase
- Better performance (patterns compiled once vs per-use)
- More explicit intent in code
- Easier to maintain and test

Changed files:
- Controllers: MergeController, ConvertPdfJsonController, StampController,
  PipelineDirectoryProcessor, ReactRoutingController, UIDataTessdataController
- Services: PdfJsonConversionService, PdfJsonFallbackFontService,
  SharedSignatureService, TotpService, SignatureService
- Core: SPDFApplication
- Tests updated accordingly

Related: #5680 (if already merged, this consolidates remaining changes)
2026-02-16 10:44:21 +00:00
134 changed files with 1845 additions and 8235 deletions
@@ -606,12 +606,6 @@ public class EndpointConfiguration {
return endpointGroups.getOrDefault(group, new HashSet<>());
}
public Set<String> getAllEndpoints() {
return endpointGroups.values().stream()
.flatMap(Set::stream)
.collect(java.util.stream.Collectors.toSet());
}
private boolean isToolGroup(String group) {
return "qpdf".equals(group)
|| "OCRmyPDF".equals(group)
@@ -14,7 +14,6 @@ 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;
@@ -41,7 +40,6 @@ 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";
@@ -112,10 +110,6 @@ public class InstallationPathConfig {
return SIGNATURES_PATH;
}
public static String getPluginsPath() {
return PLUGINS_PATH;
}
public static String getPrivateKeyPath() {
return BACKUP_PRIVATE_KEY_PATH;
}
@@ -1,49 +0,0 @@
package stirling.software.common.constants;
/**
* Centralized constants for JWT token management.
*
* <p>These defaults are used when configuration values are not explicitly set.
*/
public final class JwtConstants {
private JwtConstants() {
throw new UnsupportedOperationException("Utility class");
}
/** Default JWT access token lifetime in minutes (24 hours). */
public static final int DEFAULT_TOKEN_EXPIRY_MINUTES = 1440;
/** Default desktop client token lifetime in minutes (30 days). */
public static final int DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES = 43200;
/**
* Default refresh grace period in minutes.
*
* <p>Allows refresh of expired tokens within this window after expiration.
*/
public static final int DEFAULT_REFRESH_GRACE_MINUTES = 15;
/**
* Default allowed clock skew in seconds.
*
* <p>Tolerates small time drift between client and server clocks during validation.
*/
public static final int DEFAULT_CLOCK_SKEW_SECONDS = 60;
/** Milliseconds per minute. */
public static final long MILLIS_PER_MINUTE = 60_000L;
/** Seconds per minute. */
public static final long SECONDS_PER_MINUTE = 60L;
/** JWT issuer identifier. */
public static final String ISSUER = "https://stirling.com";
/**
* Maximum refresh attempts allowed within the grace period window.
*
* <p>Prevents abuse of expired tokens by limiting refresh attempts.
*/
public static final int MAX_REFRESH_ATTEMPTS_IN_GRACE = 3;
}
@@ -39,7 +39,6 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.common.configuration.InstallationPathConfig;
import stirling.software.common.configuration.YamlPropertySourceFactory;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.exception.UnsupportedProviderException;
import stirling.software.common.model.oauth2.GitHubProvider;
import stirling.software.common.model.oauth2.GoogleProvider;
@@ -394,107 +393,12 @@ public class ApplicationProperties {
}
}
/**
* JWT token configuration.
*
* <p><b>BREAKING CHANGE (v2.0):</b> Default token expiry increased from 12 hours (720
* minutes) to 24 hours (1440 minutes). If you require the previous behavior, explicitly set
* {@code tokenExpiryMinutes: 720} in your configuration.
*/
@Data
public static class Jwt {
private boolean enableKeystore = true;
private boolean enableKeyRotation = false;
private boolean enableKeyCleanup = true;
/**
* JWT access token lifetime in minutes for web clients.
*
* <p>Default: {@value JwtConstants#DEFAULT_TOKEN_EXPIRY_MINUTES} minutes (24 hours).
*
* <p><b>BREAKING CHANGE:</b> Previously hardcoded to 720 minutes (12 hours). Now
* defaults to 1440 minutes (24 hours).
*/
private int tokenExpiryMinutes = JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
/**
* JWT access token lifetime in minutes for desktop clients (Tauri app).
*
* <p>Desktop clients are automatically detected via User-Agent header and receive
* longer-lived tokens because they run on personal devices with OS-level encrypted
* storage (macOS Keychain, Windows Credential Manager, Linux Secret Service).
*
* <p>This provides better UX (login once per month) while maintaining security through
* device encryption and secure storage, matching the behavior of popular desktop apps
* like Slack, Discord, VS Code, etc.
*
* <p>Default: 43200 minutes (30 days).
*/
private int desktopTokenExpiryMinutes = 43200;
/**
* Allowed clock skew in seconds for JWT validation.
*
* <p>Tolerates small time drift between client and server clocks. Tokens that are
* slightly expired or slightly in the future (within this window) will still be
* accepted.
*
* <p>Default: {@value JwtConstants#DEFAULT_CLOCK_SKEW_SECONDS} seconds.
*/
private int allowedClockSkewSeconds = JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
/**
* Grace period in minutes for refreshing expired tokens.
*
* <p>Allows token refresh using an expired access token if the token expired within
* this many minutes. This provides better UX by allowing users to refresh slightly
* expired tokens without re-authentication.
*
* <p>Rate limiting is applied to prevent abuse of expired tokens within the grace
* window (max {@value JwtConstants#MAX_REFRESH_ATTEMPTS_IN_GRACE} attempts).
*
* <p>Default: {@value JwtConstants#DEFAULT_REFRESH_GRACE_MINUTES} minutes.
*/
private int refreshGraceMinutes = JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
/**
* Calculate number of days to retain old JWT signing keys.
*
* <p>Automatically calculated based on the longest token lifetime plus a proportional
* safety buffer. Keys must be retained for at least as long as the tokens they signed
* remain valid, otherwise token verification will fail.
*
* <p>Formula: ceil((maxTokenExpiry + 10% buffer + refreshGrace + clockSkew) / 1440)
*
* <p>The buffer includes:
*
* <ul>
* <li>10% of token lifetime (scales with token duration)
* <li>Token refresh grace period ({@link #refreshGraceMinutes})
* <li>Clock skew tolerance ({@link #allowedClockSkewSeconds} converted to minutes)
* </ul>
*
* @return calculated key retention period in days
*/
public int getKeyRetentionDays() {
final int MINUTES_PER_DAY = 1440;
final double BUFFER_PERCENTAGE = 0.10; // 10% buffer
int maxTokenExpiryMinutes = Math.max(tokenExpiryMinutes, desktopTokenExpiryMinutes);
// Add 10% buffer (scales with token lifetime)
int bufferMinutes = (int) Math.ceil(maxTokenExpiryMinutes * BUFFER_PERCENTAGE);
// Add refresh grace period
bufferMinutes += refreshGraceMinutes;
// Add clock skew (convert seconds to minutes, round up)
bufferMinutes += (int) Math.ceil(allowedClockSkewSeconds / 60.0);
// Total retention in minutes, convert to days (round up)
int totalMinutes = maxTokenExpiryMinutes + bufferMinutes;
return (int) Math.ceil(totalMinutes / (double) MINUTES_PER_DAY);
}
private int keyRetentionDays = 7;
}
@Data
@@ -1,34 +0,0 @@
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();
}
@@ -1,65 +0,0 @@
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();
}
}
@@ -1,261 +0,0 @@
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();
}
}
@@ -1,36 +0,0 @@
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;
}
}
@@ -1,28 +0,0 @@
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,7 +13,6 @@ 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;
@@ -29,7 +28,6 @@ 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
@@ -61,11 +59,11 @@ public class SPDFApplication {
}
public static void main(String[] args) throws IOException, InterruptedException {
SpringApplicationBuilder builder = new SpringApplicationBuilder(SPDFApplication.class);
SpringApplication app = new SpringApplication(SPDFApplication.class);
Properties props = new Properties();
builder.profiles(getActiveProfile(args));
app.setAdditionalProfiles(getActiveProfile(args));
ConfigInitializer initializer = new ConfigInitializer();
try {
@@ -113,13 +111,8 @@ 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
@@ -1,6 +1,5 @@
package stirling.software.SPDF.controller.api.converters;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
import java.util.UUID;
import java.util.regex.Pattern;
@@ -59,7 +58,6 @@ public class ConvertPdfJsonController {
}
byte[] jsonBytes = pdfJsonConversionService.convertPdfToJson(inputFile, lightweight);
logJsonResponse("pdf/text-editor", jsonBytes);
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
@@ -116,11 +114,10 @@ public class ConvertPdfJsonController {
// Scope job to authenticated user if security is enabled
String scopedJobKey = getScopedJobKey(baseJobId);
log.debug("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
log.info("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
byte[] jsonBytes =
pdfJsonConversionService.extractDocumentMetadata(inputFile, scopedJobKey);
logJsonResponse("pdf/text-editor/metadata", jsonBytes);
String originalName = inputFile.getOriginalFilename();
String baseName =
(originalName != null && !originalName.isBlank())
@@ -188,33 +185,11 @@ public class ConvertPdfJsonController {
validateJobAccess(jobId);
byte[] jsonBytes = pdfJsonConversionService.extractSinglePage(jobId, pageNumber);
logJsonResponse("pdf/text-editor/page", jsonBytes);
String docName = "page_" + pageNumber + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
}
@GetMapping(value = "/pdf/text-editor/fonts/{jobId}/{pageNumber}")
@Operation(
summary = "Extract fonts used by a single cached page for text editor",
description =
"Retrieves the font payloads used by a single page from a previously cached PDF document."
+ " Requires prior call to /pdf/text-editor/metadata. The jobId must belong to the"
+ " authenticated user. Output:JSON")
public ResponseEntity<byte[]> extractPageFonts(
@PathVariable String jobId, @PathVariable int pageNumber) throws Exception {
// Validate job ownership
validateJobAccess(jobId);
byte[] jsonBytes = pdfJsonConversionService.extractPageFonts(jobId, pageNumber);
logJsonResponse("pdf/text-editor/fonts/page", jsonBytes);
String docName = "page_fonts_" + pageNumber + ".json";
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
}
@AutoJobPostMapping(
value = "/pdf/text-editor/clear-cache/{jobId}",
consumes = MediaType.ALL_VALUE)
@AutoJobPostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
@Operation(
summary = "Clear cached PDF document for text editor",
description =
@@ -244,188 +219,6 @@ public class ConvertPdfJsonController {
return baseJobId;
}
private void logJsonResponse(String label, byte[] jsonBytes) {
if (jsonBytes == null) {
log.warn("Returning {} JSON response: null bytes", label);
return;
}
// Only perform expensive tail extraction if debug logging is enabled
if (log.isDebugEnabled()) {
int length = jsonBytes.length;
boolean endsWithJson =
length > 0 && (jsonBytes[length - 1] == '}' || jsonBytes[length - 1] == ']');
String tail = "";
if (length > 0) {
int start = Math.max(0, length - 64);
tail = new String(jsonBytes, start, length - start, StandardCharsets.UTF_8);
tail = tail.replaceAll("[\\r\\n\\t]+", " ").replaceAll("[^\\x20-\\x7E]", "?");
}
log.debug(
"Returning {} JSON response ({} bytes, endsWithJson={}, tail='{}')",
label,
length,
endsWithJson,
tail);
}
if (isPdfJsonDebugDumpEnabled()) {
try {
String tmpDir = System.getProperty("java.io.tmpdir");
String customDir = System.getenv("SPDF_PDFJSON_DUMP_DIR");
java.nio.file.Path dumpDir =
customDir != null && !customDir.isBlank()
? java.nio.file.Path.of(customDir)
: java.nio.file.Path.of(tmpDir);
java.nio.file.Path dumpPath =
java.nio.file.Files.createTempFile(dumpDir, "pdfjson_", ".json");
java.nio.file.Files.write(dumpPath, jsonBytes);
log.debug("PDF JSON debug dump ({}): {}", label, dumpPath);
} catch (Exception ex) {
log.warn("Failed to write PDF JSON debug dump ({}): {}", label, ex.getMessage());
}
}
if (isPdfJsonRepeatScanEnabled()) {
logRepeatedJsonStrings(label, jsonBytes);
}
}
private boolean isPdfJsonDebugDumpEnabled() {
String env = System.getenv("SPDF_PDFJSON_DUMP");
if (env != null && env.equalsIgnoreCase("true")) {
return true;
}
return Boolean.getBoolean("spdf.pdfjson.dump");
}
private boolean isPdfJsonRepeatScanEnabled() {
String env = System.getenv("SPDF_PDFJSON_REPEAT_SCAN");
if (env != null && env.equalsIgnoreCase("true")) {
return true;
}
return Boolean.getBoolean("spdf.pdfjson.repeatScan");
}
private void logRepeatedJsonStrings(String label, byte[] jsonBytes) {
final int minLen = 12;
final int maxLen = 200;
final int maxUnique = 50000;
java.util.Map<String, Integer> counts = new java.util.HashMap<>();
boolean inString = false;
boolean escape = false;
boolean tooLong = false;
StringBuilder current = new StringBuilder(64);
boolean capped = false;
for (byte b : jsonBytes) {
char ch = (char) (b & 0xFF);
if (!inString) {
if (ch == '"') {
inString = true;
escape = false;
tooLong = false;
current.setLength(0);
}
continue;
}
if (escape) {
escape = false;
if (!tooLong && current.length() < maxLen) {
current.append(ch);
}
continue;
}
if (ch == '\\') {
escape = true;
continue;
}
if (ch == '"') {
inString = false;
if (!tooLong) {
int len = current.length();
if (len >= minLen && len <= maxLen) {
String value = current.toString();
if (!looksLikeBase64(value)) {
if (!capped || counts.containsKey(value)) {
counts.merge(value, 1, Integer::sum);
if (!capped && counts.size() >= maxUnique) {
capped = true;
}
}
}
}
}
continue;
}
if (!tooLong) {
if (current.length() < maxLen) {
current.append(ch);
} else {
tooLong = true;
}
}
}
java.util.List<java.util.Map.Entry<String, Integer>> top =
counts.entrySet().stream()
.filter(e -> e.getValue() > 1)
.sorted((a, b) -> Integer.compare(b.getValue(), a.getValue()))
.limit(20)
.toList();
if (!top.isEmpty()) {
String summary =
top.stream()
.map(
e ->
String.format(
"\"%s\"(len=%d,count=%d)",
truncateForLog(e.getKey()),
e.getKey().length(),
e.getValue()))
.collect(java.util.stream.Collectors.joining("; "));
log.debug(
"PDF JSON repeat scan ({}): top strings -> {}{}",
label,
summary,
capped ? " (capped)" : "");
} else {
log.debug(
"PDF JSON repeat scan ({}): no repeated strings found{}",
label,
capped ? " (capped)" : "");
}
}
private boolean looksLikeBase64(String value) {
if (value.length() < 32) {
return false;
}
int base64Chars = 0;
for (int i = 0; i < value.length(); i++) {
char c = value.charAt(i);
if ((c >= 'A' && c <= 'Z')
|| (c >= 'a' && c <= 'z')
|| (c >= '0' && c <= '9')
|| c == '+'
|| c == '/'
|| c == '=') {
base64Chars++;
}
}
return base64Chars >= value.length() * 0.9;
}
private String truncateForLog(String value) {
int max = 64;
if (value.length() <= max) {
return value.replaceAll("[\\r\\n\\t]+", " ");
}
return value.substring(0, max).replaceAll("[\\r\\n\\t]+", " ") + "...";
}
/**
* Validate that the current user has access to the given job.
*
@@ -1,6 +1,5 @@
package stirling.software.SPDF.controller.api.misc;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -9,23 +8,20 @@ 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 jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
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;
@@ -41,7 +37,6 @@ 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,
@@ -53,8 +48,7 @@ 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,
PluginService pluginService) {
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
this.applicationProperties = applicationProperties;
this.applicationContext = applicationContext;
this.endpointConfiguration = endpointConfiguration;
@@ -62,7 +56,6 @@ public class ConfigController {
this.userService = userService;
this.licenseService = licenseService;
this.externalAppDepConfig = externalAppDepConfig;
this.pluginService = pluginService;
}
/**
@@ -297,10 +290,6 @@ 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) {
@@ -310,23 +299,6 @@ 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) {
@@ -348,13 +320,11 @@ public class ConfigController {
@GetMapping("/endpoints-availability")
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
@RequestParam(name = "endpoints", required = false) List<String> endpoints) {
Collection<String> toCheck =
(endpoints == null || endpoints.isEmpty())
? endpointConfiguration.getAllEndpoints()
: endpoints;
@RequestParam(name = "endpoints")
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
List<@NotBlank String> endpoints) {
Map<String, EndpointAvailability> result = new HashMap<>();
for (String endpoint : toCheck) {
for (String endpoint : endpoints) {
String trimmedEndpoint = endpoint.trim();
result.put(
trimmedEndpoint,
@@ -1,133 +0,0 @@
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));
}
}
}
File diff suppressed because it is too large Load Diff
@@ -37,68 +37,23 @@ import stirling.software.SPDF.model.json.PdfJsonStream;
@Component
public class PdfJsonCosMapper {
public enum SerializationContext {
DEFAULT,
ANNOTATION_RAW_DATA,
FORM_FIELD_RAW_DATA,
CONTENT_STREAMS_LIGHTWEIGHT,
RESOURCES_LIGHTWEIGHT;
public boolean omitStreamData() {
return this == CONTENT_STREAMS_LIGHTWEIGHT || this == RESOURCES_LIGHTWEIGHT;
}
}
public PdfJsonStream serializeStream(PDStream stream) throws IOException {
if (stream == null) {
return null;
}
return serializeStream(
stream.getCOSObject(),
Collections.newSetFromMap(new IdentityHashMap<>()),
SerializationContext.DEFAULT);
stream.getCOSObject(), Collections.newSetFromMap(new IdentityHashMap<>()));
}
public PdfJsonStream serializeStream(COSStream cosStream) throws IOException {
if (cosStream == null) {
return null;
}
return serializeStream(
cosStream,
Collections.newSetFromMap(new IdentityHashMap<>()),
SerializationContext.DEFAULT);
}
public PdfJsonStream serializeStream(COSStream cosStream, SerializationContext context)
throws IOException {
if (cosStream == null) {
return null;
}
SerializationContext effective = context != null ? context : SerializationContext.DEFAULT;
return serializeStream(
cosStream, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
}
public PdfJsonStream serializeStream(PDStream stream, SerializationContext context)
throws IOException {
if (stream == null) {
return null;
}
return serializeStream(stream.getCOSObject(), context);
return serializeStream(cosStream, Collections.newSetFromMap(new IdentityHashMap<>()));
}
public PdfJsonCosValue serializeCosValue(COSBase base) throws IOException {
return serializeCosValue(
base,
Collections.newSetFromMap(new IdentityHashMap<>()),
SerializationContext.DEFAULT);
}
public PdfJsonCosValue serializeCosValue(COSBase base, SerializationContext context)
throws IOException {
SerializationContext effective = context != null ? context : SerializationContext.DEFAULT;
return serializeCosValue(
base, Collections.newSetFromMap(new IdentityHashMap<>()), effective);
return serializeCosValue(base, Collections.newSetFromMap(new IdentityHashMap<>()));
}
public COSBase deserializeCosValue(PdfJsonCosValue value, PDDocument document)
@@ -210,8 +165,8 @@ public class PdfJsonCosMapper {
return cosStream;
}
private PdfJsonCosValue serializeCosValue(
COSBase base, Set<COSBase> visited, SerializationContext context) throws IOException {
private PdfJsonCosValue serializeCosValue(COSBase base, Set<COSBase> visited)
throws IOException {
if (base == null) {
return null;
}
@@ -265,23 +220,21 @@ public class PdfJsonCosMapper {
if (base instanceof COSArray array) {
List<PdfJsonCosValue> items = new ArrayList<>(array.size());
for (COSBase item : array) {
PdfJsonCosValue serialized = serializeCosValue(item, visited, context);
PdfJsonCosValue serialized = serializeCosValue(item, visited);
items.add(serialized);
}
builder.type(PdfJsonCosValue.Type.ARRAY).items(items);
return builder.build();
}
if (base instanceof COSStream stream) {
builder.type(PdfJsonCosValue.Type.STREAM).stream(
serializeStream(stream, visited, context));
builder.type(PdfJsonCosValue.Type.STREAM).stream(serializeStream(stream, visited));
return builder.build();
}
if (base instanceof COSDictionary dictionary) {
Map<String, PdfJsonCosValue> entries = new LinkedHashMap<>();
for (COSName key : dictionary.keySet()) {
PdfJsonCosValue serialized =
serializeCosValue(
dictionary.getDictionaryObject(key), visited, context);
serializeCosValue(dictionary.getDictionaryObject(key), visited);
entries.put(key.getName(), serialized);
}
builder.type(PdfJsonCosValue.Type.DICTIONARY).entries(entries);
@@ -295,23 +248,16 @@ public class PdfJsonCosMapper {
}
}
private PdfJsonStream serializeStream(
COSStream cosStream, Set<COSBase> visited, SerializationContext context)
private PdfJsonStream serializeStream(COSStream cosStream, Set<COSBase> visited)
throws IOException {
Map<String, PdfJsonCosValue> dictionary = new LinkedHashMap<>();
for (COSName key : cosStream.keySet()) {
COSBase value = cosStream.getDictionaryObject(key);
PdfJsonCosValue serialized = serializeCosValue(value, visited, context);
PdfJsonCosValue serialized = serializeCosValue(value, visited);
if (serialized != null) {
dictionary.put(key.getName(), serialized);
}
}
if (context != null && context.omitStreamData()) {
log.debug("Omitting stream rawData during {} serialization", context);
return PdfJsonStream.builder().dictionary(dictionary).rawData(null).build();
}
String rawData = null;
try (InputStream inputStream = cosStream.createRawInputStream();
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
@@ -1,59 +0,0 @@
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));
}
}
@@ -38,7 +38,6 @@ spring.devtools.livereload.enabled=true
spring.devtools.restart.exclude=stirling.software.proprietary.security/**
spring.web.resources.mime-mappings.webmanifest=application/manifest+json
spring.mvc.async.request-timeout=${SYSTEM_CONNECTIONTIMEOUTMILLISECONDS:1200000}
server.tomcat.max-http-header-size=32768
spring.datasource.url=jdbc:h2:file:./configs/stirling-pdf-DB-2.3.232;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE;MODE=PostgreSQL
spring.datasource.driver-class-name=org.h2.Driver
@@ -64,10 +64,7 @@ security:
persistence: true # Set to 'true' to enable JWT key store
enableKeyRotation: true # Set to 'true' to enable key pair rotation
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day).
desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days).
allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift.
refreshGraceMinutes: 15 # Allow refresh using an expired access token only within this many minutes after expiry.
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
validation: # PDF signature validation settings
trust:
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
@@ -2,7 +2,7 @@ package stirling.software.proprietary.security.configuration;
import java.time.Duration;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
@@ -11,22 +11,15 @@ import org.springframework.context.annotation.Configuration;
import com.github.benmanes.caffeine.cache.Caffeine;
import stirling.software.common.model.ApplicationProperties;
@Configuration
@EnableCaching
public class CacheConfig {
private final ApplicationProperties applicationProperties;
@Autowired
public CacheConfig(ApplicationProperties applicationProperties) {
this.applicationProperties = applicationProperties;
}
@Value("${security.jwt.keyRetentionDays}")
private int keyRetentionDays;
@Bean
public CacheManager cacheManager() {
int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays();
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
cacheManager.setCaffeine(
Caffeine.newBuilder()
@@ -361,8 +361,7 @@ public class SecurityConfiguration {
securityProperties.getOauth2(),
userService,
jwtService,
licenseSettingsService,
applicationProperties))
licenseSettingsService))
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
// Add existing Authorities from the database
.userInfoEndpoint(
@@ -26,7 +26,6 @@ import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.audit.AuditEventType;
import stirling.software.proprietary.audit.AuditLevel;
@@ -35,15 +34,12 @@ import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.api.user.MfaCodeRequest;
import stirling.software.proprietary.security.model.api.user.UsernameAndPassMfa;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.RefreshRateLimitService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
/** REST API Controller for authentication operations. */
@RestController
@@ -59,9 +55,7 @@ public class AuthController {
private final LoginAttemptService loginAttemptService;
private final MfaService mfaService;
private final TotpService totpService;
private final RefreshRateLimitService refreshRateLimitService;
private final ApplicationProperties.Security securityProperties;
private final ApplicationProperties applicationProperties;
/**
* Login endpoint - replaces Supabase signInWithPassword
@@ -177,52 +171,16 @@ public class AuthController {
claims.put("authType", AuthenticationType.WEB.toString());
claims.put("role", user.getRolesAsString());
// Detect desktop client and issue longer-lived tokens for better UX
// Desktop apps run on personal devices with OS-level encryption (secure storage)
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(httpRequest);
String token;
int keyRetentionDays = securityProperties.getJwt().getKeyRetentionDays();
if (isDesktopClient) {
// Desktop: Use configured desktop token expiry (default 30 days)
int desktopExpiryMinutes =
DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties);
token = jwtService.generateToken(user.getUsername(), claims, desktopExpiryMinutes);
log.info(
"Issued DESKTOP token for user '{}': expiry={}min ({}d), keyRetention={}d",
username,
desktopExpiryMinutes,
desktopExpiryMinutes / 1440,
keyRetentionDays);
} else {
// Web: Use configured web expiry (default 24 hours)
token = jwtService.generateToken(user.getUsername(), claims);
int webExpiryMinutes =
DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
log.info(
"Issued WEB token for user '{}': expiry={}min ({}d), keyRetention={}d",
username,
webExpiryMinutes,
webExpiryMinutes / 1440,
keyRetentionDays);
}
String token = jwtService.generateToken(user.getUsername(), claims);
// Record successful login
loginAttemptService.loginSucceeded(username);
log.info(
"Login successful for user: {} from IP: {} (desktop: {})",
username,
ip,
isDesktopClient);
log.info("Login successful for user: {} from IP: {}", username, ip);
return ResponseEntity.ok(
Map.of(
"user", buildUserResponse(user),
"session",
Map.of(
"access_token",
token,
"expires_in",
getTokenExpirySeconds(isDesktopClient))));
"session", Map.of("access_token", token, "expires_in", 3600)));
} catch (UsernameNotFoundException e) {
String username = request.getUsername();
@@ -314,92 +272,25 @@ public class AuthController {
.body(Map.of("error", "No token found"));
}
// Generate token hash for rate limiting (avoid storing actual tokens)
String tokenHash = generateTokenHash(token);
Map<String, Object> claims = jwtService.extractClaimsAllowExpired(token);
if (!isRefreshWithinGrace(claims)) {
log.warn("Token refresh rejected: token expired beyond configured grace window");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
}
// Only apply rate limiting if token is actually expired (not for valid tokens)
// This prevents false-positive 429 errors with multiple tabs, retries, etc.
long expMillis = extractEpochMillis(claims.get("exp"));
boolean isExpired = expMillis > 0 && expMillis < System.currentTimeMillis();
if (isExpired
&& !refreshRateLimitService.isRefreshAllowed(
tokenHash, getRefreshGraceMillis())) {
log.warn(
"Token refresh rejected: rate limit exceeded (max {} attempts allowed)",
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(
Map.of(
"error",
"Too many refresh attempts",
"max_attempts",
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE));
}
Object usernameClaim = claims.get("sub");
String username = usernameClaim != null ? usernameClaim.toString() : null;
if (username == null || username.isBlank()) {
log.warn("Token refresh rejected: missing subject claim");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
}
jwtService.validateToken(token);
String username = jwtService.extractUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
User user = (User) userDetails;
Map<String, Object> newClaims = new HashMap<>();
newClaims.put("authType", user.getAuthenticationType());
newClaims.put("role", user.getRolesAsString());
Map<String, Object> claims = new HashMap<>();
claims.put("authType", user.getAuthenticationType());
claims.put("role", user.getRolesAsString());
// Detect desktop client and issue longer-lived tokens
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
String newToken;
if (isDesktopClient) {
int desktopExpiryMinutes =
DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties);
newToken = jwtService.generateToken(username, newClaims, desktopExpiryMinutes);
log.info(
"Refreshed DESKTOP token for user '{}': expiry={}min ({}d)",
username,
desktopExpiryMinutes,
desktopExpiryMinutes / 1440);
} else {
newToken = jwtService.generateToken(username, newClaims);
int webExpiryMinutes =
DesktopClientUtils.getWebTokenExpiryMinutes(applicationProperties);
log.info(
"Refreshed WEB token for user '{}': expiry={}min ({}d)",
username,
webExpiryMinutes,
webExpiryMinutes / 1440);
}
// Don't clear rate limit tracking - let it expire naturally after grace period
// This prevents reusing the same expired token indefinitely
String newToken = jwtService.generateToken(username, claims);
log.debug("Token refreshed for user: {}", username);
return ResponseEntity.ok(
Map.of(
"user", buildUserResponse(user),
"session",
Map.of(
"access_token",
newToken,
"expires_in",
getTokenExpirySeconds(isDesktopClient))));
"session", Map.of("access_token", newToken, "expires_in", 3600)));
} catch (AuthenticationFailureException e) {
log.warn("Token refresh failed: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("error", "Token refresh failed"));
} catch (Exception e) {
log.error("Token refresh error", e);
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
@@ -641,95 +532,6 @@ public class AuthController {
return userMap;
}
private long getTokenExpirySeconds() {
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
int expiryMinutes =
configuredMinutes > 0
? configuredMinutes
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
return expiryMinutes * JwtConstants.SECONDS_PER_MINUTE;
}
private long getTokenExpirySeconds(boolean isDesktop) {
if (isDesktop) {
// Desktop: use configured desktop token expiry
return DesktopClientUtils.getDesktopTokenExpiryMinutes(applicationProperties)
* JwtConstants.SECONDS_PER_MINUTE;
}
// Web: use configured web value
return getTokenExpirySeconds();
}
private boolean isRefreshWithinGrace(Map<String, Object> claims) {
long expMillis = extractEpochMillis(claims.get("exp"));
if (expMillis <= 0) {
return false;
}
long now = System.currentTimeMillis();
if (expMillis >= now) {
return true;
}
long expiredForMillis = now - expMillis;
return expiredForMillis <= getRefreshGraceMillis();
}
private long getRefreshGraceMillis() {
int configuredMinutes = securityProperties.getJwt().getRefreshGraceMinutes();
int graceMinutes =
configuredMinutes >= 0
? configuredMinutes
: JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
return graceMinutes * JwtConstants.MILLIS_PER_MINUTE;
}
private long extractEpochMillis(Object claimValue) {
if (claimValue == null) {
return -1L;
}
if (claimValue instanceof java.util.Date date) {
return date.getTime();
}
if (claimValue instanceof Number number) {
long epochSeconds = number.longValue();
return epochSeconds * 1000L;
}
return -1L;
}
/**
* Generate a hash of the token for rate limiting purposes.
*
* <p>Uses SHA-256 to avoid storing actual token values in memory.
*
* @param token the JWT token
* @return hex-encoded SHA-256 hash of the token
*/
private String generateTokenHash(String token) {
try {
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
byte[] hashBytes =
digest.digest(token.getBytes(java.nio.charset.StandardCharsets.UTF_8));
StringBuilder hexString = new StringBuilder();
for (byte b : hashBytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
hexString.append('0');
}
hexString.append(hex);
}
return hexString.toString();
} catch (java.security.NoSuchAlgorithmException e) {
// Fallback to hashCode if SHA-256 is not available (should never happen)
log.warn("SHA-256 not available, using hashCode for token tracking", e);
return String.valueOf(token.hashCode());
}
}
private ResponseEntity<?> ensureWebAuth(User user) {
if (!AuthenticationType.WEB.name().equalsIgnoreCase(user.getAuthenticationType())) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
@@ -36,7 +36,6 @@ import stirling.software.proprietary.security.model.AuthenticationType;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
@Slf4j
@RequiredArgsConstructor
@@ -49,7 +48,6 @@ public class CustomOAuth2AuthenticationSuccessHandler
private final JwtServiceInterface jwtService;
private final stirling.software.proprietary.service.UserLicenseSettingsService
licenseSettingsService;
private final ApplicationProperties applicationProperties;
@Override
@Audited(type = AuditEventType.USER_LOGIN, level = AuditLevel.BASIC)
@@ -152,27 +150,9 @@ public class CustomOAuth2AuthenticationSuccessHandler
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
Map<String, Object> claims = Map.of("authType", AuthenticationType.OAUTH2);
// Detect desktop client and issue longer-lived tokens
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
String jwt;
if (isDesktopClient) {
// Desktop: Use configured desktop token expiry (default 30 days)
int desktopExpiryMinutes =
DesktopClientUtils.getDesktopTokenExpiryMinutes(
applicationProperties);
jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
log.info(
"Issued DESKTOP OAuth2 token for user '{}': expiry={}min ({}d)",
username,
desktopExpiryMinutes,
desktopExpiryMinutes / 1440);
} else {
// Web: Use default expiry
jwt = jwtService.generateToken(authentication, claims);
log.debug("Issued WEB OAuth2 token for user '{}'", username);
}
String jwt =
jwtService.generateToken(
authentication, Map.of("authType", AuthenticationType.OAUTH2));
// Build context-aware redirect URL based on the original request
String redirectUrl =
@@ -37,7 +37,6 @@ import stirling.software.proprietary.security.oauth2.TauriOAuthUtils;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.UserService;
import stirling.software.proprietary.security.util.DesktopClientUtils;
@AllArgsConstructor
@Slf4j
@@ -192,27 +191,10 @@ public class CustomSaml2AuthenticationSuccessHandler
// Generate JWT if v2 is enabled
if (jwtService.isJwtEnabled()) {
Map<String, Object> claims = Map.of("authType", AuthenticationType.SAML2);
// Detect desktop client and issue longer-lived tokens
boolean isDesktopClient = DesktopClientUtils.isDesktopClient(request);
String jwt;
if (isDesktopClient) {
// Desktop: Use configured desktop token expiry (default 30 days)
int desktopExpiryMinutes =
DesktopClientUtils.getDesktopTokenExpiryMinutes(
applicationProperties);
jwt = jwtService.generateToken(username, claims, desktopExpiryMinutes);
log.info(
"Issued DESKTOP SAML token for user '{}': expiry={}min ({}d)",
username,
desktopExpiryMinutes,
desktopExpiryMinutes / 1440);
} else {
// Web: Use default expiry
jwt = jwtService.generateToken(authentication, claims);
log.debug("Issued WEB SAML token for user '{}'", username);
}
String jwt =
jwtService.generateToken(
authentication,
Map.of("authType", AuthenticationType.SAML2));
// Build context-aware redirect URL based on the original request
String redirectUrl =
@@ -5,7 +5,6 @@ import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.time.LocalDateTime;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
@@ -20,9 +19,6 @@ import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.stereotype.Service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.Jwts;
@@ -34,8 +30,6 @@ import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.JwtVerificationKey;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrincipal;
@@ -44,20 +38,18 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
@Service
public class JwtService implements JwtServiceInterface {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private static final String ISSUER = "https://stirling.com";
private static final long EXPIRATION = 43200000;
private final KeyPersistenceServiceInterface keyPersistenceService;
private final boolean v2Enabled;
private final ApplicationProperties.Security securityProperties;
@Autowired
public JwtService(
@Qualifier("v2Enabled") boolean v2Enabled,
KeyPersistenceServiceInterface keyPersistenceService,
ApplicationProperties applicationProperties) {
KeyPersistenceServiceInterface keyPersistenceService) {
this.v2Enabled = v2Enabled;
this.keyPersistenceService = keyPersistenceService;
this.securityProperties = applicationProperties.getSecurity();
}
@Override
@@ -92,10 +84,9 @@ public class JwtService implements JwtServiceInterface {
Jwts.builder()
.claims(claims)
.subject(username)
.issuer(JwtConstants.ISSUER)
.issuer(ISSUER)
.issuedAt(new Date())
.expiration(
new Date(System.currentTimeMillis() + getExpirationMillis()))
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
String keyId = activeKey.getKeyId();
@@ -109,40 +100,6 @@ public class JwtService implements JwtServiceInterface {
}
}
@Override
public String generateToken(String username, Map<String, Object> claims, int expiryMinutes) {
try {
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
Optional<KeyPair> keyPairOpt = keyPersistenceService.getKeyPair(activeKey.getKeyId());
if (keyPairOpt.isEmpty()) {
throw new RuntimeException("Unable to retrieve key pair for active key");
}
KeyPair keyPair = keyPairOpt.get();
long customExpirationMillis = expiryMinutes * JwtConstants.MILLIS_PER_MINUTE;
var builder =
Jwts.builder()
.claims(claims)
.subject(username)
.issuer(JwtConstants.ISSUER)
.issuedAt(new Date())
.expiration(
new Date(System.currentTimeMillis() + customExpirationMillis))
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
String keyId = activeKey.getKeyId();
if (keyId != null) {
builder.header().keyId(keyId);
}
return builder.compact();
} catch (Exception e) {
throw new RuntimeException("Failed to generate token with custom expiry", e);
}
}
@Override
public void validateToken(String token) throws AuthenticationFailureException {
extractAllClaims(token);
@@ -157,23 +114,12 @@ public class JwtService implements JwtServiceInterface {
return extractClaim(token, Claims::getSubject);
}
@Override
public String extractUsernameAllowExpired(String token) {
return extractClaim(token, Claims::getSubject, true);
}
@Override
public Map<String, Object> extractClaims(String token) {
Claims claims = extractAllClaims(token);
return new HashMap<>(claims);
}
@Override
public Map<String, Object> extractClaimsAllowExpired(String token) {
Claims claims = extractAllClaims(token, true);
return new HashMap<>(claims);
}
@Override
public boolean isTokenExpired(String token) {
return extractExpiration(token).before(new Date());
@@ -184,21 +130,11 @@ public class JwtService implements JwtServiceInterface {
}
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
final Claims claims = extractAllClaims(token, false);
return claimsResolver.apply(claims);
}
private <T> T extractClaim(
String token, Function<Claims, T> claimsResolver, boolean allowExpired) {
final Claims claims = extractAllClaims(token, allowExpired);
final Claims claims = extractAllClaims(token);
return claimsResolver.apply(claims);
}
private Claims extractAllClaims(String token) {
return extractAllClaims(token, false);
}
private Claims extractAllClaims(String token, boolean allowExpired) {
try {
String keyId = extractKeyId(token);
KeyPair keyPair;
@@ -240,12 +176,11 @@ public class JwtService implements JwtServiceInterface {
} else {
log.debug("No key ID in token header, trying all available keys");
// Try all available keys when no keyId is present
return tryAllKeys(token, allowExpired);
return tryAllKeys(token);
}
return Jwts.parser()
.verifyWith(keyPair.getPublic())
.clockSkewSeconds(getAllowedClockSkewSeconds())
.build()
.parseSignedClaims(token)
.getPayload();
@@ -256,13 +191,7 @@ public class JwtService implements JwtServiceInterface {
log.warn("Invalid token: {}", e.getMessage());
throw new AuthenticationFailureException("Invalid token", e);
} catch (ExpiredJwtException e) {
if (allowExpired) {
log.debug(
"Extracting claims from expired token (allowed for refresh grace period): {}",
e.getMessage());
return e.getClaims();
}
log.warn("Token validation failed - token has expired: {}", e.getMessage());
log.warn("The token has expired: {}", e.getMessage());
throw new AuthenticationFailureException("The token has expired", e);
} catch (UnsupportedJwtException e) {
log.warn("The token is unsupported: {}", e.getMessage());
@@ -273,8 +202,7 @@ public class JwtService implements JwtServiceInterface {
}
}
private Claims tryAllKeys(String token, boolean allowExpired)
throws AuthenticationFailureException {
private Claims tryAllKeys(String token) throws AuthenticationFailureException {
// First try the active key
try {
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
@@ -282,18 +210,9 @@ public class JwtService implements JwtServiceInterface {
keyPersistenceService.decodePublicKey(activeKey.getVerifyingKey());
return Jwts.parser()
.verifyWith(publicKey)
.clockSkewSeconds(getAllowedClockSkewSeconds())
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
if (allowExpired) {
log.debug(
"Extracting claims from expired token (allowed for refresh grace period)");
return e.getClaims();
}
log.warn("Token validation failed - token has expired");
throw new AuthenticationFailureException("The token has expired", e);
} catch (SignatureException
| NoSuchAlgorithmException
| InvalidKeySpecException activeKeyException) {
@@ -311,15 +230,9 @@ public class JwtService implements JwtServiceInterface {
verificationKey.getVerifyingKey());
return Jwts.parser()
.verifyWith(publicKey)
.clockSkewSeconds(getAllowedClockSkewSeconds())
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
if (allowExpired) {
return e.getClaims();
}
throw new AuthenticationFailureException("The token has expired", e);
} catch (SignatureException
| NoSuchAlgorithmException
| InvalidKeySpecException e) {
@@ -353,51 +266,24 @@ public class JwtService implements JwtServiceInterface {
return v2Enabled;
}
/**
* Extract key ID from JWT header without validating the token.
*
* <p>Parses the Base64-encoded JWT header to retrieve the "kid" (key ID) claim. Returns null if
* the header cannot be parsed or does not contain a key ID.
*
* @param token the JWT token
* @return the key ID, or null if not found or parsing fails
*/
private String extractKeyId(String token) {
try {
String[] tokenParts = token.split("\\.");
if (tokenParts.length < 2) {
log.debug(
"Token does not have enough parts (expected at least 2, got {})",
tokenParts.length);
return null;
}
PublicKey signingKey =
keyPersistenceService.decodePublicKey(
keyPersistenceService.getActiveKey().getVerifyingKey());
byte[] headerBytes = Base64.getUrlDecoder().decode(tokenParts[0]);
Map<String, Object> header =
OBJECT_MAPPER.readValue(
headerBytes, new TypeReference<Map<String, Object>>() {});
Object keyId = header.get("kid");
return keyId instanceof String ? (String) keyId : null;
} catch (IllegalArgumentException e) {
log.debug("Failed to decode Base64 JWT header: {}", e.getMessage());
return null;
} catch (java.io.IOException e) {
log.debug("Failed to parse JWT header as JSON: {}", e.getMessage());
String keyId =
(String)
Jwts.parser()
.verifyWith(signingKey)
.build()
.parse(token)
.getHeader()
.get("kid");
return keyId;
} catch (Exception e) {
log.debug("Failed to extract key ID from token header: {}", e.getMessage());
return null;
}
}
private long getExpirationMillis() {
int configuredMinutes = securityProperties.getJwt().getTokenExpiryMinutes();
int expiryMinutes =
configuredMinutes > 0
? configuredMinutes
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
return expiryMinutes * JwtConstants.MILLIS_PER_MINUTE;
}
private long getAllowedClockSkewSeconds() {
int configuredSeconds = securityProperties.getJwt().getAllowedClockSkewSeconds();
return configuredSeconds >= 0 ? configuredSeconds : JwtConstants.DEFAULT_CLOCK_SKEW_SECONDS;
}
}
@@ -25,16 +25,6 @@ public interface JwtServiceInterface {
*/
String generateToken(String username, Map<String, Object> claims);
/**
* Generate a JWT token for a specific username with custom expiry
*
* @param username the username for which to generate the token
* @param claims additional claims to include in the token
* @param expiryMinutes custom token lifetime in minutes
* @return JWT token as a string
*/
String generateToken(String username, Map<String, Object> claims, int expiryMinutes);
/**
* Validate a JWT token
*
@@ -51,15 +41,6 @@ public interface JwtServiceInterface {
*/
String extractUsername(String token);
/**
* Extract username from JWT token while allowing expired tokens. Signature and token structure
* must still be valid.
*
* @param token the JWT token
* @return username extracted from token
*/
String extractUsernameAllowExpired(String token);
/**
* Extract all claims from JWT token
*
@@ -68,15 +49,6 @@ public interface JwtServiceInterface {
*/
Map<String, Object> extractClaims(String token);
/**
* Extract all claims from JWT token while allowing expired tokens. Signature and token
* structure must still be valid.
*
* @param token the JWT token
* @return map of claims
*/
Map<String, Object> extractClaimsAllowExpired(String token);
/**
* Check if token is expired
*
@@ -10,10 +10,8 @@ import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.interfaces.RSAPrivateCrtKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.security.spec.RSAPublicKeySpec;
import java.security.spec.X509EncodedKeySpec;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
@@ -43,7 +41,6 @@ import stirling.software.proprietary.security.model.JwtVerificationKey;
public class KeyPersistenceService implements KeyPersistenceServiceInterface {
public static final String KEY_SUFFIX = ".key";
public static final String PUB_KEY_SUFFIX = ".pub";
private final ApplicationProperties.Security.Jwt jwtProperties;
private final CacheManager cacheManager;
@@ -62,119 +59,19 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
@PostConstruct
public void initializeKeystore() {
if (!isKeystoreEnabled()) {
log.info("JWT keystore is disabled - keys will be generated in memory");
return;
}
try {
ensurePrivateKeyDirectoryExists();
loadExistingKeysFromDisk();
loadKeyPair();
} catch (Exception e) {
log.error("Failed to initialize keystore, using in-memory generation", e);
}
}
/**
* Load all existing JWT keys from disk into memory on startup.
*
* <p>This ensures tokens signed with previous keys remain valid after server restart. If no
* keys exist on disk, generates a new keypair.
*/
private void loadExistingKeysFromDisk() {
try {
Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
if (!Files.exists(keyDirectory)) {
log.info("No existing keys found, generating new keypair");
generateAndStoreKeypair();
return;
}
List<Path> keyFiles;
try (var stream = Files.list(keyDirectory)) {
keyFiles =
stream.filter(path -> path.toString().endsWith(KEY_SUFFIX))
.sorted(
(a, b) ->
b.getFileName().compareTo(a.getFileName())) // Most
// recent
// first
.collect(Collectors.toList());
}
if (keyFiles.isEmpty()) {
log.info("No existing keys found in directory, generating new keypair");
generateAndStoreKeypair();
return;
}
log.info("Loading {} existing JWT keys from disk", keyFiles.size());
int loadedCount = 0;
for (Path keyFile : keyFiles) {
try {
String keyId = keyFile.getFileName().toString().replace(KEY_SUFFIX, "");
// Load private key first
PrivateKey privateKey = loadPrivateKey(keyId);
// Try to load public key, or generate it from private key if missing
// (migration)
String encodedPublicKey;
try {
encodedPublicKey = loadPublicKey(keyId);
} catch (IOException e) {
// Public key file doesn't exist - generate it from private key (migration)
log.info("Migrating legacy key: generating public key file for {}", keyId);
KeyPair keyPair = reconstructKeyPair(privateKey);
// Save the public key file
Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX);
encodedPublicKey = encodePublicKey(keyPair.getPublic());
Files.writeString(publicKeyFile, encodedPublicKey);
publicKeyFile.toFile().setReadable(true, true);
publicKeyFile.toFile().setWritable(true, true);
publicKeyFile.toFile().setExecutable(false, false);
log.info("Successfully migrated key: {}", keyId);
}
// Create verification key and add to cache
JwtVerificationKey verifyingKey =
new JwtVerificationKey(keyId, encodedPublicKey);
verifyingKeyCache.put(keyId, verifyingKey);
loadedCount++;
// Set the most recent key as active (first in sorted list)
if (activeKey == null) {
activeKey = verifyingKey;
log.info("Set active JWT signing key: {}", keyId);
} else {
log.debug(
"Loaded historical JWT key: {} (created: {})",
keyId,
verifyingKey.getCreatedAt());
}
} catch (Exception e) {
log.warn(
"Failed to load key: {}, skipping. Error: {}",
keyFile.getFileName(),
e.getMessage());
}
}
if (loadedCount == 0) {
log.warn("No valid keys could be loaded from disk, generating new keypair");
generateAndStoreKeypair();
} else {
log.info(
"Successfully loaded {} JWT keys, active key: {}",
loadedCount,
activeKey.getKeyId());
}
} catch (IOException e) {
log.error("Failed to load keys from disk, generating new keypair", e);
private void loadKeyPair() {
if (activeKey == null) {
generateAndStoreKeypair();
}
}
@@ -187,11 +84,10 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
KeyPair keyPair = generateRSAKeypair();
String keyId = generateKeyId();
storeKeyPair(keyId, keyPair);
storePrivateKey(keyId, keyPair.getPrivate());
verifyingKey = new JwtVerificationKey(keyId, encodePublicKey(keyPair.getPublic()));
verifyingKeyCache.put(keyId, verifyingKey);
activeKey = verifyingKey;
log.info("Generated and stored new JWT keypair: {}", keyId);
} catch (IOException e) {
log.error("Failed to generate and store keypair", e);
}
@@ -304,43 +200,16 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
}
}
/**
* Store both private and public keys to disk.
*
* <p>Private key stored as: keyId.key
*
* <p>Public key stored as: keyId.pub
*/
private void storeKeyPair(String keyId, KeyPair keyPair) throws IOException {
Path keyDirectory = Paths.get(InstallationPathConfig.getPrivateKeyPath());
private void storePrivateKey(String keyId, PrivateKey privateKey) throws IOException {
Path keyFile =
Paths.get(InstallationPathConfig.getPrivateKeyPath()).resolve(keyId + KEY_SUFFIX);
String encodedKey = Base64.getEncoder().encodeToString(privateKey.getEncoded());
Files.writeString(keyFile, encodedKey);
// Store private key
Path privateKeyFile = keyDirectory.resolve(keyId + KEY_SUFFIX);
String encodedPrivateKey =
Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded());
Files.writeString(privateKeyFile, encodedPrivateKey);
// Set read/write to only the owner (security)
privateKeyFile.toFile().setReadable(true, true);
privateKeyFile.toFile().setWritable(true, true);
privateKeyFile.toFile().setExecutable(false, false);
// Store public key
Path publicKeyFile = keyDirectory.resolve(keyId + PUB_KEY_SUFFIX);
String encodedPublicKey =
Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded());
Files.writeString(publicKeyFile, encodedPublicKey);
// Public key can be more permissive but still restrict to owner
publicKeyFile.toFile().setReadable(true, true);
publicKeyFile.toFile().setWritable(true, true);
publicKeyFile.toFile().setExecutable(false, false);
log.debug(
"Stored keypair to disk: {} (private: {}, public: {})",
keyId,
privateKeyFile.getFileName(),
publicKeyFile.getFileName());
// Set read/write to only the owner
keyFile.toFile().setReadable(true, true);
keyFile.toFile().setWritable(true, true);
keyFile.toFile().setExecutable(false, false);
}
private PrivateKey loadPrivateKey(String keyId)
@@ -360,53 +229,6 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
return keyFactory.generatePrivate(keySpec);
}
/**
* Load public key from disk.
*
* @param keyId the key identifier
* @return Base64-encoded public key string
* @throws IOException if the public key file is not found
*/
private String loadPublicKey(String keyId) throws IOException {
Path publicKeyFile =
Paths.get(InstallationPathConfig.getPrivateKeyPath())
.resolve(keyId + PUB_KEY_SUFFIX);
if (!Files.exists(publicKeyFile)) {
throw new IOException("Public key not found: " + publicKeyFile);
}
return Files.readString(publicKeyFile).trim();
}
/**
* Reconstruct a KeyPair from a PrivateKey.
*
* <p>For RSA keys, derives the public key from the private key.
*
* @param privateKey the RSA private key
* @return reconstructed KeyPair
* @throws NoSuchAlgorithmException if RSA algorithm is not available
* @throws InvalidKeySpecException if the key specification is invalid
*/
private KeyPair reconstructKeyPair(PrivateKey privateKey)
throws NoSuchAlgorithmException, InvalidKeySpecException {
// For RSA, we can derive the public key from the private key
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
// Get the private key spec
RSAPrivateCrtKey rsaPrivateKey = (RSAPrivateCrtKey) privateKey;
// Create public key spec from private key parameters
RSAPublicKeySpec publicKeySpec =
new RSAPublicKeySpec(rsaPrivateKey.getModulus(), rsaPrivateKey.getPublicExponent());
// Generate public key
PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
return new KeyPair(publicKey, privateKey);
}
private String encodePublicKey(PublicKey publicKey) {
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
}
@@ -1,124 +0,0 @@
package stirling.software.proprietary.security.service;
import java.time.Instant;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.ApplicationProperties;
/**
* Service to rate limit token refresh attempts within the grace period.
*
* <p>Prevents abuse of expired tokens by tracking and limiting refresh attempts per token. Tokens
* are identified by a hash to avoid storing actual token values.
*/
@Service
@Slf4j
public class RefreshRateLimitService {
private final ApplicationProperties.Security.Jwt jwtProperties;
@Autowired
public RefreshRateLimitService(ApplicationProperties applicationProperties) {
this.jwtProperties = applicationProperties.getSecurity().getJwt();
}
private static class RefreshAttempt {
private final AtomicInteger count = new AtomicInteger(0);
private final Instant firstAttempt = Instant.now();
int incrementAndGet() {
return count.incrementAndGet();
}
Instant getFirstAttempt() {
return firstAttempt;
}
int getCount() {
return count.get();
}
}
private final Map<String, RefreshAttempt> attempts = new ConcurrentHashMap<>();
/**
* Check if a refresh attempt is allowed for the given token.
*
* @param tokenHash hash of the token attempting refresh
* @param graceWindowMillis the configured grace window in milliseconds
* @return true if refresh is allowed, false if rate limit exceeded
*/
public boolean isRefreshAllowed(String tokenHash, long graceWindowMillis) {
RefreshAttempt attempt = attempts.computeIfAbsent(tokenHash, k -> new RefreshAttempt());
int attemptCount = attempt.incrementAndGet();
if (attemptCount > JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE) {
log.warn(
"Refresh rate limit exceeded for token (attempt {}). Token hash: {}",
attemptCount,
tokenHash.substring(0, Math.min(8, tokenHash.length())));
return false;
}
// Clean up if outside grace window
Instant cutoff = Instant.now().minusMillis(graceWindowMillis);
if (attempt.getFirstAttempt().isBefore(cutoff)) {
attempts.remove(tokenHash);
}
return true;
}
/**
* Remove tracking for a token after successful refresh.
*
* @param tokenHash hash of the refreshed token
*/
public void clearRefreshAttempts(String tokenHash) {
attempts.remove(tokenHash);
}
/** Clean up expired tracking entries every 5 minutes. */
@Scheduled(fixedRate = 300000)
public void cleanupExpiredEntries() {
// Use configured grace period with same normalization as runtime checks
int configuredMinutes = jwtProperties.getRefreshGraceMinutes();
int graceMinutes =
configuredMinutes >= 0
? configuredMinutes
: JwtConstants.DEFAULT_REFRESH_GRACE_MINUTES;
Instant cutoff = Instant.now().minusMillis(graceMinutes * 60000L);
int removed =
attempts.entrySet().stream()
.filter(entry -> entry.getValue().getFirstAttempt().isBefore(cutoff))
.mapToInt(
entry -> {
attempts.remove(entry.getKey());
return 1;
})
.sum();
if (removed > 0) {
log.debug("Cleaned up {} expired refresh tracking entries", removed);
}
}
/** Get current tracking statistics for monitoring. */
public Map<String, Object> getStats() {
return Map.of(
"tracked_tokens",
attempts.size(),
"max_attempts_allowed",
JwtConstants.MAX_REFRESH_ATTEMPTS_IN_GRACE);
}
}
@@ -1,82 +0,0 @@
package stirling.software.proprietary.security.util;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.constants.JwtConstants;
import stirling.software.common.model.ApplicationProperties;
/**
* Utility class for detecting desktop clients and determining appropriate token expiry times.
*
* <p>Desktop clients (Tauri, Electron) receive longer-lived tokens because:
*
* <ul>
* <li>They run on personal devices (not shared computers)
* <li>Tokens stored in OS-level encrypted keychain (not browser localStorage)
* <li>Better UX (users expect desktop apps to stay logged in)
* </ul>
*/
@Slf4j
public class DesktopClientUtils {
private DesktopClientUtils() {
// Utility class - prevent instantiation
}
/**
* Detect if the request is from a desktop client (Tauri app).
*
* @param request the HTTP request
* @return true if desktop client, false if web browser
*/
public static boolean isDesktopClient(HttpServletRequest request) {
String userAgent = request.getHeader("User-Agent");
if (userAgent == null) {
return false;
}
// Tauri desktop app includes "Tauri" or "tauri-plugin" in User-Agent
// Also check for common desktop app identifiers
String userAgentLower = userAgent.toLowerCase();
boolean hasTauri = userAgentLower.contains("tauri");
boolean hasStirling = userAgentLower.contains("stirlingpdf-desktop");
boolean hasElectron = userAgentLower.contains("electron");
boolean isDesktop = hasTauri || hasStirling || hasElectron;
log.debug("Desktop client detection: {} (User-Agent: {})", isDesktop, userAgent);
return isDesktop;
}
/**
* Get the configured desktop token expiry time in minutes.
*
* @param applicationProperties the application properties
* @return desktop token expiry in minutes (defaults to 30 days if not configured)
*/
public static int getDesktopTokenExpiryMinutes(ApplicationProperties applicationProperties) {
int configuredMinutes =
applicationProperties.getSecurity().getJwt().getDesktopTokenExpiryMinutes();
// If not configured or invalid, default to 30 days (43200 minutes)
return configuredMinutes > 0
? configuredMinutes
: JwtConstants.DEFAULT_DESKTOP_TOKEN_EXPIRY_MINUTES;
}
/**
* Get the configured web token expiry time in minutes.
*
* @param applicationProperties the application properties
* @return web token expiry in minutes
*/
public static int getWebTokenExpiryMinutes(ApplicationProperties applicationProperties) {
int configuredMinutes =
applicationProperties.getSecurity().getJwt().getTokenExpiryMinutes();
return configuredMinutes > 0
? configuredMinutes
: JwtConstants.DEFAULT_TOKEN_EXPIRY_MINUTES;
}
}
@@ -10,8 +10,6 @@ import static org.springframework.test.web.servlet.request.MockMvcRequestBuilder
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
@@ -38,7 +36,6 @@ import stirling.software.proprietary.security.service.CustomUserDetailsService;
import stirling.software.proprietary.security.service.JwtServiceInterface;
import stirling.software.proprietary.security.service.LoginAttemptService;
import stirling.software.proprietary.security.service.MfaService;
import stirling.software.proprietary.security.service.RefreshRateLimitService;
import stirling.software.proprietary.security.service.TotpService;
import stirling.software.proprietary.security.service.UserService;
@@ -56,17 +53,11 @@ class AuthControllerLoginTest {
@Mock private LoginAttemptService loginAttemptService;
@Mock private MfaService mfaService;
@Mock private TotpService totpService;
@Mock private RefreshRateLimitService refreshRateLimitService;
@BeforeEach
void setUp() {
securityProperties = new ApplicationProperties.Security();
securityProperties.setLoginMethod("all");
securityProperties.getJwt().setTokenExpiryMinutes(60);
securityProperties.getJwt().setRefreshGraceMinutes(5);
ApplicationProperties applicationProperties = new ApplicationProperties();
applicationProperties.setSecurity(securityProperties);
AuthController controller =
new AuthController(
@@ -76,9 +67,7 @@ class AuthControllerLoginTest {
loginAttemptService,
mfaService,
totpService,
refreshRateLimitService,
securityProperties,
applicationProperties);
securityProperties);
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
}
@@ -186,11 +175,7 @@ class AuthControllerLoginTest {
void refreshReturnsNewTokenWhenValid() throws Exception {
User user = buildUser();
when(jwtService.extractToken(any())).thenReturn("old");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "user@example.com");
claims.put("exp", new Date(System.currentTimeMillis() + 60_000));
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
// Rate limiting is not checked for valid tokens, so no stub needed
when(jwtService.extractUsername("old")).thenReturn("user@example.com");
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
.thenReturn("new-token");
@@ -199,75 +184,7 @@ class AuthControllerLoginTest {
.andExpect(status().isOk())
.andExpect(jsonPath("$.user").exists())
.andExpect(jsonPath("$.session.access_token").value("new-token"))
.andExpect(
jsonPath("$.session.expires_in")
.value(3600)); // 60 minutes * 60 = 3600 seconds
// clearRefreshAttempts is intentionally not called - tokens expire naturally after grace
// period
}
@Test
void refreshRejectsTokenExpiredBeyondGrace() throws Exception {
when(jwtService.extractToken(any())).thenReturn("old");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "user@example.com");
claims.put(
"exp",
new Date(
System.currentTimeMillis()
- (10 * 60_000))); // 10 minutes ago, beyond 5 minute grace
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.error").value("Token refresh failed"));
verify(userDetailsService, never()).loadUserByUsername(any());
verify(refreshRateLimitService, never()).isRefreshAllowed(any(), any(Long.class));
}
@Test
void refreshAcceptsTokenExpiredWithinGrace() throws Exception {
User user = buildUser();
when(jwtService.extractToken(any())).thenReturn("old");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "user@example.com");
claims.put(
"exp",
new Date(
System.currentTimeMillis()
- 60_000)); // 1 minute ago, within 5 minute grace
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(true);
when(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
.thenReturn("new-token");
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.session.access_token").value("new-token"));
// clearRefreshAttempts is intentionally not called - tokens expire naturally after grace
// period
}
@Test
void refreshRejectsWhenRateLimitExceeded() throws Exception {
when(jwtService.extractToken(any())).thenReturn("old");
Map<String, Object> claims = new HashMap<>();
claims.put("sub", "user@example.com");
claims.put("exp", new Date(System.currentTimeMillis() - 60_000)); // 1 minute ago
when(jwtService.extractClaimsAllowExpired("old")).thenReturn(claims);
when(refreshRateLimitService.isRefreshAllowed(any(), any(Long.class))).thenReturn(false);
mockMvc.perform(post("/api/v1/auth/refresh"))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.error").value("Too many refresh attempts"))
.andExpect(jsonPath("$.max_attempts").exists());
verify(userDetailsService, never()).loadUserByUsername(any());
verify(refreshRateLimitService, never()).clearRefreshAttempts(any());
.andExpect(jsonPath("$.session.expires_in").value(3600));
}
@Test
@@ -37,19 +37,13 @@ class CustomOAuth2AuthenticationSuccessHandlerTest {
oauth2Props.setAutoCreateUser(true);
oauth2Props.setBlockRegistration(false);
ApplicationProperties applicationProperties = new ApplicationProperties();
ApplicationProperties.Security securityProperties = new ApplicationProperties.Security();
securityProperties.setOauth2(oauth2Props);
applicationProperties.setSecurity(securityProperties);
CustomOAuth2AuthenticationSuccessHandler handler =
new CustomOAuth2AuthenticationSuccessHandler(
loginAttemptService,
oauth2Props,
userService,
jwtService,
licenseSettingsService,
applicationProperties);
licenseSettingsService);
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
@@ -31,7 +31,6 @@ import org.springframework.security.core.Authentication;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import stirling.software.common.model.ApplicationProperties;
import stirling.software.proprietary.security.model.JwtVerificationKey;
import stirling.software.proprietary.security.model.User;
import stirling.software.proprietary.security.model.exception.AuthenticationFailureException;
@@ -65,8 +64,7 @@ class JwtServiceTest {
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
ApplicationProperties applicationProperties = new ApplicationProperties();
jwtService = new JwtService(true, keystoreService, applicationProperties);
jwtService = new JwtService(true, keystoreService);
}
@Test
@@ -75,6 +73,8 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -94,6 +94,8 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -112,6 +114,8 @@ class JwtServiceTest {
void testValidateTokenSuccess() throws Exception {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn("testuser");
@@ -175,6 +179,8 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(user);
when(user.getUsername()).thenReturn(username);
@@ -201,6 +207,8 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -273,6 +281,8 @@ class JwtServiceTest {
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
@@ -297,6 +307,8 @@ class JwtServiceTest {
// First, generate a token successfully
when(keystoreService.getActiveKey()).thenReturn(testVerificationKey);
when(keystoreService.getKeyPair("test-key-id")).thenReturn(Optional.of(testKeyPair));
when(keystoreService.decodePublicKey(testVerificationKey.getVerifyingKey()))
.thenReturn(testKeyPair.getPublic());
when(authentication.getPrincipal()).thenReturn(userDetails);
when(userDetails.getUsername()).thenReturn(username);
+1 -1
View File
@@ -67,7 +67,7 @@ springBoot {
allprojects {
group = 'stirling.software'
version = '2.5.1'
version = '2.4.5'
configurations.configureEach {
exclude group: 'commons-logging', module: 'commons-logging'
+183 -247
View File
@@ -10,31 +10,30 @@
"license": "SEE LICENSE IN https://raw.githubusercontent.com/Stirling-Tools/Stirling-PDF/refs/heads/main/proprietary/LICENSE",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@cantoo/pdf-lib": "^2.5.3",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.6.0",
"@embedpdf/engines": "^2.6.0",
"@embedpdf/models": "^2.6.0",
"@embedpdf/plugin-annotation": "^2.6.0",
"@embedpdf/plugin-attachment": "^2.6.0",
"@embedpdf/plugin-bookmark": "^2.6.0",
"@embedpdf/plugin-document-manager": "^2.6.0",
"@embedpdf/plugin-export": "^2.6.0",
"@embedpdf/plugin-history": "^2.6.0",
"@embedpdf/plugin-interaction-manager": "^2.6.0",
"@embedpdf/plugin-pan": "^2.6.0",
"@embedpdf/plugin-print": "^2.6.0",
"@embedpdf/plugin-redaction": "^2.6.0",
"@embedpdf/plugin-render": "^2.6.0",
"@embedpdf/plugin-rotate": "^2.6.0",
"@embedpdf/plugin-scroll": "^2.6.0",
"@embedpdf/plugin-search": "^2.6.0",
"@embedpdf/plugin-selection": "^2.6.0",
"@embedpdf/plugin-spread": "^2.6.0",
"@embedpdf/plugin-thumbnail": "^2.6.0",
"@embedpdf/plugin-tiling": "^2.6.0",
"@embedpdf/plugin-viewport": "^2.6.0",
"@embedpdf/plugin-zoom": "^2.6.0",
"@embedpdf/core": "^2.5.0",
"@embedpdf/engines": "^2.5.0",
"@embedpdf/models": "^2.5.0",
"@embedpdf/plugin-annotation": "^2.5.0",
"@embedpdf/plugin-attachment": "^2.5.0",
"@embedpdf/plugin-bookmark": "^2.5.0",
"@embedpdf/plugin-document-manager": "^2.5.0",
"@embedpdf/plugin-export": "^2.5.0",
"@embedpdf/plugin-history": "^2.5.0",
"@embedpdf/plugin-interaction-manager": "^2.5.0",
"@embedpdf/plugin-pan": "^2.5.0",
"@embedpdf/plugin-print": "^2.5.0",
"@embedpdf/plugin-redaction": "^2.5.0",
"@embedpdf/plugin-render": "^2.5.0",
"@embedpdf/plugin-rotate": "^2.5.0",
"@embedpdf/plugin-scroll": "^2.5.0",
"@embedpdf/plugin-search": "^2.5.0",
"@embedpdf/plugin-selection": "^2.5.0",
"@embedpdf/plugin-spread": "^2.5.0",
"@embedpdf/plugin-thumbnail": "^2.5.0",
"@embedpdf/plugin-tiling": "^2.5.0",
"@embedpdf/plugin-viewport": "^2.5.0",
"@embedpdf/plugin-zoom": "^2.5.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -62,6 +61,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"jszip": "^3.10.1",
"license-report": "^6.8.0",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.149",
"peerjs": "^1.5.5",
"posthog-js": "^1.268.0",
@@ -366,21 +366,6 @@
"node": ">=18"
}
},
"node_modules/@cantoo/pdf-lib": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/@cantoo/pdf-lib/-/pdf-lib-2.5.3.tgz",
"integrity": "sha512-SBQp8i/XdWNUhLutn5P67Pwj4X9vU046BRpfOMODJZuYVrgChtsTfgdnlW2O7x8gdXs8j7NoTaWI/b78E2oVmQ==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"color": "^4.2.3",
"crypto-js": "^4.2.0",
"node-html-better-parser": ">=1.4.0",
"pako": "^1.0.11",
"tslib": ">=2"
}
},
"node_modules/@csstools/color-helpers": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz",
@@ -570,13 +555,13 @@
}
},
"node_modules/@embedpdf/core": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.6.0.tgz",
"integrity": "sha512-859GUvZ3BLpJuKTiwcPPMNn9CSlMaPjQ4yXnyQRngfbvDAiijIIpVLaC98B08Nx6QsUcD3cs/6+wkB888lNsDw==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.5.0.tgz",
"integrity": "sha512-nI7GnA5xCNtJHAdKBLPKJVvi4+yAKjy1sysaDf+qp+z3D81Hy8oAcl///QTaZ9ob0SL2jyqi3x//hKl0Rwmgrw==",
"license": "MIT",
"dependencies": {
"@embedpdf/engines": "2.6.0",
"@embedpdf/models": "2.6.0"
"@embedpdf/engines": "2.5.0",
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -587,9 +572,9 @@
}
},
"node_modules/@embedpdf/engines": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.6.0.tgz",
"integrity": "sha512-zW3927u0wbFBD2tQLWbE45DEBIMkZyN7n5O2p70er6u7mP1XYEz7Ud9NxcPL/3b5MzDfPBTSyxM3T12e+ZeAxw==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.5.0.tgz",
"integrity": "sha512-SEknNmQrYvkAZgJllRKXuvXSrHSndDQsr7b3mrIVa9bzV6TeZua0a/YUlvI3/jf74Sdajru3XKPe22iHEOH4Zg==",
"license": "MIT",
"dependencies": {
"@embedpdf/fonts-arabic": "1.0.0",
@@ -599,8 +584,8 @@
"@embedpdf/fonts-latin": "1.0.0",
"@embedpdf/fonts-sc": "1.0.0",
"@embedpdf/fonts-tc": "1.0.0",
"@embedpdf/models": "2.6.0",
"@embedpdf/pdfium": "2.6.0"
"@embedpdf/models": "2.5.0",
"@embedpdf/pdfium": "2.5.0"
},
"peerDependencies": {
"preact": "^10.26.4",
@@ -653,31 +638,31 @@
"license": "OFL-1.1"
},
"node_modules/@embedpdf/models": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.6.0.tgz",
"integrity": "sha512-6zuoJE79WXyRXKhJXhl+8p4njuC1nxPpKYRIs54PRLgTkHOLaou+G+ZunEd99XOoVssHLCjxWBUpg46ihQwXDw==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.5.0.tgz",
"integrity": "sha512-wu7XgargYBQEh46hVnfsmkTF6TvuoP9nAkTASR60s5ourjlT12qL9RiFLpwGkOBfs8E58h8V5hkgKsra5t03Lw==",
"license": "MIT"
},
"node_modules/@embedpdf/pdfium": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.6.0.tgz",
"integrity": "sha512-eYXU1VvVI0e9OqOzvsTcsU6YSLq9F7jcAiIbtMB+NxApvvH3kHz3FPEcf8ha2ZiLftF5OAD8K89SSE5GLE6t1A==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.5.0.tgz",
"integrity": "sha512-2VEO4cNZsV8ig9upS+C+x3Tb58aqNxiAdaUMlD2ZZT8FgszhsV9xMyEuM2maFRdjeT7EO37FtzYBdXc/K67ivA==",
"license": "MIT"
},
"node_modules/@embedpdf/plugin-annotation": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.6.0.tgz",
"integrity": "sha512-FJgGy6lhKrWsiJjh7jZ92NwMBob5GOwfYejQl28JFk6muEQORLtysz5gaeyMpMIyxnfjlf9Eqv8Z6LBBfGLGOA==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.5.0.tgz",
"integrity": "sha512-S5zCeWU3hM9jrnaGuW5RAXt+AzXXvQbFtAdCtxHW1hFADiZ97FKr8KS9MGCkkj6C9madtZP6iUJikvnhoLCABQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0",
"@embedpdf/utils": "2.6.0"
"@embedpdf/models": "2.5.0",
"@embedpdf/utils": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-history": "2.6.0",
"@embedpdf/plugin-interaction-manager": "2.6.0",
"@embedpdf/plugin-selection": "2.6.0",
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-history": "2.5.0",
"@embedpdf/plugin-interaction-manager": "2.5.0",
"@embedpdf/plugin-selection": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -686,15 +671,15 @@
}
},
"node_modules/@embedpdf/plugin-attachment": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.6.0.tgz",
"integrity": "sha512-6UZkj7jFWCruR69OPQFMqbJTgwdra4rnJSBfLA8yLxgz2zTsgt3owjfQDmlJvAQ7G1/rZM2T+EJeuozulj4NoQ==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-attachment/-/plugin-attachment-2.5.0.tgz",
"integrity": "sha512-dVVnklI2V1Tsnkf2Ob1PY/R8U6bImhaYwiWm8TwJnNzXXT3aTHQ81lGQOv+pIA9f02L8Y4J5OOKkVrKIpqBHug==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/core": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -703,15 +688,15 @@
}
},
"node_modules/@embedpdf/plugin-bookmark": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.6.0.tgz",
"integrity": "sha512-4JmaFD+gFaLj8Bayi6Fm5qxMoRH+JUy+L3S6xk1KM8YWjJyzsoz9C2mHSXKJ0GBMgiOkjaBuJSWqLgMe/oz7OQ==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.5.0.tgz",
"integrity": "sha512-2N5kGoamUrQqWZC5SMWIhdyBHqZN/CdcGf8GVH71FFw3AU6rmZ1AD/AkLzgqoYGIuZFE8ACckdrhtbpsZMmSDQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/core": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -720,15 +705,15 @@
}
},
"node_modules/@embedpdf/plugin-document-manager": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.6.0.tgz",
"integrity": "sha512-fcx0JKDboEV8eQ4r++ksDHPDuUz40oOmtHDqxYLw6cpos0fqW0p55OP+fKp6LfC/bY7ULVDrmcEQf1cD9Qho4w==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-document-manager/-/plugin-document-manager-2.5.0.tgz",
"integrity": "sha512-I8Z/0B7R/YhtVaJFruwFO+QBLIDmQfHx9WVlrDXWZs68YiGwEbjSyizEIEqtulUJxcXfPs2Tf7oIBbdSuPG2NQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/core": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -737,15 +722,15 @@
}
},
"node_modules/@embedpdf/plugin-export": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.6.0.tgz",
"integrity": "sha512-i1Xy7qUipVVLDPnnY22hm3RNMx33lvuNbCuPggql5Ws6WBLG9YhDsK+v0JVe2sDSlifa5SJwuBlMHZWPRTZyxg==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.5.0.tgz",
"integrity": "sha512-KC9jXqwcxe76QqfxLx0tnrSdFoApTFOpT+dwrvox186uxYKSmSt1JHFWe4THB/A63hCNr8uMwyswYdFO8fWNHw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/core": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -754,15 +739,15 @@
}
},
"node_modules/@embedpdf/plugin-history": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.6.0.tgz",
"integrity": "sha512-cfVoBjkIbFiRsQu/cwPEi0rrTAF7jriAGzABWawnSTKYEPFrU3LDHO7TewgBz45kHl9pSwvRexaIdTR8ECIKbQ==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.5.0.tgz",
"integrity": "sha512-Av9NBSE9Or1Y6cXcNWpx0bBZN3yI4vywa6kSNjhaqOrgpQDWMaTO57eApJpyHzBodqEztY+klE9YJ7MH88zm6w==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/core": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -771,15 +756,15 @@
}
},
"node_modules/@embedpdf/plugin-interaction-manager": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.6.0.tgz",
"integrity": "sha512-9bruF6M6GKVdABRTinHsZ+izf2tDQwDEcNI0CHVc5gurrz3CQfAGP2sJkv8uQrXyYTK3zV2Oq6zGknk7Hdx9mA==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.5.0.tgz",
"integrity": "sha512-QrmowLVvC5FNZdvVr2kczSDdnHHOuhf+So0VG5Ythts/OL1bIR/0OOpuyJsScTyo5boYnRkXv8yPf8htL57YKQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/core": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -788,17 +773,17 @@
}
},
"node_modules/@embedpdf/plugin-pan": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.6.0.tgz",
"integrity": "sha512-r8AXcXUy6NMYDaQeixScbeFfmZIvWpUUjx3gxjP4J90xfxXnuz/g/lnh4D2DBaiK4mt6crIVBpXU9IUwMIcUMQ==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.5.0.tgz",
"integrity": "sha512-DfdA+hBm9kGYYy7OuJym6azk2h2U/Geirud+tmVzFSL7+OZ3tZ3K9fqj07w66zx0msyUVlYrXzkYSU9NEmwpLA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-interaction-manager": "2.6.0",
"@embedpdf/plugin-viewport": "2.6.0",
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-interaction-manager": "2.5.0",
"@embedpdf/plugin-viewport": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -807,15 +792,15 @@
}
},
"node_modules/@embedpdf/plugin-print": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.6.0.tgz",
"integrity": "sha512-cgWRqVtRgCCLCn1ViuZEFr+ZJ3QI61/5s9tl3T9x81rwkBN4HT582BYzyRnLBzTMYKxkQZDr1WxfS8ctdlHEUQ==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.5.0.tgz",
"integrity": "sha512-qejq7/0K9hh3hzop+u+Qmn7ijTqGcDhxaiXoPkyl91CZVOyAD8qMBzWnhC7vRNOB7hcYgBP81uegE3se+EIlcA==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/core": "2.5.0",
"preact": "^10.26.4",
"react": ">=18.0.0",
"react-dom": ">=18.0.0",
@@ -824,20 +809,20 @@
}
},
"node_modules/@embedpdf/plugin-redaction": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.6.0.tgz",
"integrity": "sha512-DdDnmOl9K0N4dpTeUohavxQyrfollhkjT+zdfkna3Fc7F4jfl3Vg6uKoGmT71A+Vp4uTGNLt6cNCscbJW9E9kQ==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.5.0.tgz",
"integrity": "sha512-G0cm1hLWi09gU8WV+IShq2XHkmLtEbk+EvD3dIiyJV2kbOjgwGSC2Ezt8br3DzH6R/0bF6RAbDIpFyS2Q0oMfg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0",
"@embedpdf/utils": "2.6.0"
"@embedpdf/models": "2.5.0",
"@embedpdf/utils": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-annotation": "2.6.0",
"@embedpdf/plugin-history": "2.6.0",
"@embedpdf/plugin-interaction-manager": "2.6.0",
"@embedpdf/plugin-selection": "2.6.0",
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-annotation": "2.5.0",
"@embedpdf/plugin-history": "2.5.0",
"@embedpdf/plugin-interaction-manager": "2.5.0",
"@embedpdf/plugin-selection": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -846,15 +831,15 @@
}
},
"node_modules/@embedpdf/plugin-render": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.6.0.tgz",
"integrity": "sha512-Rk4QCxDOzhQrvKPt/G3G+p5ELwnKFkC5ljHMd7ND23atR9E3wm5W3+Nx3FaAYYPrpfqQ7BrbKnfQ7SkUbDxS3w==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.5.0.tgz",
"integrity": "sha512-nrTmg8cVMohcKYiQ/7erErsaWlyaq20OtXbVjmnPNnqz4amJLAjlPyudTJRlWWPyIiri9SF4A0ue5ICDY2sypg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/core": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -863,15 +848,15 @@
}
},
"node_modules/@embedpdf/plugin-rotate": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.6.0.tgz",
"integrity": "sha512-zgF2S5cfkOxkOWrwoLQLN8scJgKBEhyhVOv/RNdeAKP6qE3h28AGRmDeMsekBDbiInlIxIHzynE5vVcTNf5EnQ==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.5.0.tgz",
"integrity": "sha512-crFsXduaxNZJmVRfgklBpO4x4i9cRxPmfFBvdIoyJ1ea6AGOCL0rQKQcfqHTFdgtPzlVUiIg6Hi2v+033jdLUg==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/core": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -880,16 +865,16 @@
}
},
"node_modules/@embedpdf/plugin-scroll": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.6.0.tgz",
"integrity": "sha512-BEgSy6cs9+MLCS0Z3/FYMdA4Ygt6ddYIAg28XlF20kN3tLj8BQUo5qx6adI+SlwrFFGY52VAjjK7VSBuGfn19g==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.5.0.tgz",
"integrity": "sha512-AdLuSgvAaukLl1uQ0FbswcAIPFaR3Jk2ZbEJpWLd9E6iQ+66Cta0Sz8d5J6ndx7VBlRYAZwoqiXF85utJxpQ5g==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-viewport": "2.6.0",
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-viewport": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -898,15 +883,15 @@
}
},
"node_modules/@embedpdf/plugin-search": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.6.0.tgz",
"integrity": "sha512-GSzJkmuK9LE7LmlTwnDl71KdD9prHlCjgFs5Tm0K8qjELOSH+oduFXusIuf654+UQveDYczpzBVUcqb4yBf1xA==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.5.0.tgz",
"integrity": "sha512-ycHJh05vBZ1PTSdEMgdx6K1py0oklwbwY2eXO4nD54EN9EVZgWlYC4Q+u8nyGOiNL6VmJYcqJ0HmjSWBdmGWBw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/core": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -915,17 +900,17 @@
}
},
"node_modules/@embedpdf/plugin-selection": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.6.0.tgz",
"integrity": "sha512-VrW0duVxLwaquInwmuNDMz8o0tfCDwe3j81fvTUDW/s7KqnzFbxK7vEuq5TEtxWuSng2DXxyX3r1ntCm4X/NCg==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.5.0.tgz",
"integrity": "sha512-M3WDjahig/6KE83SZGvTaJWhqEOIzH002k2fpJVuks926UBnfgYCH8uqV7SOUQTneQDmIa0PlyFiuEXDw1Ocrw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0",
"@embedpdf/utils": "2.6.0"
"@embedpdf/models": "2.5.0",
"@embedpdf/utils": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-interaction-manager": "2.6.0",
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-interaction-manager": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -934,15 +919,15 @@
}
},
"node_modules/@embedpdf/plugin-spread": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.6.0.tgz",
"integrity": "sha512-0mzPCJlw1X7jWeDg5JssU6/HCFtyOP7scEdbIaASYzofGXa2Rj8/+L+UDBrb+KTF6CR4X6fEfeNmxWmAatOAWQ==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-2.5.0.tgz",
"integrity": "sha512-kG8HZMZmbpUVDxCOEyQzIiMPW+VjjebOl93V+quAH+GAI5Tkg6exPyyQ2+/DOJPCtYX4Kh2z2aeoyK2b7NRgIQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/core": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -951,16 +936,16 @@
}
},
"node_modules/@embedpdf/plugin-thumbnail": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.6.0.tgz",
"integrity": "sha512-Sj4jCV1MNk+19zKWX4KfSl5c0YHrqVG83pEYfqexjSkSX7y7HRwAOtMBtNd3uLInPPSBnzDxj+KlJlIe8RPPJw==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.5.0.tgz",
"integrity": "sha512-iWofJSXKbWrgvS2fe8v3U1+e2wjBRXD2i1DUcJKnTrqyfjZ8YzUomc5EzdG2RT7uUjtqrcu7463TZ9JHXUkASQ==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-render": "2.6.0",
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-render": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -969,18 +954,18 @@
}
},
"node_modules/@embedpdf/plugin-tiling": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.6.0.tgz",
"integrity": "sha512-qyiHWljryHWQ7uzip2WDg4x28o/1QM0wh9oIyz5WlBnrDaK6bLJGsWUym5P6WfLp0Y8h6GFAslNNcgjBv6E3qw==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.5.0.tgz",
"integrity": "sha512-oih0GyGOJvfaXPLSEY+qfC05UUU1ZkADEbr6uCwRMmdHIXu/0ZTJnAToegfWXtfE+Sw0J5wscVkipXXIX5azlw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-render": "2.6.0",
"@embedpdf/plugin-scroll": "2.6.0",
"@embedpdf/plugin-viewport": "2.6.0",
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-render": "2.5.0",
"@embedpdf/plugin-scroll": "2.5.0",
"@embedpdf/plugin-viewport": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -989,15 +974,15 @@
}
},
"node_modules/@embedpdf/plugin-viewport": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.6.0.tgz",
"integrity": "sha512-Ea7s+LivQ4ph01mVngU2tu2Ni/zulxzIyiifCpMaBMHmvjGFQjJcNNYHR90YuM8keto82KCszxdNDuAEEzT6Wg==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.5.0.tgz",
"integrity": "sha512-z0AXHA9Z3rZdCLje7P2NsQbxKLJ4b/l8lgzXOVn5Ow/pIPE0D2P3fn9WzImHTNI1RNrZMdkW9OH3lfkEXFTqHw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/core": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1006,17 +991,17 @@
}
},
"node_modules/@embedpdf/plugin-zoom": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.6.0.tgz",
"integrity": "sha512-2XUgasN2ZQm2MgpB6ls/re/SKhsREvt2D1gIcvJgXvGkene0NcpxGNIRi/+JN7W0fw4x3QtwLQtyF+/0uMgPmg==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.5.0.tgz",
"integrity": "sha512-HWJlqXOXdv/kttV+XWCCStUZAeLl66AuaO8BsnPlAPwEADLLCH4tR4XqJQoWr7/r5watKP7UeQ00FsWu0oGclw==",
"license": "MIT",
"dependencies": {
"@embedpdf/models": "2.6.0"
"@embedpdf/models": "2.5.0"
},
"peerDependencies": {
"@embedpdf/core": "2.6.0",
"@embedpdf/plugin-scroll": "2.6.0",
"@embedpdf/plugin-viewport": "2.6.0",
"@embedpdf/core": "2.5.0",
"@embedpdf/plugin-scroll": "2.5.0",
"@embedpdf/plugin-viewport": "2.5.0",
"preact": "^10.26.4",
"react": ">=16.8.0",
"react-dom": ">=16.8.0",
@@ -1025,9 +1010,9 @@
}
},
"node_modules/@embedpdf/utils": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.6.0.tgz",
"integrity": "sha512-FT6U6L3Et688urUTyISpYH05w4sG+WzoWxaI7aPU4ieh4c/vVgadUtjZj/QCC8v+DebPYRAX1gpUY7e0Y0HlTQ==",
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.5.0.tgz",
"integrity": "sha512-JjYj6BRzu9oesA1JOqKPFMEWKinjvJIjziWu1j6lDXxLsE59bkShjUKbaEG+lkXRspuZRWNP++rzE2p2Ht4veg==",
"license": "MIT",
"peerDependencies": {
"preact": "^10.26.4",
@@ -6495,23 +6480,11 @@
"node": ">=6"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
@@ -6524,18 +6497,9 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -6654,12 +6618,6 @@
"node": ">= 8"
}
},
"node_modules/crypto-js": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/css-tree": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz",
@@ -8909,22 +8867,6 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-entities": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/mdevils"
},
{
"type": "patreon",
"url": "https://patreon.com/mdevils"
}
],
"license": "MIT"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -10771,15 +10713,6 @@
"node": ">= 0.4.0"
}
},
"node_modules/node-html-better-parser": {
"version": "1.5.8",
"resolved": "https://registry.npmjs.org/node-html-better-parser/-/node-html-better-parser-1.5.8.tgz",
"integrity": "sha512-t/wAKvaTSKco43X+yf9+76RiMt18MtMmzd4wc7rKj+fWav6DV4ajDEKdWlLzSE8USDF5zr/06uGj0Wr/dGAFtw==",
"license": "MIT",
"dependencies": {
"html-entities": "^2.3.2"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -11335,6 +11268,24 @@
"node": ">= 14.16"
}
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/pdfjs-dist": {
"version": "5.4.530",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-5.4.530.tgz",
@@ -13042,21 +12993,6 @@
"integrity": "sha512-zyxW5vuJVnQdGcU+kAj9FYl7WaAunY3kA5S7mPg0xJiujL9+sPAWfSQHS5tXaJXDUa4FuZeKhfdCDQ6K3wfkpQ==",
"license": "MIT"
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/slash": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/slash/-/slash-5.1.0.tgz",
+24 -24
View File
@@ -7,29 +7,29 @@
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
"@dnd-kit/core": "^6.3.1",
"@embedpdf/core": "^2.6.0",
"@embedpdf/engines": "^2.6.0",
"@embedpdf/models": "^2.6.0",
"@embedpdf/plugin-annotation": "^2.6.0",
"@embedpdf/plugin-attachment": "^2.6.0",
"@embedpdf/plugin-bookmark": "^2.6.0",
"@embedpdf/plugin-export": "^2.6.0",
"@embedpdf/plugin-history": "^2.6.0",
"@embedpdf/plugin-document-manager": "^2.6.0",
"@embedpdf/plugin-interaction-manager": "^2.6.0",
"@embedpdf/plugin-pan": "^2.6.0",
"@embedpdf/plugin-print": "^2.6.0",
"@embedpdf/plugin-redaction": "^2.6.0",
"@embedpdf/plugin-render": "^2.6.0",
"@embedpdf/plugin-rotate": "^2.6.0",
"@embedpdf/plugin-scroll": "^2.6.0",
"@embedpdf/plugin-search": "^2.6.0",
"@embedpdf/plugin-selection": "^2.6.0",
"@embedpdf/plugin-spread": "^2.6.0",
"@embedpdf/plugin-thumbnail": "^2.6.0",
"@embedpdf/plugin-tiling": "^2.6.0",
"@embedpdf/plugin-viewport": "^2.6.0",
"@embedpdf/plugin-zoom": "^2.6.0",
"@embedpdf/core": "^2.5.0",
"@embedpdf/engines": "^2.5.0",
"@embedpdf/models": "^2.5.0",
"@embedpdf/plugin-annotation": "^2.5.0",
"@embedpdf/plugin-attachment": "^2.5.0",
"@embedpdf/plugin-bookmark": "^2.5.0",
"@embedpdf/plugin-export": "^2.5.0",
"@embedpdf/plugin-history": "^2.5.0",
"@embedpdf/plugin-document-manager": "^2.5.0",
"@embedpdf/plugin-interaction-manager": "^2.5.0",
"@embedpdf/plugin-pan": "^2.5.0",
"@embedpdf/plugin-print": "^2.5.0",
"@embedpdf/plugin-redaction": "^2.5.0",
"@embedpdf/plugin-render": "^2.5.0",
"@embedpdf/plugin-rotate": "^2.5.0",
"@embedpdf/plugin-scroll": "^2.5.0",
"@embedpdf/plugin-search": "^2.5.0",
"@embedpdf/plugin-selection": "^2.5.0",
"@embedpdf/plugin-spread": "^2.5.0",
"@embedpdf/plugin-thumbnail": "^2.5.0",
"@embedpdf/plugin-tiling": "^2.5.0",
"@embedpdf/plugin-viewport": "^2.5.0",
"@embedpdf/plugin-zoom": "^2.5.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@iconify/react": "^6.0.2",
@@ -57,7 +57,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"jszip": "^3.10.1",
"license-report": "^6.8.0",
"@cantoo/pdf-lib": "^2.5.3",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.149",
"peerjs": "^1.5.5",
"posthog-js": "^1.268.0",
+53 -96
View File
@@ -1236,21 +1236,9 @@ label = "Enable Key Cleanup"
description = "Automatically rotate JWT signing keys periodically"
label = "Enable Key Rotation"
[admin.settings.security.jwt.tokenExpiryMinutes]
description = "Access token lifetime in minutes for web clients (default: 1440 = 24 hours)"
label = "Web Token Expiry (minutes)"
[admin.settings.security.jwt.desktopTokenExpiryMinutes]
description = "Access token lifetime in minutes for desktop clients. Desktop apps automatically detected via User-Agent and receive longer sessions for better UX (default: 43200 = 30 days)"
label = "Desktop Token Expiry (minutes)"
[admin.settings.security.jwt.allowedClockSkewSeconds]
description = "Tolerance for client/server time drift during token validation (default: 60 seconds)"
label = "Clock Skew Tolerance (seconds)"
[admin.settings.security.jwt.refreshGraceMinutes]
description = "Allow token refresh within this many minutes after expiry (default: 15 minutes, max 3 attempts)"
label = "Refresh Grace Period (minutes)"
[admin.settings.security.jwt.keyRetentionDays]
description = "Number of days to retain old JWT keys for verification"
label = "Key Retention Days"
[admin.settings.security.jwt.persistence]
description = "Store JWT keys persistently to survive server restarts"
@@ -1439,16 +1427,13 @@ applyChanges = "Apply Changes"
backgroundColor = "Background colour"
borderOff = "Border: Off"
borderOn = "Border: On"
changeColor = "Change Colour"
chooseColor = "Choose colour"
circle = "Circle"
clearBackground = "Remove background"
color = "Colour"
contents = "Text"
delete = "Delete"
desc = "Use highlight, pen, text, and notes. Changes stay live—no flattening required."
drawing = "Drawing"
duplicate = "Duplicate"
editCircle = "Edit Circle"
editInk = "Edit Pen"
editLine = "Edit Line"
@@ -1478,7 +1463,6 @@ notesStamps = "Notes & Stamps"
opacity = "Opacity"
pen = "Pen"
polygon = "Polygon"
properties = "Properties"
rectangle = "Rectangle"
redo = "Redo"
saveChanges = "Save Changes"
@@ -1504,7 +1488,6 @@ title = "Annotate"
underline = "Underline"
undo = "Undo"
unsupportedType = "This annotation type is not fully supported for editing."
width = "Width"
[app]
description = "The Free Adobe Acrobat alternative (10M+ Downloads)"
@@ -4235,57 +4218,6 @@ title = "Page Editor"
zoomIn = "Zoom In"
zoomOut = "Zoom Out"
[viewer]
cannotPreviewFile = "Cannot Preview File"
dualPageView = "Dual Page View"
firstPage = "First Page"
lastPage = "Last Page"
nextPage = "Next Page"
onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format."
previousPage = "Previous Page"
singlePageView = "Single Page View"
unknownFile = "Unknown file"
zoomIn = "Zoom In"
zoomOut = "Zoom Out"
[rightRail]
closeSelected = "Close Selected Files"
selectAll = "Select All"
deselectAll = "Deselect All"
selectByNumber = "Select by Page Numbers"
deleteSelected = "Delete Selected Pages"
closePdf = "Close PDF"
exportAll = "Export PDF"
downloadSelected = "Download Selected Files"
annotations = "Annotations"
exportSelected = "Export Selected Pages"
formFill = "Fill Form"
saveChanges = "Save Changes"
toggleAttachments = "Toggle Attachments"
toggleTheme = "Toggle Theme"
language = "Language"
toggleAnnotations = "Toggle Annotations Visibility"
search = "Search PDF"
panMode = "Pan Mode"
applyRedactionsFirst = "Apply redactions first"
rotateLeft = "Rotate Left"
rotateRight = "Rotate Right"
toggleSidebar = "Toggle Sidebar"
toggleBookmarks = "Toggle Bookmarks"
print = "Print PDF"
ruler = "Ruler / Measure"
draw = "Draw"
redact = "Redact"
exitRedaction = "Exit Redaction Mode"
save = "Save"
downloadAll = "Download All"
saveAll = "Save All"
[textAlign]
left = "Left"
center = "Center"
right = "Right"
[pageExtracter]
header = "Extract Pages"
placeholder = "(e.g. 1,2,8 or 4,7,12-16 or 2n-1)"
@@ -4912,7 +4844,6 @@ account = "Account"
activity = "Activity"
adminSettings = "Admin Settings"
allTools = "Tools"
plugins = "Plugins"
automate = "Automate"
config = "Config"
files = "Files"
@@ -5367,6 +5298,38 @@ title = "High Contrast"
text = "Completely invert all colours in the PDF, creating a negative-like effect. Useful for creating dark mode versions of documents or reducing eye strain in low-light conditions."
title = "Invert All Colours"
[rightRail]
annotations = "Annotations"
applyRedactionsFirst = "Apply redactions first"
closePdf = "Close PDF"
closeSelected = "Close Selected Files"
formFill = "Fill Form"
deleteSelected = "Delete Selected Pages"
deselectAll = "Deselect All"
downloadAll = "Download All"
downloadSelected = "Download Selected Files"
draw = "Draw"
exitRedaction = "Exit Redaction Mode"
exportAll = "Export PDF"
exportSelected = "Export Selected Pages"
language = "Language"
panMode = "Pan Mode"
print = "Print PDF"
redact = "Redact"
rotateLeft = "Rotate Left"
rotateRight = "Rotate Right"
save = "Save"
saveAll = "Save All"
saveChanges = "Save Changes"
search = "Search PDF"
selectAll = "Select All"
selectByNumber = "Select by Page Numbers"
toggleAnnotations = "Toggle Annotations Visibility"
toggleBookmarks = "Toggle Bookmarks"
toggleAttachments = "Toggle Attachments"
toggleSidebar = "Toggle Sidebar"
toggleTheme = "Toggle Theme"
[rotate]
rotateLeft = "Rotate Anticlockwise"
rotateRight = "Rotate Clockwise"
@@ -5626,21 +5589,6 @@ 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"
@@ -6219,6 +6167,11 @@ title = "API Documentation"
[tableExtraxt]
tags = "CSV,Table Extraction,extract,convert"
[textAlign]
center = "Center"
left = "Left"
right = "Right"
[theme]
toggle = "Toggle Theme"
@@ -6284,15 +6237,6 @@ 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"
@@ -6503,6 +6447,19 @@ fileManager = "File Manager"
pageEditor = "Page Editor"
viewer = "Viewer"
[viewer]
cannotPreviewFile = "Cannot Preview File"
dualPageView = "Dual Page View"
firstPage = "First Page"
lastPage = "Last Page"
nextPage = "Next Page"
onlyPdfSupported = "The viewer only supports PDF files. This file appears to be a different format."
previousPage = "Previous Page"
singlePageView = "Single Page View"
unknownFile = "Unknown file"
zoomIn = "Zoom In"
zoomOut = "Zoom Out"
[viewer.attachments]
title = "Attachments"
searchPlaceholder = "Search attachments"
-8
View File
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSLocalNetworkUsageDescription</key>
<string>Stirling-PDF needs access to your local network to connect to self-hosted servers.</string>
</dict>
</plist>
+2 -4
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "Stirling-PDF",
"version": "2.5.1",
"version": "2.4.6",
"identifier": "stirling.pdf.dev",
"build": {
"frontendDist": "../dist",
@@ -23,7 +23,6 @@
},
"bundle": {
"active": true,
"publisher": "Stirling PDF Inc.",
"targets": [
"deb",
"rpm",
@@ -77,8 +76,7 @@
"minimumSystemVersion": "10.15",
"signingIdentity": null,
"entitlements": null,
"providerShortName": null,
"infoPlist": "Info.plist"
"providerShortName": null
}
},
"plugins": {
-10
View File
@@ -7,7 +7,6 @@ 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
@@ -44,15 +43,6 @@ export default function App() {
/>
{/* All other routes need AppProviders for backend integration */}
<Route
path="/plugins/:id"
element={
<AppProviders>
<PluginPage />
</AppProviders>
}
/>
<Route
path="*"
element={
@@ -2,7 +2,6 @@ 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";
@@ -109,8 +108,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
<AppInitializer />
<BrandingAssetManager />
<PluginRegistryProvider>
<ToolRegistryProvider>
<ToolRegistryProvider>
<NavigationProvider>
<FilesModalProvider>
<ToolWorkflowProvider>
@@ -141,7 +139,6 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
</FilesModalProvider>
</NavigationProvider>
</ToolRegistryProvider>
</PluginRegistryProvider>
</FileContextProvider>
</AppConfigProvider>
</BannerProvider>
@@ -1,59 +0,0 @@
import { ActionIcon, Tooltip, Popover, Stack, ColorSwatch, ColorPicker as MantineColorPicker } from '@mantine/core';
import { useState } from 'react';
interface ColorControlProps {
value: string;
onChange: (color: string) => void;
label: string;
disabled?: boolean;
}
export function ColorControl({ value, onChange, label, disabled = false }: ColorControlProps) {
const [opened, setOpened] = useState(false);
return (
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow withinPortal>
<Popover.Target>
<Tooltip label={label}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<ColorSwatch color={value} size={18} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs">
<MantineColorPicker
format="hex"
value={value}
onChange={onChange}
swatches={[
'#000000', '#ffffff', '#ff0000', '#00ff00', '#0000ff',
'#ffff00', '#ff00ff', '#00ffff', '#ffa500', 'transparent'
]}
swatchesPerRow={5}
size="sm"
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,60 +0,0 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import OpacityIcon from '@mui/icons-material/Opacity';
interface OpacityControlProps {
value: number; // 0-100
onChange: (value: number) => void;
disabled?: boolean;
}
export function OpacityControl({ value, onChange, disabled = false }: OpacityControlProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
return (
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.opacity', 'Opacity')}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<OpacityIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs" style={{ minWidth: 150 }}>
<Text size="xs" fw={500}>
{t('annotation.opacity', 'Opacity')}
</Text>
<Slider
value={value}
onChange={onChange}
min={10}
max={100}
label={(val) => `${val}%`}
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -1,211 +0,0 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text, Group, Button } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import TuneIcon from '@mui/icons-material/Tune';
import FormatAlignLeftIcon from '@mui/icons-material/FormatAlignLeft';
import FormatAlignCenterIcon from '@mui/icons-material/FormatAlignCenter';
import FormatAlignRightIcon from '@mui/icons-material/FormatAlignRight';
type AnnotationType = 'text' | 'note' | 'shape';
interface PropertiesPopoverProps {
annotationType: AnnotationType;
annotation: any;
onUpdate: (patch: Record<string, any>) => void;
disabled?: boolean;
}
export function PropertiesPopover({
annotationType,
annotation,
onUpdate,
disabled = false,
}: PropertiesPopoverProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
const obj = annotation?.object;
// Get current values
const fontSize = obj?.fontSize ?? 14;
const textAlign = obj?.textAlign;
const currentAlign =
typeof textAlign === 'number'
? textAlign === 1
? 'center'
: textAlign === 2
? 'right'
: 'left'
: textAlign === 'center'
? 'center'
: textAlign === 'right'
? 'right'
: 'left';
// For shapes
const opacity = Math.round((obj?.opacity ?? 1) * 100);
const strokeWidth = obj?.borderWidth ?? obj?.strokeWidth ?? 2;
const borderVisible = strokeWidth > 0;
const renderTextNoteControls = () => (
<Stack gap="md" style={{ minWidth: 280 }}>
{/* Font Size */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.fontSize', 'Font size')}
</Text>
<Slider
value={fontSize}
onChange={(val) => onUpdate({ fontSize: val })}
min={8}
max={32}
label={(val) => `${val}pt`}
/>
</div>
{/* Opacity */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.opacity', 'Opacity')}
</Text>
<Slider
value={Math.round((obj?.opacity ?? 1) * 100)}
onChange={(val) => onUpdate({ opacity: val / 100 })}
min={10}
max={100}
label={(val) => `${val}%`}
/>
</div>
{/* Text Alignment */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.textAlignment', 'Text Alignment')}
</Text>
<Group gap="xs">
<ActionIcon
variant={currentAlign === 'left' ? 'filled' : 'default'}
onClick={() => onUpdate({ textAlign: 0 })}
size="md"
>
<FormatAlignLeftIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'center' ? 'filled' : 'default'}
onClick={() => onUpdate({ textAlign: 1 })}
size="md"
>
<FormatAlignCenterIcon style={{ fontSize: 18 }} />
</ActionIcon>
<ActionIcon
variant={currentAlign === 'right' ? 'filled' : 'default'}
onClick={() => onUpdate({ textAlign: 2 })}
size="md"
>
<FormatAlignRightIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Group>
</div>
</Stack>
);
const renderShapeControls = () => (
<Stack gap="md" style={{ minWidth: 250 }}>
{/* Opacity */}
<div>
<Text size="xs" fw={500} mb={4}>
{t('annotation.opacity', 'Opacity')}
</Text>
<Slider
value={opacity}
onChange={(val) => {
const newOpacity = val / 100;
onUpdate({
opacity: newOpacity,
strokeOpacity: newOpacity,
fillOpacity: newOpacity,
});
}}
min={10}
max={100}
label={(val) => `${val}%`}
/>
</div>
{/* Stroke Width */}
<div>
<Group gap="xs" align="flex-end">
<div style={{ flex: 1 }}>
<Text size="xs" fw={500} mb={4}>
{t('annotation.strokeWidth', 'Stroke')}
</Text>
<Slider
value={strokeWidth}
onChange={(val) => {
onUpdate({
borderWidth: val,
strokeWidth: val,
lineWidth: val,
});
}}
min={0}
max={12}
label={(val) => `${val}pt`}
/>
</div>
<Button
size="xs"
variant={!borderVisible ? 'filled' : 'light'}
onClick={() => {
const newValue = borderVisible ? 0 : 1;
onUpdate({
borderWidth: newValue,
strokeWidth: newValue,
lineWidth: newValue,
});
}}
>
{borderVisible
? t('annotation.borderOn', 'Border: On')
: t('annotation.borderOff', 'Border: Off')}
</Button>
</Group>
</div>
</Stack>
);
return (
<Popover opened={opened} onChange={setOpened} position="bottom" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.properties', 'Properties')}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<TuneIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
{(annotationType === 'text' || annotationType === 'note') && renderTextNoteControls()}
{annotationType === 'shape' && renderShapeControls()}
</Popover.Dropdown>
</Popover>
);
}
@@ -1,62 +0,0 @@
import { ActionIcon, Tooltip, Popover, Stack, Slider, Text } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { useState } from 'react';
import LineWeightIcon from '@mui/icons-material/LineWeight';
interface WidthControlProps {
value: number;
onChange: (value: number) => void;
min: number; // 1 for ink, 0 for shapes
max: number; // 12 for ink, 20 for highlighter
disabled?: boolean;
}
export function WidthControl({ value, onChange, min, max, disabled = false }: WidthControlProps) {
const { t } = useTranslation();
const [opened, setOpened] = useState(false);
return (
<Popover opened={opened} onChange={setOpened} position="top" withArrow>
<Popover.Target>
<Tooltip label={t('annotation.width', 'Width')}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={() => setOpened(!opened)}
disabled={disabled}
styles={{
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
}}
>
<LineWeightIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
</Popover.Target>
<Popover.Dropdown>
<Stack gap="xs" style={{ minWidth: 150 }}>
<Text size="xs" fw={500}>
{t('annotation.width', 'Width')}
</Text>
<Slider
value={value}
onChange={onChange}
min={min}
max={max}
label={(val) => `${val}pt`}
/>
</Stack>
</Popover.Dropdown>
</Popover>
);
}
@@ -52,15 +52,12 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
}) => {
const { t } = useTranslation();
const labelText = option.label;
const comingSoonText = t('comingSoon', 'Coming soon');
const label = disabled ? (
<Tooltip content={comingSoonText} position="left" arrow>
<p>{labelText}</p>
<Tooltip content={t('comingSoon', 'Coming soon')} position="left" arrow>
<p>{option.label}</p>
</Tooltip>
) : (
<p>{labelText}</p>
<p>{option.label}</p>
);
return (
@@ -160,27 +157,12 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
compact = false,
tooltip
}) => {
const { i18n, ready } = useTranslation();
const { i18n } = useTranslation();
const [opened, setOpened] = useState(false);
const [animationTriggered, setAnimationTriggered] = useState(false);
const [pendingLanguage, setPendingLanguage] = useState<string | null>(null);
const [rippleEffect, setRippleEffect] = useState<RippleEffect | null>(null);
// Trigger animation when dropdown opens
useEffect(() => {
if (opened) {
setAnimationTriggered(false);
// Small delay to ensure DOM is ready
setTimeout(() => setAnimationTriggered(true), 20);
}
}, [opened]);
// Don't render until i18n is ready to prevent race condition
// during SAML auth where components render before i18n initializes
if (!ready || !i18n.language) {
return null;
}
// Get the filtered list of supported languages from i18n
// This respects server config (ui.languages) applied by AppConfigLoader
const allowedLanguages = (i18n.options.supportedLngs as string[] || [])
@@ -194,6 +176,12 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
label: name,
}));
// Hide the language selector if there's only one language option
// (no point showing a selector when there's nothing to select)
if (languageOptions.length <= 1) {
return null;
}
// Calculate dropdown width and grid columns based on number of languages
// 2-4: 300px/2 cols, 5-9: 400px/3 cols, 10+: 600px/4 cols
const dropdownWidth = languageOptions.length <= 4 ? 300
@@ -237,14 +225,16 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
};
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
supportedLanguages['en-GB'] ||
'English'; // Fallback if supportedLanguages lookup fails
supportedLanguages['en-GB'];
// Hide the language selector if there's only one language option
// (no point showing a selector when there's nothing to select)
if (languageOptions.length <= 1) {
return null;
}
// Trigger animation when dropdown opens
useEffect(() => {
if (opened) {
setAnimationTriggered(false);
// Small delay to ensure DOM is ready
setTimeout(() => setAnimationTriggered(true), 20);
}
}, [opened]);
return (
<>
@@ -1,91 +0,0 @@
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
);
}
@@ -24,9 +24,10 @@ const ToolChain: React.FC<ToolChainProps> = ({
size = 'xs',
color = 'var(--mantine-color-blue-7)'
}) => {
const { t } = useTranslation();
if (!toolChain || toolChain.length === 0) return null;
const { t } = useTranslation();
const toolIds = toolChain.map(tool => tool.toolId);
const getToolName = (toolId: ToolId) => {
@@ -3,7 +3,6 @@ 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;
@@ -54,17 +53,6 @@ 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;
@@ -1,146 +0,0 @@
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,7 +29,6 @@ export const VALID_NAV_KEYS = [
'adminAudit',
'adminUsage',
'adminEndpoints',
'plugins',
] as const;
// Derive the type from the array
@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useRef } from "react";
import { Box, Button, Stack } from "@mantine/core";
import React, { useMemo, useRef } from "react";
import { Box, Stack } from "@mantine/core";
import { useTranslation } from "react-i18next";
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
import "@app/components/tools/toolPicker/ToolPicker.css";
@@ -12,11 +12,6 @@ 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;
@@ -31,10 +26,7 @@ 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);
@@ -55,19 +47,6 @@ 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;
@@ -154,68 +133,6 @@ 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}>
@@ -74,11 +74,6 @@ const CompareDocumentPane = ({
}
}, [zoom]);
const renderedPageNumbers = useMemo(
() => new Set(pages.map((p) => p.pageNumber)),
[pages]
);
return (
<div className="compare-pane">
<div className="compare-header">
@@ -93,7 +88,7 @@ const CompareDocumentPane = ({
placeholder={dropdownPlaceholder ?? null}
className={pane === 'comparison' ? 'compare-changes-select--comparison' : undefined}
onNavigate={onNavigateChange}
renderedPageNumbers={renderedPageNumbers}
renderedPageNumbers={useMemo(() => new Set(pages.map(p => p.pageNumber)), [pages])}
/>
)}
</Group>
@@ -43,16 +43,6 @@ interface EditTableOfContentsWorkbenchViewProps {
const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbenchViewProps) => {
const { t } = useTranslation();
const terminology = useFileActionTerminology();
const files = data?.files ?? [];
const thumbnails = data?.thumbnails ?? [];
const previewFiles = useMemo(
() =>
files.map((file, index) => ({
file,
thumbnail: thumbnails[index],
})),
[files, thumbnails]
);
if (!data) {
return (
@@ -73,6 +63,8 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
bookmarks,
selectedFileName,
disabled,
files,
thumbnails,
downloadUrl,
downloadFilename,
errorMessage,
@@ -86,6 +78,15 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
onFileClick,
} = data;
const previewFiles = useMemo(
() =>
files?.map((file, index) => ({
file,
thumbnail: thumbnails[index],
})) ?? [],
[files, thumbnails]
);
const showResults = Boolean(
previewFiles.length > 0 || downloadUrl || errorMessage
);
@@ -1,7 +1,7 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import FileStatusIndicator from '@app/components/tools/shared/FileStatusIndicator';
import { StirlingFile } from '@app/types/fileContext';
import i18n from '@app/i18n';
export interface FilesToolStepProps {
selectedFiles: StirlingFile[];
@@ -14,7 +14,9 @@ export function createFilesToolStep(
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
props: FilesToolStepProps
): React.ReactElement {
return createStep(i18n.t("files.title", "Files"), {
const { t } = useTranslation();
return createStep(t("files.title", "Files"), {
isVisible: true,
isCollapsed: props.isCollapsed,
onCollapsedClick: props.onCollapsedClick
@@ -12,7 +12,6 @@ import { useFileActionIcons } from "@app/hooks/useFileActionIcons";
import { saveOperationResults } from "@app/services/operationResultsSaveService";
import { useFileActions, useFileState } from "@app/contexts/FileContext";
import { FileId } from "@app/types/fileContext";
import i18n from "@app/i18n";
export interface ReviewToolStepProps<TParams = unknown> {
isVisible: boolean;
@@ -152,8 +151,10 @@ export function createReviewToolStep<TParams = unknown>(
) => React.ReactElement,
props: ReviewToolStepProps<TParams>
): React.ReactElement {
const { t } = useTranslation();
return createStep(
i18n.t("review", "Review"),
t("review", "Review"),
{
isVisible: props.isVisible,
isCollapsed: props.isCollapsed,
@@ -80,6 +80,8 @@ const ToolStep = ({
alwaysShowTooltip = false,
tooltip
}: ToolStepProps) => {
if (!isVisible) return null;
const parent = useContext(ToolStepContext);
// Auto-detect if we should show numbers based on sibling count or force option
@@ -89,8 +91,6 @@ const ToolStep = ({
return parent ? parent.visibleStepCount >= 3 : false; // Auto-detect
}, [showNumber, parent]);
if (!isVisible) return null;
const stepNumber = _stepNumber;
return (
@@ -8,7 +8,6 @@ import type {
AnnotationEvent,
AnnotationPatch,
} from '@app/components/viewer/viewerTypes';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
type NoteIcon = NonNullable<AnnotationToolOptions['icon']>;
@@ -291,7 +290,6 @@ const TOOL_DEFAULT_BUILDERS: Record<AnnotationToolId, ToolDefaultsBuilder> = {
export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function AnnotationAPIBridge(_props, ref) {
// Use the provided annotation API just like SignatureAPIBridge/HistoryAPIBridge
const { provides: annotationApi } = useAnnotationCapability();
const documentReady = useDocumentReady();
const buildAnnotationDefaults = useCallback(
(toolId: AnnotationToolId, options?: AnnotationToolOptions) =>
@@ -325,7 +323,6 @@ export const AnnotationAPIBridge = forwardRef<AnnotationAPI>(function Annotation
activateAnnotationTool: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
configureAnnotationTool(toolId, options);
},
isReady: () => !!annotationApi && documentReady,
setAnnotationStyle: (toolId: AnnotationToolId, options?: AnnotationToolOptions) => {
const defaults = buildAnnotationDefaults(toolId, options);
const api = annotationApi as AnnotationApiSurface | undefined;
@@ -1,620 +0,0 @@
import { ActionIcon, Tooltip, Group } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
import { useEffect, useState, useRef, useCallback } from 'react';
import DeleteIcon from '@mui/icons-material/Delete';
import EditIcon from '@mui/icons-material/Edit';
import { useAnnotation } from '@embedpdf/plugin-annotation/react';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { OpacityControl } from '@app/components/annotation/shared/OpacityControl';
import { WidthControl } from '@app/components/annotation/shared/WidthControl';
import { PropertiesPopover } from '@app/components/annotation/shared/PropertiesPopover';
import { ColorControl } from '@app/components/annotation/shared/ColorControl';
/**
* Props interface matching EmbedPDF's annotation selection menu pattern
* This matches the type from @embedpdf/plugin-annotation
*/
export interface AnnotationSelectionMenuProps {
documentId?: string;
context?: {
type: 'annotation';
annotation: any;
pageIndex: number;
};
selected: boolean;
menuWrapperProps?: {
ref?: (node: HTMLDivElement | null) => void;
style?: React.CSSProperties;
};
}
export function AnnotationSelectionMenu(props: AnnotationSelectionMenuProps) {
const activeDocumentId = useActiveDocumentId();
// Don't render until we have a valid document ID
if (!activeDocumentId) {
return null;
}
return (
<AnnotationSelectionMenuInner
documentId={activeDocumentId}
{...props}
/>
);
}
type AnnotationType = 'textMarkup' | 'ink' | 'inkHighlighter' | 'text' | 'note' | 'shape' | 'line' | 'stamp' | 'unknown';
function AnnotationSelectionMenuInner({
documentId,
context,
selected,
menuWrapperProps,
}: AnnotationSelectionMenuProps & { documentId: string }) {
const annotation = context?.annotation;
const pageIndex = context?.pageIndex;
const { t } = useTranslation();
const { provides } = useAnnotation(documentId);
const wrapperRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [menuPosition, setMenuPosition] = useState<{ top: number; left: number } | null>(null);
const [isTextEditorOpen, setIsTextEditorOpen] = useState(false);
const [textDraft, setTextDraft] = useState('');
const [textBoxPosition, setTextBoxPosition] = useState<{ top: number; left: number; width: number; height: number; fontSize: number; fontFamily: string } | null>(null);
// Merge refs - menuWrapperProps.ref is a callback ref
const setRef = useCallback((node: HTMLDivElement | null) => {
wrapperRef.current = node;
// Call the EmbedPDF ref callback
menuWrapperProps?.ref?.(node);
}, [menuWrapperProps]);
// Type detection
const getAnnotationType = useCallback((): AnnotationType => {
const type = annotation?.object?.type;
const toolId = annotation?.object?.customData?.toolId;
// Map type numbers to categories
if ([9, 10, 11, 12].includes(type)) return 'textMarkup';
if (type === 15) {
return toolId === 'inkHighlighter' ? 'inkHighlighter' : 'ink';
}
if (type === 3) {
return toolId === 'note' ? 'note' : 'text';
}
if ([5, 6, 7].includes(type)) return 'shape';
if ([4, 8].includes(type)) return 'line';
if (type === 13) return 'stamp';
return 'unknown';
}, [annotation]);
// Calculate menu width based on annotation type
const calculateWidth = (annotationType: AnnotationType): number => {
switch (annotationType) {
case 'stamp':
return 80;
case 'inkHighlighter':
return 220;
case 'shape':
return 200;
default:
return 180;
}
};
// Get annotation properties
const obj = annotation?.object;
const annotationType = getAnnotationType();
const annotationId = obj?.id;
// Get current colors
const getCurrentColor = (): string => {
if (!obj) return '#000000';
const type = obj.type;
// Text annotations use textColor
if (type === 3) return obj.textColor || obj.color || '#000000';
// Shape annotations use strokeColor
if ([4, 5, 6, 7, 8].includes(type)) return obj.strokeColor || obj.color || '#000000';
// Default to color property
return obj.color || obj.strokeColor || '#000000';
};
const getStrokeColor = (): string => {
return obj?.strokeColor || obj?.color || '#000000';
};
const getFillColor = (): string => {
return obj?.color || obj?.fillColor || '#0000ff';
};
const getBackgroundColor = (): string => {
// Check multiple possible properties for background color
return obj?.backgroundColor || obj?.fillColor || obj?.color || '#ffffff';
};
const getTextColor = (): string => {
return obj?.textColor || obj?.color || '#000000';
};
const getOpacity = (): number => {
return Math.round((obj?.opacity ?? 1) * 100);
};
const getWidth = (): number => {
return obj?.strokeWidth ?? obj?.borderWidth ?? obj?.lineWidth ?? obj?.thickness ?? 2;
};
// Handlers
const handleDelete = useCallback(() => {
if (provides?.deleteAnnotation && annotationId && pageIndex !== undefined) {
provides.deleteAnnotation(pageIndex, annotationId);
}
}, [provides, annotationId, pageIndex]);
const handleOpenTextEditor = useCallback(() => {
if (!annotation) return;
// Try to find the annotation element in the DOM
const annotationElement = document.querySelector(`[data-annotation-id="${annotationId}"]`) as HTMLElement;
let fontSize = (obj?.fontSize || 14) * 1.33;
let fontFamily = 'Helvetica';
if (annotationElement) {
const rect = annotationElement.getBoundingClientRect();
// Try multiple selectors to find the text element
const textElement = annotationElement.querySelector('text, [class*="text"], [class*="content"]') as HTMLElement;
if (textElement) {
const computedStyle = window.getComputedStyle(textElement);
const computedSize = parseFloat(computedStyle.fontSize);
if (computedSize && computedSize > 0) {
fontSize = computedSize;
}
fontFamily = computedStyle.fontFamily || fontFamily;
}
setTextBoxPosition({
top: rect.top,
left: rect.left,
width: rect.width,
height: rect.height,
fontSize: fontSize,
fontFamily: fontFamily,
});
} else if (wrapperRef.current) {
// Fallback to wrapper position
const rect = wrapperRef.current.getBoundingClientRect();
setTextBoxPosition({
top: rect.top,
left: rect.left,
width: Math.max(rect.width, 200),
height: Math.max(rect.height, 50),
fontSize: fontSize,
fontFamily: fontFamily,
});
} else {
return;
}
setTextDraft(obj?.contents || '');
setIsTextEditorOpen(true);
// Focus the textarea after it renders
setTimeout(() => {
textareaRef.current?.focus();
textareaRef.current?.select();
}, 0);
}, [obj, annotation, annotationId]);
const handleSaveText = useCallback(() => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, {
contents: textDraft,
});
setIsTextEditorOpen(false);
setTextBoxPosition(null);
}, [provides, annotationId, pageIndex, textDraft]);
const handleCloseTextEdit = useCallback(() => {
setIsTextEditorOpen(false);
setTextBoxPosition(null);
}, []);
const handleColorChange = useCallback((color: string, target: 'main' | 'stroke' | 'fill' | 'text' | 'background') => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
const type = obj?.type;
const patch: any = {};
if (target === 'stroke') {
// Shape stroke - preserve fill color
patch.strokeColor = color;
patch.color = obj?.color || '#0000ff'; // Preserve fill
patch.strokeWidth = getWidth();
} else if (target === 'fill') {
// Shape fill - preserve stroke color
patch.color = color;
patch.strokeColor = obj?.strokeColor || '#000000'; // Preserve stroke
patch.strokeWidth = getWidth();
} else if (target === 'background') {
// Background color for text/note - set multiple properties for compatibility
patch.backgroundColor = color;
patch.fillColor = color;
patch.color = color;
} else if (target === 'text') {
// Text color for text/note - TRY PROPERTY COMBINATIONS
patch.textColor = color;
patch.fontColor = color; // EmbedPDF might expect this instead
// Include font metadata (EmbedPDF might require these together)
patch.fontSize = obj?.fontSize ?? 14;
patch.fontFamily = obj?.fontFamily ?? 'Helvetica';
// Re-submit text content
patch.contents = obj?.contents ?? '';
} else {
// Main color - for highlights, ink, etc.
patch.color = color;
// For text markup annotations (highlight, underline, strikeout, squiggly)
if ([9, 10, 11, 12].includes(type)) {
patch.strokeColor = color;
patch.fillColor = color;
patch.opacity = obj?.opacity ?? 1;
}
// For line annotations (type 4, 8), include stroke properties
if ([4, 8].includes(type)) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.lineWidth ?? 2;
patch.lineWidth = obj?.lineWidth ?? obj?.strokeWidth ?? 2;
}
// For ink annotations (type 15), include all stroke-related properties
if (type === 15) {
patch.strokeColor = color;
patch.strokeWidth = obj?.strokeWidth ?? obj?.thickness ?? 2;
patch.opacity = obj?.opacity ?? 1;
}
}
provides.updateAnnotation(pageIndex, annotationId, patch);
}, [provides, annotationId, pageIndex, obj]);
const handleOpacityChange = useCallback((opacity: number) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, {
opacity: opacity / 100,
});
}, [provides, annotationId, pageIndex]);
const handleWidthChange = useCallback((width: number) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, {
strokeWidth: width,
});
}, [provides, annotationId, pageIndex]);
const handlePropertiesUpdate = useCallback((patch: Record<string, any>) => {
if (!provides?.updateAnnotation || !annotationId || pageIndex === undefined) return;
provides.updateAnnotation(pageIndex, annotationId, patch);
}, [provides, annotationId, pageIndex]);
// Render button groups based on annotation type
const renderButtons = () => {
const commonButtonStyles = {
root: {
flexShrink: 0,
backgroundColor: 'var(--bg-raised)',
border: '1px solid var(--border-default)',
color: 'var(--text-secondary)',
'&:hover': {
backgroundColor: 'var(--hover-bg)',
borderColor: 'var(--border-strong)',
color: 'var(--text-primary)',
},
},
};
const EditTextButton = () => (
<Tooltip label={t('annotation.editText', 'Edit Text')}>
<ActionIcon
variant="subtle"
color="gray"
size="md"
onClick={handleOpenTextEditor}
styles={commonButtonStyles}
>
<EditIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
const DeleteButton = () => (
<Tooltip label={t('annotation.delete', 'Delete')}>
<ActionIcon
variant="subtle"
color="red"
size="md"
onClick={handleDelete}
styles={{
root: {
...commonButtonStyles.root,
'&:hover': {
backgroundColor: 'var(--mantine-color-red-1)',
borderColor: 'var(--mantine-color-red-4)',
color: 'var(--mantine-color-red-7)',
},
},
}}
>
<DeleteIcon style={{ fontSize: 18 }} />
</ActionIcon>
</Tooltip>
);
switch (annotationType) {
case 'textMarkup':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<OpacityControl value={getOpacity()} onChange={handleOpacityChange} />
<DeleteButton />
</>
);
case 'ink':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={12} />
<DeleteButton />
</>
);
case 'inkHighlighter':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={20} />
<OpacityControl value={getOpacity()} onChange={handleOpacityChange} />
<DeleteButton />
</>
);
case 'text':
case 'note':
return (
<>
<ColorControl
value={getTextColor()}
onChange={(color) => handleColorChange(color, 'text')}
label={t('annotation.color', 'Color')}
/>
<ColorControl
value={getBackgroundColor()}
onChange={(color) => handleColorChange(color, 'background')}
label={t('annotation.backgroundColor', 'Background color')}
/>
<EditTextButton />
<PropertiesPopover
annotationType={annotationType}
annotation={annotation}
onUpdate={handlePropertiesUpdate}
/>
<DeleteButton />
</>
);
case 'shape':
return (
<>
<ColorControl
value={getStrokeColor()}
onChange={(color) => handleColorChange(color, 'stroke')}
label={t('annotation.strokeColor', 'Stroke Colour')}
/>
<ColorControl
value={getFillColor()}
onChange={(color) => handleColorChange(color, 'fill')}
label={t('annotation.fillColor', 'Fill Colour')}
/>
<PropertiesPopover
annotationType="shape"
annotation={annotation}
onUpdate={handlePropertiesUpdate}
/>
<DeleteButton />
</>
);
case 'line':
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<WidthControl value={getWidth()} onChange={handleWidthChange} min={1} max={12} />
<DeleteButton />
</>
);
case 'stamp':
return <DeleteButton />;
default:
return (
<>
<ColorControl
value={getCurrentColor()}
onChange={(color) => handleColorChange(color, 'main')}
label={t('annotation.changeColor', 'Change Colour')}
/>
<DeleteButton />
</>
);
}
};
// Calculate position for portal based on wrapper element
useEffect(() => {
if (!selected || !annotation || !wrapperRef.current) {
setMenuPosition(null);
return;
}
const updatePosition = () => {
const wrapper = wrapperRef.current;
if (!wrapper) {
setMenuPosition(null);
return;
}
const wrapperRect = wrapper.getBoundingClientRect();
// Position menu below the wrapper, centered
// Use getBoundingClientRect which gives viewport-relative coordinates
// Since we're using fixed positioning in the portal, we don't need to add scroll offsets
setMenuPosition({
top: wrapperRect.bottom + 8,
left: wrapperRect.left + wrapperRect.width / 2,
});
};
updatePosition();
// Update position on scroll/resize
window.addEventListener('scroll', updatePosition, true);
window.addEventListener('resize', updatePosition);
return () => {
window.removeEventListener('scroll', updatePosition, true);
window.removeEventListener('resize', updatePosition);
};
}, [selected, annotation]);
// Early return AFTER all hooks have been called
if (!selected || !annotation) return null;
const menuContent = menuPosition ? (
<div
style={{
position: 'fixed',
top: `${menuPosition.top}px`,
left: `${menuPosition.left}px`,
transform: 'translateX(-50%)',
pointerEvents: 'auto',
zIndex: 10000, // Very high z-index to appear above everything
backgroundColor: 'var(--mantine-color-body)',
borderRadius: 8,
padding: '8px 12px',
boxShadow: '0 2px 12px rgba(0, 0, 0, 0.25)',
border: '1px solid var(--mantine-color-default-border)',
fontSize: '14px',
minWidth: `${calculateWidth(annotationType)}px`,
transition: 'min-width 0.2s ease',
}}
>
<Group gap="sm" wrap="nowrap" justify="center">
{renderButtons()}
</Group>
</div>
) : null;
const textEditorOverlay = isTextEditorOpen && textBoxPosition ? (
<div
style={{
position: 'fixed',
top: `${textBoxPosition.top}px`,
left: `${textBoxPosition.left}px`,
width: `${textBoxPosition.width}px`,
height: `${textBoxPosition.height}px`,
zIndex: 10001,
pointerEvents: 'auto',
}}
>
<textarea
ref={textareaRef}
value={textDraft}
onChange={(e) => setTextDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') {
handleCloseTextEdit();
} else if (e.key === 'Enter' && e.ctrlKey) {
handleSaveText();
}
}}
onBlur={handleSaveText}
style={{
width: '100%',
height: '100%',
minHeight: '0',
minWidth: '0',
maxWidth: '100%',
maxHeight: '100%',
fontSize: `${textBoxPosition.fontSize}px`,
fontFamily: textBoxPosition.fontFamily,
lineHeight: '1.2',
color: '#000000',
backgroundColor: '#ffffff',
border: '2px solid var(--mantine-color-blue-5)',
borderRadius: '0',
padding: '0',
margin: '0',
resize: 'none',
boxSizing: 'border-box',
outline: 'none',
overflow: 'hidden',
wordWrap: 'break-word',
overflowWrap: 'break-word',
}}
/>
</div>
) : null;
const canClickToEdit = selected && (annotationType === 'text' || annotationType === 'note') && !isTextEditorOpen;
return (
<>
{/* Invisible wrapper that provides positioning - uses EmbedPDF's menuWrapperProps */}
<div
ref={setRef}
onClick={canClickToEdit ? handleOpenTextEditor : undefined}
style={{
// Use EmbedPDF's positioning styles
...menuWrapperProps?.style,
// Keep the wrapper invisible but still occupying space for positioning
opacity: 0,
pointerEvents: canClickToEdit ? 'auto' : 'none',
}}
/>
{typeof document !== 'undefined' && menuContent
? createPortal(menuContent, document.body)
: null}
{typeof document !== 'undefined' && textEditorOverlay
? createPortal(textEditorOverlay, document.body)
: null}
</>
);
}
@@ -3,11 +3,7 @@ import { useAttachmentCapability } from '@embedpdf/plugin-attachment/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { AttachmentState, AttachmentAPIWrapper } from '@app/contexts/viewer/viewerBridges';
import { PdfAttachmentObject } from '@embedpdf/models';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF attachment plugin to the shared ViewerContext.
*/
export function AttachmentAPIBridge() {
const { provides: attachmentCapability } = useAttachmentCapability();
const { registerBridge } = useViewer();
@@ -16,19 +12,10 @@ export function AttachmentAPIBridge() {
isLoading: false,
error: null,
});
const documentReady = useDocumentReady();
const fetchAttachments = useCallback(
async () => {
if (!attachmentCapability || !documentReady) {
// Set error state instead of throwing for better user experience
setState(prev => ({
...prev,
error: 'Document not ready or attachment capability not available',
isLoading: false
}));
return [];
}
if (!attachmentCapability) return [];
setState(prev => ({ ...prev, isLoading: true, error: null }));
try {
@@ -55,12 +42,11 @@ export function AttachmentAPIBridge() {
throw error;
}
},
[attachmentCapability, documentReady]
[attachmentCapability]
);
const api = useMemo<AttachmentAPIWrapper | null>(() => {
// Only provide API when both capability AND document are ready
if (!attachmentCapability || !documentReady) return null;
if (!attachmentCapability) return null;
return {
getAttachments: fetchAttachments,
@@ -98,23 +84,15 @@ export function AttachmentAPIBridge() {
});
},
};
}, [attachmentCapability, documentReady, fetchAttachments]);
}, [attachmentCapability, fetchAttachments]);
useEffect(() => {
if (!api) {
// If API becomes null (e.g. document transitions), ensure we unregister stale bridge
registerBridge('attachment', null);
return;
}
if (!api) return;
registerBridge('attachment', {
state,
api,
});
return () => {
registerBridge('attachment', null);
};
}, [api, state, registerBridge]);
return null;
@@ -2,11 +2,7 @@ import { useEffect, useMemo, useState, useCallback } from 'react';
import { useBookmarkCapability, BookmarkCapability } from '@embedpdf/plugin-bookmark/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { BookmarkState, BookmarkAPIWrapper } from '@app/contexts/viewer/viewerBridges';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF bookmark plugin to the shared ViewerContext.
*/
export function BookmarkAPIBridge() {
const { provides: bookmarkCapability } = useBookmarkCapability();
const { registerBridge } = useViewer();
@@ -15,19 +11,9 @@ export function BookmarkAPIBridge() {
isLoading: false,
error: null,
});
const documentReady = useDocumentReady();
const fetchBookmarks = useCallback(
async (capability: BookmarkCapability) => {
if (!documentReady) {
setState(prev => ({
...prev,
error: 'Document not ready or bookmark capability not available',
isLoading: false,
}));
return [];
}
setState(prev => ({ ...prev, isLoading: true, error: null }));
try {
const task = capability.getBookmarks();
@@ -48,12 +34,11 @@ export function BookmarkAPIBridge() {
throw error;
}
},
[documentReady]
[]
);
const api = useMemo<BookmarkAPIWrapper | null>(() => {
// Only provide API when both capability AND document are ready
if (!bookmarkCapability || !documentReady) return null;
if (!bookmarkCapability) return null;
return {
fetchBookmarks: () => fetchBookmarks(bookmarkCapability),
@@ -72,22 +57,15 @@ export function BookmarkAPIBridge() {
});
},
};
}, [bookmarkCapability, documentReady, fetchBookmarks]);
}, [bookmarkCapability, fetchBookmarks]);
useEffect(() => {
if (!api) {
registerBridge('bookmark', null);
return;
}
if (!api) return;
registerBridge('bookmark', {
state,
api,
});
return () => {
registerBridge('bookmark', null);
};
}, [api, state, registerBridge]);
return null;
@@ -1,6 +1,5 @@
import { useEffect, useMemo } from 'react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
import {
PdfPermissionFlag,
DocumentPermissionsState,
@@ -26,7 +25,6 @@ export function DocumentPermissionsAPIBridge({
permissions = PdfPermissionFlag.AllowAll,
}: DocumentPermissionsAPIBridgeProps) {
const { registerBridge } = useViewer();
const documentReady = useDocumentReady();
const state = useMemo<DocumentPermissionsState>(() => ({
isEncrypted,
@@ -44,7 +42,7 @@ export function DocumentPermissionsAPIBridge({
const api = useMemo<DocumentPermissionsAPIWrapper>(() => ({
hasPermission: (flag: PdfPermissionFlag) => hasPermissionFlag(permissions, flag),
hasAllPermissions: (flags: PdfPermissionFlag[]) =>
hasAllPermissions: (flags: PdfPermissionFlag[]) =>
flags.every(flag => hasPermissionFlag(permissions, flag)),
getEffectivePermission: (flag: PdfPermissionFlag) => {
if (isOwnerUnlocked) return true;
@@ -53,17 +51,11 @@ export function DocumentPermissionsAPIBridge({
}), [permissions, isOwnerUnlocked]);
useEffect(() => {
if (documentReady) {
registerBridge('permissions', {
state,
api,
});
}
return () => {
registerBridge('permissions', null);
};
}, [registerBridge, state, api, documentReady]);
registerBridge('permissions', {
state,
api,
});
}, [registerBridge, state, api]);
return null;
}
@@ -19,87 +19,10 @@ import NavigationWarningModal from '@app/components/shared/NavigationWarningModa
import { isStirlingFile } from '@app/types/fileContext';
import { useViewerRightRailButtons } from '@app/components/viewer/useViewerRightRailButtons';
import { StampPlacementOverlay } from '@app/components/viewer/StampPlacementOverlay';
import { RulerOverlay, type PageMeasureScales, type PageScaleInfo, type ViewportScale } from '@app/components/viewer/RulerOverlay';
import { useWheelZoom } from '@app/hooks/useWheelZoom';
import { useFormFill } from '@app/tools/formFill/FormFillContext';
import { FormSaveBar } from '@app/tools/formFill/FormSaveBar';
import type { PDFDict, PDFNumber } from '@cantoo/pdf-lib';
// ─── Measure dictionary extraction ────────────────────────────────────────────
async function extractPageMeasureScales(file: Blob): Promise<PageMeasureScales | null> {
try {
const { PDFDocument, PDFDict, PDFName, PDFArray, PDFNumber, PDFString, PDFHexString } = await import('@cantoo/pdf-lib');
const pdfDoc = await PDFDocument.load(await file.arrayBuffer(), { ignoreEncryption: true });
// Parse a Measure dict into a MeasureScale, or return null if malformed.
const parseScale = (measureObj: unknown) => {
if (!(measureObj instanceof PDFDict)) return null;
const rObj = measureObj.lookup(PDFName.of('R'));
const ratioLabel = (rObj instanceof PDFString || rObj instanceof PDFHexString)
? rObj.decodeText() : '';
// D = distance array, X = x-axis fallback
let fmtArray = measureObj.lookup(PDFName.of('D'));
if (!(fmtArray instanceof PDFArray)) fmtArray = measureObj.lookup(PDFName.of('X'));
if (!(fmtArray instanceof PDFArray)) return null;
const firstFmt = fmtArray.lookup(0);
if (!(firstFmt instanceof PDFDict)) return null;
const cObj = firstFmt.lookup(PDFName.of('C'));
const uObj = firstFmt.lookup(PDFName.of('U'));
if (!(cObj instanceof PDFNumber) || cObj.asNumber() <= 0) return null;
const unit = (uObj instanceof PDFString || uObj instanceof PDFHexString)
? uObj.decodeText() : 'units';
return { factor: cObj.asNumber(), unit, ratioLabel };
};
const result: PageMeasureScales = new Map();
for (let i = 0; i < pdfDoc.getPageCount(); i++) {
const page = pdfDoc.getPage(i);
const pageHeight = page.getHeight();
const pageNode = page.node as unknown as PDFDict;
const viewports: ViewportScale[] = [];
// Spec-conformant: /VP array — each viewport can have its own scale and BBox
const vpObj = pageNode.lookup(PDFName.of('VP'));
if (vpObj instanceof PDFArray) {
for (let j = 0; j < vpObj.size(); j++) {
const vpEntry = vpObj.lookup(j);
if (!(vpEntry instanceof PDFDict)) continue;
const scale = parseScale(vpEntry.lookup(PDFName.of('Measure')));
if (!scale) continue;
let bbox: ViewportScale['bbox'] = null;
const bboxObj = vpEntry.lookup(PDFName.of('BBox'));
if (bboxObj instanceof PDFArray && bboxObj.size() >= 4) {
bbox = [
(bboxObj.lookup(0) as PDFNumber).asNumber(),
(bboxObj.lookup(1) as PDFNumber).asNumber(),
(bboxObj.lookup(2) as PDFNumber).asNumber(),
(bboxObj.lookup(3) as PDFNumber).asNumber(),
];
}
viewports.push({ bbox, scale });
}
}
// Fallback: /Measure directly on page (non-conforming but seen in the wild)
if (viewports.length === 0) {
const scale = parseScale(pageNode.lookup(PDFName.of('Measure')));
if (scale) viewports.push({ bbox: null, scale });
}
if (viewports.length > 0) result.set(i, { viewports, pageHeight } satisfies PageScaleInfo);
}
return result.size > 0 ? result : null;
} catch {
return null;
}
}
// ──────────────────────────────────────────────────────────────────────────────
export interface EmbedPdfViewerProps {
sidebarsVisible: boolean;
setSidebarsVisible: (v: boolean) => void;
@@ -765,20 +688,8 @@ const EmbedPdfViewerContent = ({
};
}, [applyChanges, setApplyChanges]);
// Ruler / measurement tool state
const [isRulerActive, setIsRulerActive] = useState(false);
const [pageMeasureScales, setPageMeasureScales] = useState<PageMeasureScales | null>(null);
useEffect(() => {
const file = effectiveFile?.file;
if (!file) { setPageMeasureScales(null); return; }
let cancelled = false;
extractPageMeasureScales(file).then(scales => { if (!cancelled) setPageMeasureScales(scales); });
return () => { cancelled = true; };
}, [effectiveFile]);
// Register viewer right-rail buttons
useViewerRightRailButtons(isRulerActive, setIsRulerActive);
useViewerRightRailButtons();
// Auto-fetch form fields when a PDF is loaded in the viewer.
// In normal viewer mode, this uses pdf-lib (frontend-only).
@@ -908,11 +819,6 @@ const EmbedPdfViewerContent = ({
isActive={isPlacementOverlayActive}
signatureConfig={signatureConfig}
/>
<RulerOverlay
containerRef={pdfContainerRef}
isActive={isRulerActive}
pageMeasureScales={pageMeasureScales}
/>
</Box>
</>
)}
@@ -1,7 +1,6 @@
import { useEffect } from 'react';
import { useExportCapability } from '@embedpdf/plugin-export/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Component that runs inside EmbedPDF context and provides export functionality
@@ -9,10 +8,9 @@ import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady'
export function ExportAPIBridge() {
const { provides: exportApi } = useExportCapability();
const { registerBridge } = useViewer();
const documentReady = useDocumentReady();
useEffect(() => {
if (exportApi && documentReady) {
if (exportApi) {
// Register this bridge with ViewerContext
registerBridge('export', {
state: {
@@ -21,11 +19,7 @@ export function ExportAPIBridge() {
api: exportApi
});
}
return () => {
registerBridge('export', null);
};
}, [exportApi, documentReady, registerBridge]);
}, [exportApi, registerBridge]);
return null;
}
}
@@ -2,25 +2,19 @@ import { useImperativeHandle, forwardRef, useEffect, useRef } from 'react';
import { useHistoryCapability } from '@embedpdf/plugin-history/react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { useSignature } from '@app/contexts/SignatureContext';
import { uuidV4, PdfAnnotationSubtype } from '@embedpdf/models';
import { PdfAnnotationSubtype, uuidV4 } from '@embedpdf/models';
import type { HistoryAPI } from '@app/components/viewer/viewerTypes';
import { ANNOTATION_RECREATION_DELAY_MS, ANNOTATION_VERIFICATION_DELAY_MS } from '@app/constants/app';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF history (undo/redo) plugin to the shared ViewerContext.
*/
export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge(_, ref) {
const { provides: historyApi } = useHistoryCapability();
const { provides: annotationApi } = useAnnotationCapability();
const { getImageData, storeImageData } = useSignature();
const documentReady = useDocumentReady();
const restoringIds = useRef<Set<string>>(new Set());
// Monitor annotation events to detect when annotations are restored
useEffect(() => {
if (!annotationApi || !documentReady) return;
if (!annotationApi) return;
const handleAnnotationEvent = (event: any) => {
const annotation = event.annotation;
@@ -152,8 +146,6 @@ export const HistoryAPIBridge = forwardRef<HistoryAPI>(function HistoryAPIBridge
return historyApi ? historyApi.canRedo() : false;
},
isReady: () => !!historyApi && documentReady,
purgeByMetadata: <T,>(predicate: (metadata: T | undefined) => boolean, topic?: string) => {
if (historyApi?.purgeByMetadata) {
return historyApi.purgeByMetadata(predicate, topic);
@@ -3,8 +3,7 @@ import { useDocumentState } from '@embedpdf/core/react';
import { useScroll } from '@embedpdf/plugin-scroll/react';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { PdfAnnotationSubtype } from '@embedpdf/models';
import { usePdfLibLinks } from '@app/hooks/usePdfLibLinks';
import type { PdfLibLink } from '@app/utils/pdfLinkUtils';
import { usePdfLibLinks, type PdfLibLink } from '@app/hooks/usePdfLibLinks';
// ---------------------------------------------------------------------------
// Inline SVG icons (thin-stroke, modern)
@@ -50,7 +50,6 @@ import { useTranslation } from 'react-i18next';
import { LinkLayer } from '@app/components/viewer/LinkLayer';
import { TextSelectionHandler } from '@app/components/viewer/TextSelectionHandler';
import { RedactionSelectionMenu } from '@app/components/viewer/RedactionSelectionMenu';
import { AnnotationSelectionMenu } from '@app/components/viewer/AnnotationSelectionMenu';
import { RedactionPendingTracker, RedactionPendingTrackerAPI } from '@app/components/viewer/RedactionPendingTracker';
import { RedactionAPIBridge } from '@app/components/viewer/RedactionAPIBridge';
import { DocumentPermissionsAPIBridge } from '@app/components/viewer/DocumentPermissionsAPIBridge';
@@ -126,7 +125,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
}),
createPluginRegistration(ScrollPluginPackage),
createPluginRegistration(RenderPluginPackage, {
withForms: !enableFormFill,
withForms: !enableFormFill, // Disable native form rendering when our interactive overlay is active
withAnnotations: showBakedAnnotations && !enableAnnotations, // Show baked annotations only when: visibility is ON and annotation layer is OFF
}),
@@ -205,7 +204,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
// Register print plugin for printing PDFs
createPluginRegistration(PrintPluginPackage),
];
}, [pdfUrl, enableAnnotations, showBakedAnnotations, fileName, file, url]);
}, [pdfUrl, enableAnnotations, enableFormFill, showBakedAnnotations, fileName, file, url]);
// Initialize the engine with the React hook - use local WASM for offline support
const { engine, isLoading, error } = usePdfiumEngine({
@@ -753,7 +752,7 @@ export function LocalEmbedPDF({ file, url, fileName, enableAnnotations = false,
documentId={documentId}
pageIndex={pageIndex}
selectionOutlineColor="#007ACC"
selectionMenu={(props) => <AnnotationSelectionMenu {...props} />}
selectionMenu={(props) => <RedactionSelectionMenu {...props} />}
/>
)}
@@ -2,33 +2,28 @@ import { useEffect, useRef } from 'react';
import { usePan } from '@embedpdf/plugin-pan/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF pan (hand tool) plugin to the shared ViewerContext.
*/
export function PanAPIBridge() {
const activeDocumentId = useActiveDocumentId();
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID and the document is ready
if (!activeDocumentId || !documentReady) {
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
return null;
}
return <PanAPIBridgeInner documentId={activeDocumentId} />;
}
function PanAPIBridgeInner({ documentId }: { documentId: string }) {
const { provides: pan, isPanning } = usePan(documentId);
const { registerBridge, triggerImmediatePanUpdate } = useViewer();
// Keep pan ref updated to avoid re-running effect when object reference changes
const panRef = useRef(pan);
useEffect(() => {
panRef.current = pan;
}, [pan]);
// Track previous isPanning value to detect changes
const prevIsPanningRef = useRef<boolean>(isPanning);
@@ -62,16 +57,13 @@ function PanAPIBridgeInner({ documentId }: { documentId: string }) {
},
}
});
// Trigger immediate pan update if the value changed
if (prevIsPanningRef.current !== isPanning) {
prevIsPanningRef.current = isPanning;
triggerImmediatePanUpdate(isPanning);
}
}
return () => {
registerBridge('pan', null);
};
}, [isPanning, registerBridge, triggerImmediatePanUpdate]);
return null;
@@ -1,18 +1,16 @@
import { useEffect } from 'react';
import { usePrintCapability } from '@embedpdf/plugin-print/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF print plugin to the shared ViewerContext.
* Component that runs inside EmbedPDF context and exposes print API to ViewerContext
*/
export function PrintAPIBridge() {
const { provides: print } = usePrintCapability();
const { registerBridge } = useViewer();
const documentReady = useDocumentReady();
useEffect(() => {
if (print && documentReady) {
if (print) {
// Register this bridge with ViewerContext
registerBridge('print', {
state: {},
@@ -21,11 +19,7 @@ export function PrintAPIBridge() {
}
});
}
return () => {
registerBridge('print', null);
};
}, [print, documentReady, registerBridge]);
}, [print, registerBridge]);
return null;
}
@@ -4,18 +4,17 @@ import { PdfAnnotationSubtype } from '@embedpdf/models';
import { useRedaction } from '@app/contexts/RedactionContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useAnnotationCapability } from '@embedpdf/plugin-annotation/react';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* RedactionAPIBridge - Uses embedPDF v2.5.0
* Bridges between the EmbedPDF redaction plugin and the Stirling-PDF RedactionContext.
* Uses the unified redaction mode (toggleRedact/enableRedact/endRedact).
*/
export function RedactionAPIBridge() {
const activeDocumentId = useActiveDocumentId();
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID and document is ready
if (!activeDocumentId || !documentReady) {
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
return null;
}
@@ -70,7 +69,9 @@ function RedactionAPIBridgeInner({ documentId }: { documentId: string }) {
}, [annotationProvides, manualRedactColor]);
// Expose the EmbedPDF API through our context's ref
// Uses v2.5.0 unified redaction mode
useImperativeHandle(redactionApiRef, () => ({
// Unified redaction methods (v2.5.0)
toggleRedact: () => {
redactionProvides?.toggleRedact();
},
@@ -2,20 +2,15 @@ import { useEffect, useRef } from 'react';
import { useRotate } from '@embedpdf/plugin-rotate/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF rotation plugin to the shared ViewerContext.
*/
export function RotateAPIBridge() {
const activeDocumentId = useActiveDocumentId();
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID and document is ready
if (!activeDocumentId || !documentReady) {
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
return null;
}
return <RotateAPIBridgeInner documentId={activeDocumentId} />;
}
@@ -47,11 +42,7 @@ function RotateAPIBridgeInner({ documentId }: { documentId: string }) {
}
});
}
return () => {
registerBridge('rotation', null);
};
}, [rotation, registerBridge]);
return null;
}
}
@@ -1,747 +0,0 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useViewer } from '@app/contexts/ViewerContext';
// ─── Types ────────────────────────────────────────────────────────────────────
interface Point {
x: number;
y: number;
}
/**
* A point anchored to a specific PDF page in PDF-unit space.
* x and y are in PDF points (1/72 inch) relative to the page's top-left corner.
*
* This is the only truly zoom-invariant representation. Screen positions are
* recovered at render time via getBoundingClientRect on the page element, so
* scroll, zoom, and fixed page margins are all handled by the browser we never
* have to track them ourselves.
*/
interface PagePoint {
pageIndex: number;
x: number;
y: number;
}
interface Measurement {
id: string;
start: PagePoint;
end: PagePoint;
}
export interface RulerOverlayHandle {
clearAll: () => void;
}
interface RulerOverlayProps {
containerRef: React.RefObject<HTMLElement | null>;
isActive: boolean;
pageMeasureScales?: PageMeasureScales | null;
}
// ─── Math ─────────────────────────────────────────────────────────────────────
function dist(a: Point, b: Point): number {
return Math.sqrt((b.x - a.x) ** 2 + (b.y - a.y) ** 2);
}
function midpoint(a: Point, b: Point): Point {
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
function perpUnit(a: Point, b: Point): { nx: number; ny: number } {
const dx = b.x - a.x;
const dy = b.y - a.y;
const len = Math.sqrt(dx * dx + dy * dy) || 1;
return { nx: -dy / len, ny: dx / len };
}
/** Angle from horizontal 0°–90°. Computed from screen-space points (same angle as PDF space). */
function angleDeg(a: Point, b: Point): number {
return Math.atan2(Math.abs(b.y - a.y), Math.abs(b.x - a.x)) * (180 / Math.PI);
}
function formatDist(pts: number): string {
const mm = (pts / 72) * 25.4;
if (mm < 100) return `${mm.toFixed(1)} mm`;
if (mm < 1000) return `${(mm / 10).toFixed(1)} cm`;
return `${(mm / 1000).toFixed(2)} m`;
}
function formatInches(pts: number): string {
const inches = pts / 72;
if (inches < 12) return `${inches.toFixed(2)} in`;
return `${(inches / 12).toFixed(2)} ft`;
}
export interface MeasureScale {
/** real_world_value = pdf_points * factor */
factor: number;
/** e.g. "ft", "m" */
unit: string;
/** Human-readable ratio from PDF, e.g. "1 in = 10 ft" */
ratioLabel: string;
}
export interface ViewportScale {
/** BBox in PDF user space (bottom-left origin). null = entire page. */
bbox: [number, number, number, number] | null;
scale: MeasureScale;
}
export interface PageScaleInfo {
viewports: ViewportScale[];
/** Page height in PDF points — used to flip screen-y (top=0) to PDF-y (bottom=0). */
pageHeight: number;
}
export type PageMeasureScales = Map<number, PageScaleInfo>;
/**
* Given the start/end PagePoints of a measurement, find the scale from the
* viewport whose BBox contains the midpoint. Falls back to the first viewport
* if none contains it (handles whole-page viewports with bbox=null).
*/
function pickScale(
start: PagePoint,
end: PagePoint,
pageMeasureScales: PageMeasureScales,
): MeasureScale | null {
if (start.pageIndex !== end.pageIndex) return null;
const info = pageMeasureScales.get(start.pageIndex);
if (!info?.viewports.length) return null;
// Midpoint in screen-space page coords (x left→right, y top→bottom, PDF points)
const mx = (start.x + end.x) / 2;
// Flip y: screen y=0 is page top; PDF user space y=0 is page bottom
const my = info.pageHeight - (start.y + end.y) / 2;
for (const { bbox, scale } of info.viewports) {
if (!bbox) return scale; // whole-page viewport
const [x0, y0, x1, y1] = bbox;
if (mx >= Math.min(x0, x1) && mx <= Math.max(x0, x1) &&
my >= Math.min(y0, y1) && my <= Math.max(y0, y1)) {
return scale;
}
}
return null;
}
function formatScaled(pts: number, scale: MeasureScale): string {
const val = pts * scale.factor;
if (val >= 1000) return `${val.toFixed(0)} ${scale.unit}`;
if (val >= 100) return `${val.toFixed(1)} ${scale.unit}`;
if (val >= 10) return `${val.toFixed(2)} ${scale.unit}`;
return `${val.toFixed(3)} ${scale.unit}`;
}
// Conversion factors to metres for known units
const TO_METRES: Record<string, number> = {
m: 1, cm: 0.01, mm: 0.001, km: 1000,
ft: 0.3048, in: 0.0254, yd: 0.9144, mi: 1609.344,
};
function isImperialUnit(unit: string): boolean {
return ['ft', 'in', 'yd', 'mi'].includes(unit.toLowerCase().trim());
}
function formatMetricFromMetres(m: number): string {
if (m >= 1000) return `${(m / 1000).toFixed(2)} km`;
if (m >= 1) return `${m.toFixed(1)} m`;
if (m >= 0.1) return `${(m * 100).toFixed(1)} cm`;
return `${(m * 1000).toFixed(1)} mm`;
}
function formatImperialFromFeet(ft: number): string {
if (ft >= 1) return `${ft.toFixed(2)} ft`;
return `${(ft * 12).toFixed(2)} in`;
}
/**
* Returns the scaled real-world value in the *other* unit system, or null if
* the unit is not a recognised metric/imperial unit.
* e.g. 72 pts, scale {factor:0.138889, unit:"ft"} "3.048 m"
* 72 pts, scale {factor:0.352778, unit:"m"} "1.157 ft" (approx)
*/
function scaledCross(pts: number, scale: MeasureScale): string | null {
const toM = TO_METRES[scale.unit.toLowerCase().trim()];
if (!toM) return null;
const metres = pts * scale.factor * toM;
return isImperialUnit(scale.unit)
? formatMetricFromMetres(metres)
: formatImperialFromFeet(metres / 0.3048);
}
// ─── DOM helpers ──────────────────────────────────────────────────────────────
function findScrollEl(root: HTMLElement): HTMLElement | null {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT);
let node: Node | null;
while ((node = walker.nextNode())) {
const el = node as HTMLElement;
if (el === root) continue;
const { overflow, overflowY, overflowX } = window.getComputedStyle(el);
if ([overflow, overflowY, overflowX].some(v => v === 'auto' || v === 'scroll')) {
return el;
}
}
return null;
}
function isOverPage(e: MouseEvent): boolean {
return !!(e.target as Element).closest?.('[data-page-index]');
}
/**
* Find the nearest point on any page boundary and return it as both
* an SVG screen coordinate and a PagePoint (page-relative PDF units).
* Used to clamp the live line when the cursor drifts off the page.
*/
function nearestPageDocPt(
cursor: Point,
container: HTMLElement,
zoom: number,
): { screenPt: Point; docPt: PagePoint } | null {
const pages = container.querySelectorAll('[data-page-index]');
if (!pages.length) return null;
const cr = container.getBoundingClientRect();
let bestDist = Infinity;
let best: { screenPt: Point; docPt: PagePoint } | null = null;
pages.forEach(pageNode => {
const pageEl = pageNode as HTMLElement;
const r = pageEl.getBoundingClientRect();
const pageIndex = parseInt(pageEl.dataset.pageIndex ?? '0', 10);
// Page bounds in SVG (container-relative) space
const left = r.left - cr.left;
const top = r.top - cr.top;
const right = r.right - cr.left;
const bottom = r.bottom - cr.top;
// Nearest point on this rect to the cursor (SVG space)
const cx = Math.max(left, Math.min(right, cursor.x));
const cy = Math.max(top, Math.min(bottom, cursor.y));
const d = Math.sqrt((cursor.x - cx) ** 2 + (cursor.y - cy) ** 2);
if (d < bestDist) {
bestDist = d;
// Convert SVG-space point (cx, cy) → page-relative viewport → PDF points:
// viewport position of cx = cr.left + cx
// page-relative position = (cr.left + cx) - r.left
// PDF units = page-relative / zoom
best = {
screenPt: { x: cx, y: cy },
docPt: {
pageIndex,
x: (cr.left + cx - r.left) / zoom,
y: (cr.top + cy - r.top ) / zoom,
},
};
}
});
return best;
}
// ─── Sub-components ───────────────────────────────────────────────────────────
const TICK = 10;
const DOT_R = 5;
const LH = 26; // label height (normal — 1 line)
const LH2 = 44; // label height (hovered, no scale — 2 lines)
const LH3 = 62; // label height (hovered, with scale — 3 lines)
const LP = 10; // label horizontal padding
const DEL_R = 8;
interface MeasurementLineProps {
id: string;
startS: Point;
endS: Point;
/** Physical distance in PDF points (= screen pixel distance / zoom). */
distPts: number;
hovered: boolean;
onDelete: (id: string) => void;
onHover: (id: string | null) => void;
measureScale?: MeasureScale | null;
}
function MeasurementLine({ id, startS, endS, distPts, hovered, onDelete, onHover, measureScale }: MeasurementLineProps) {
const mid = midpoint(startS, endS);
const { nx, ny } = perpUnit(startS, endS);
const ang = angleDeg(startS, endS);
const angLabel = `${ang.toFixed(1)}°`;
// Whether the PDF's unit is imperial — determines display order (imperial-first vs metric-first)
const imperialFirst = !!measureScale && isImperialUnit(measureScale.unit);
// Idle: scaled primary if scale present, else physical metric
const distLabel = measureScale ? formatScaled(distPts, measureScale) : formatDist(distPts);
// Hover line 1 — both real-world values ordered by PDF unit system:
// imperial PDF: "10.000 ft / 3.048 m"
// metric PDF: "142.5 m / 467.5 ft"
// no scale: "25.4 mm / 1.00 in" (metric first, default)
const hoverLine1 = measureScale
? (() => {
const primary = formatScaled(distPts, measureScale);
const cross = scaledCross(distPts, measureScale);
return cross ? `${primary} / ${cross}` : primary;
})()
: `${formatDist(distPts)} / ${formatInches(distPts)}`;
// Hover line 2 — both physical paper values, same order as line 1:
// imperial PDF: "1.00 in / 25.4 mm"
// metric PDF or no scale: "25.4 mm / 1.00 in"
const hoverLine2 = measureScale
? (imperialFirst
? `${formatInches(distPts)} / ${formatDist(distPts)}`
: `${formatDist(distPts)} / ${formatInches(distPts)}`)
: null;
// Hover line 3 (scaled) / line 2 (no scale) — ratio label + angle
const contextLabel = measureScale?.ratioLabel
? `${measureScale.ratioLabel} ${angLabel}`
: angLabel;
const maxHoverLh = measureScale ? LH3 : LH2;
const lh = hovered ? maxHoverLh : LH;
const lwNormal = Math.max(distLabel.length * 8 + LP * 2, 80);
const lwHover = Math.max(
hoverLine1.length * 8 + LP * 2,
(hoverLine2?.length ?? 0) * 8 + LP * 2,
contextLabel.length * 8 + LP * 2,
80,
);
const lw = hovered ? lwHover : lwNormal;
const sw = hovered ? 3 : 2;
const delX = mid.x + lwHover / 2 + DEL_R + 4;
const delY = mid.y;
const hitLeft = mid.x - lwHover / 2 - 4;
const hitTop = mid.y - maxHoverLh / 2 - 4;
const hitWidth = (delX + DEL_R + 4) - hitLeft;
const hitHeight = maxHoverLh + 8;
const mono = "'Roboto Mono','Consolas',monospace";
return (
<g
onMouseEnter={() => onHover(id)}
onMouseLeave={() => onHover(null)}
style={{ pointerEvents: 'all' }}
>
<rect x={hitLeft} y={hitTop} width={hitWidth} height={hitHeight}
fill="transparent" stroke="none" style={{ pointerEvents: 'all' }} />
<line x1={startS.x} y1={startS.y} x2={endS.x} y2={endS.y}
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
<line x1={startS.x + nx * TICK / 2} y1={startS.y + ny * TICK / 2}
x2={startS.x - nx * TICK / 2} y2={startS.y - ny * TICK / 2}
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
<line x1={endS.x + nx * TICK / 2} y1={endS.y + ny * TICK / 2}
x2={endS.x - nx * TICK / 2} y2={endS.y - ny * TICK / 2}
stroke="#1e88e5" strokeWidth={sw} strokeLinecap="round" />
<circle cx={startS.x} cy={startS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
<circle cx={endS.x} cy={endS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
<g style={{ pointerEvents: 'all', cursor: 'default' }}>
<rect x={mid.x - lw / 2} y={mid.y - lh / 2} width={lw} height={lh}
rx={5} fill="white" stroke="#1e88e5" strokeWidth={1.5} filter="url(#ruler-shadow)" />
{hovered && measureScale ? (
// 3-line scaled hover
<>
<text x={mid.x} y={mid.y - 17} textAnchor="middle" dominantBaseline="middle"
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
style={{ userSelect: 'none' }}>{hoverLine1}</text>
<text x={mid.x} y={mid.y} textAnchor="middle" dominantBaseline="middle"
fill="#546e7a" fontSize={11} fontFamily={mono} fontWeight={500}
style={{ userSelect: 'none' }}>{hoverLine2}</text>
<text x={mid.x} y={mid.y + 17} textAnchor="middle" dominantBaseline="middle"
fill="#5c6bc0" fontSize={10} fontFamily={mono} fontWeight={500}
style={{ userSelect: 'none' }}>{contextLabel}</text>
</>
) : hovered ? (
// 2-line no-scale hover
<>
<text x={mid.x} y={mid.y - 6} textAnchor="middle" dominantBaseline="middle"
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
style={{ userSelect: 'none' }}>{hoverLine1}</text>
<text x={mid.x} y={mid.y + 13} textAnchor="middle" dominantBaseline="middle"
fill="#5c6bc0" fontSize={11} fontFamily={mono} fontWeight={500}
style={{ userSelect: 'none' }}>{contextLabel}</text>
</>
) : (
// Idle — single line
<text x={mid.x} y={mid.y + 1} textAnchor="middle" dominantBaseline="middle"
fill="#1e88e5" fontSize={12} fontFamily={mono} fontWeight={600}
style={{ userSelect: 'none' }}>{distLabel}</text>
)}
<g style={{ cursor: 'pointer' }} onClick={(e) => { e.stopPropagation(); onDelete(id); }}>
<circle cx={delX} cy={delY} r={DEL_R} fill="#ef5350" stroke="white" strokeWidth={1.5} />
<text x={delX} y={delY} textAnchor="middle" dominantBaseline="middle"
fill="white" fontSize={12} fontWeight={700} style={{ userSelect: 'none' }}>×</text>
</g>
</g>
</g>
);
}
interface LiveLineProps {
startS: Point;
endS: Point;
zoom: number;
measureScale?: MeasureScale | null;
}
function LiveLine({ startS, endS, zoom, measureScale }: LiveLineProps) {
const d = dist(startS, endS) / zoom; // PDF points from screen distance
const mid = midpoint(startS, endS);
const { nx, ny } = perpUnit(startS, endS);
const ang = angleDeg(startS, endS);
const distLabel = measureScale ? formatScaled(d, measureScale) : formatDist(d);
const lw = Math.max(distLabel.length * 8 + LP * 2, 80);
return (
<g>
<line x1={startS.x} y1={startS.y} x2={endS.x} y2={endS.y}
stroke="#1e88e5" strokeWidth={2} strokeDasharray="7 4"
strokeLinecap="round" opacity={0.85} />
<line x1={startS.x + nx * TICK / 2} y1={startS.y + ny * TICK / 2}
x2={startS.x - nx * TICK / 2} y2={startS.y - ny * TICK / 2}
stroke="#1e88e5" strokeWidth={2} strokeLinecap="round" />
{d > 4 && (
<g>
<rect x={mid.x - lw / 2} y={mid.y - LH2 / 2} width={lw} height={LH2}
rx={5} fill="#1e88e5" stroke="white" strokeWidth={1} />
<text x={mid.x} y={mid.y - 6}
textAnchor="middle" dominantBaseline="middle"
fill="white" fontSize={12}
fontFamily="'Roboto Mono','Consolas',monospace" fontWeight={600}
style={{ userSelect: 'none' }}>
{distLabel}
</text>
<text x={mid.x} y={mid.y + 13}
textAnchor="middle" dominantBaseline="middle"
fill="rgba(255,255,255,0.85)" fontSize={11}
fontFamily="'Roboto Mono','Consolas',monospace" fontWeight={500}
style={{ userSelect: 'none' }}>
{`${ang.toFixed(1)}°`}
</text>
</g>
)}
</g>
);
}
// ─── Main ─────────────────────────────────────────────────────────────────────
export const RulerOverlay = React.forwardRef<RulerOverlayHandle, RulerOverlayProps>(
({ containerRef, isActive, pageMeasureScales }, ref) => {
const [measurements, setMeasurements] = useState<Measurement[]>([]);
const [firstPt, setFirstPt] = useState<PagePoint | null>(null);
/** Current cursor in SVG screen-space — for live crosshair and live line rendering. */
const [cursorS, setCursorS] = useState<Point | null>(null);
/** Current cursor in page-relative PDF units — for finalising off-page clicks. */
const [cursorDoc, setCursorDoc] = useState<PagePoint | null>(null);
const [hoveredId, setHoveredId] = useState<string | null>(null);
/**
* Incremented on scroll to trigger re-renders.
* We no longer store the scroll value getBoundingClientRect handles that
* automatically and is always accurate regardless of scroll position.
*/
const [, setScrollVersion] = useState(0);
const scrollElRef = useRef<HTMLElement | null>(null);
const scrollCleanupRef = useRef<(() => void) | null>(null);
const idCounter = useRef(0);
const firstPtRef = useRef<PagePoint | null>(null);
useEffect(() => { firstPtRef.current = firstPt; }, [firstPt]);
const cursorDocRef = useRef<PagePoint | null>(null);
// ── Zoom ──────────────────────────────────────────────────────────────────
const viewer = useViewer();
const { registerImmediateZoomUpdate } = viewer;
const [zoom, setZoom] = useState<number>(() => {
try { return ((viewer.getZoomState() as any)?.zoomPercent ?? 140) / 100; }
catch { return 1.4; }
});
const zoomRef = useRef(zoom);
useEffect(() => { zoomRef.current = zoom; }, [zoom]);
useEffect(() => {
return registerImmediateZoomUpdate((pct) => {
const newZoom = pct / 100;
zoomRef.current = newZoom; // immediate for event-listener closures
setZoom(newZoom); // re-render #1: zoom updated, but PDF.js DOM may not be yet
// re-render #2: after PDF.js has updated page element dimensions in the DOM,
// so getBoundingClientRect returns the correct positions for the new zoom level.
requestAnimationFrame(() => setScrollVersion(n => n + 1));
});
}, [registerImmediateZoomUpdate]);
// ── Scroll tracking ────────────────────────────────────────────────────────
// We only need re-renders on scroll; getBoundingClientRect gives us accurate
// positions without needing to know the scroll offset ourselves.
const attachScrollEl = useCallback((el: HTMLElement) => {
scrollCleanupRef.current?.();
scrollElRef.current = el;
const handler = () => setScrollVersion(n => n + 1);
el.addEventListener('scroll', handler, { passive: true });
scrollCleanupRef.current = () => el.removeEventListener('scroll', handler);
}, []);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const tryAttach = () => {
const el = findScrollEl(container);
if (el) { attachScrollEl(el); return true; }
return false;
};
if (!tryAttach()) {
const timer = setTimeout(() => tryAttach(), 600);
return () => { clearTimeout(timer); scrollCleanupRef.current?.(); };
}
return () => scrollCleanupRef.current?.();
}, [containerRef, attachScrollEl]);
// Re-find scroll element when zoom changes (PDF.js may recreate the scroll DOM).
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const el = findScrollEl(container);
if (el && el !== scrollElRef.current) attachScrollEl(el);
}, [zoom, containerRef, attachScrollEl]);
// ── Imperative handle ──────────────────────────────────────────────────────
React.useImperativeHandle(ref, () => ({
clearAll: () => { setMeasurements([]); setFirstPt(null); setCursorS(null); setCursorDoc(null); },
}));
// ── Reset when deactivated ─────────────────────────────────────────────────
useEffect(() => {
if (!isActive) { setFirstPt(null); setCursorS(null); setCursorDoc(null); }
}, [isActive]);
// ── Mouse events ───────────────────────────────────────────────────────────
useEffect(() => {
const el = containerRef.current;
if (!isActive || !el) return;
const toScreenPt = (e: MouseEvent): Point => {
const r = el.getBoundingClientRect();
return { x: e.clientX - r.left, y: e.clientY - r.top };
};
/**
* Convert a mouse event to a page-relative PagePoint.
* Returns null if the cursor is not directly over a page element.
*/
const toDocPagePt = (e: MouseEvent): PagePoint | null => {
const pageEl = (e.target as Element).closest?.('[data-page-index]') as HTMLElement | null;
if (!pageEl) return null;
const pageIndex = parseInt(pageEl.dataset.pageIndex ?? '0', 10);
const r = pageEl.getBoundingClientRect();
const z = zoomRef.current;
return { pageIndex, x: (e.clientX - r.left) / z, y: (e.clientY - r.top) / z };
};
const clearCursor = () => {
setCursorS(null);
setCursorDoc(null);
cursorDocRef.current = null;
};
const onMove = (e: MouseEvent) => {
const screenPt = toScreenPt(e);
if (isOverPage(e)) {
el.style.cursor = 'crosshair';
const docPt = toDocPagePt(e);
setCursorS(screenPt);
setCursorDoc(docPt);
cursorDocRef.current = docPt;
} else if (firstPtRef.current !== null) {
// First point placed, cursor wandered off page — clamp to nearest edge
el.style.cursor = 'crosshair';
const result = nearestPageDocPt(screenPt, el, zoomRef.current);
if (result) {
setCursorS(result.screenPt);
setCursorDoc(result.docPt);
cursorDocRef.current = result.docPt;
}
} else {
el.style.cursor = 'default';
clearCursor();
}
};
const onClick = (e: MouseEvent) => {
if (e.button !== 0) return;
if ((e.target as Element).closest?.('[data-ruler-interactive]')) return;
const overPage = isOverPage(e);
if (!overPage && firstPtRef.current === null) return;
e.preventDefault();
const dp = overPage ? toDocPagePt(e) : cursorDocRef.current;
if (!dp) return;
setFirstPt(prev => {
if (!prev) { firstPtRef.current = dp; return dp; }
firstPtRef.current = null;
const id = `ruler-${++idCounter.current}`;
setMeasurements(m => [...m, { id, start: prev, end: dp }]);
return null;
});
};
const onLeave = () => {
el.style.cursor = '';
if (firstPtRef.current === null) clearCursor();
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { setFirstPt(null); setCursorS(null); setCursorDoc(null); }
};
el.addEventListener('mousemove', onMove);
el.addEventListener('click', onClick);
el.addEventListener('mouseleave', onLeave);
document.addEventListener('keydown', onKey);
return () => {
el.removeEventListener('mousemove', onMove);
el.removeEventListener('click', onClick);
el.removeEventListener('mouseleave', onLeave);
document.removeEventListener('keydown', onKey);
el.style.cursor = '';
};
}, [containerRef, isActive]);
const deleteMeasurement = useCallback((id: string) => {
setMeasurements(prev => prev.filter(m => m.id !== id));
}, []);
if (!isActive && measurements.length === 0) return null;
// ── PagePoint → SVG screen coordinates ────────────────────────────────────
/**
* Convert a page-anchored point to SVG screen coordinates.
*
* Uses getBoundingClientRect so the browser computes the exact screen position
* accounting for scroll, zoom, page margins, centering everything. This is
* why we no longer need to track scroll offsets.
*
* Returns null if the page element isn't in the DOM (shouldn't happen with
* PDF.js placeholder divs, but guard anyway).
*/
const pagePointToScreen = (pt: PagePoint): Point | null => {
const container = containerRef.current;
if (!container) return null;
const pageEl = container.querySelector(`[data-page-index="${pt.pageIndex}"]`) as HTMLElement | null;
if (!pageEl) return null;
const pageRect = pageEl.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
return {
x: pageRect.left - containerRect.left + pt.x * zoom,
y: pageRect.top - containerRect.top + pt.y * zoom,
};
};
const firstPtS = firstPt ? pagePointToScreen(firstPt) : null;
return (
<svg
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
overflow: 'visible',
zIndex: 100,
}}
>
<defs>
<filter id="ruler-shadow" x="-20%" y="-50%" width="140%" height="200%">
<feDropShadow dx="0" dy="1" stdDeviation="2" floodColor="rgba(0,0,0,0.22)" />
</filter>
</defs>
{/* Completed measurements */}
{measurements.map(m => {
const startS = pagePointToScreen(m.start);
const endS = pagePointToScreen(m.end);
if (!startS || !endS) return null;
const mScale = pageMeasureScales ? pickScale(m.start, m.end, pageMeasureScales) : null;
return (
<MeasurementLine
key={m.id}
id={m.id}
startS={startS}
endS={endS}
distPts={dist(startS, endS) / zoom}
hovered={hoveredId === m.id}
onDelete={deleteMeasurement}
onHover={setHoveredId}
measureScale={mScale}
/>
);
})}
{/* Live line while drawing */}
{isActive && firstPtS && cursorS && (
<LiveLine
startS={firstPtS} endS={cursorS} zoom={zoom}
measureScale={pageMeasureScales && firstPt && cursorDoc
? pickScale(firstPt, cursorDoc, pageMeasureScales)
: null}
/>
)}
{/* First-point anchor dot */}
{isActive && firstPtS && (
<circle cx={firstPtS.x} cy={firstPtS.y} r={DOT_R} fill="#1e88e5" stroke="white" strokeWidth={2} />
)}
{/* Crosshair */}
{isActive && cursorS && (
<g opacity={0.75}>
<line x1={cursorS.x - 12} y1={cursorS.y} x2={cursorS.x + 12} y2={cursorS.y} stroke="#1e88e5" strokeWidth={1.5} />
<line x1={cursorS.x} y1={cursorS.y - 12} x2={cursorS.x} y2={cursorS.y + 12} stroke="#1e88e5" strokeWidth={1.5} />
<circle cx={cursorS.x} cy={cursorS.y} r={2} fill="#1e88e5" />
</g>
)}
{/* Clear all */}
{measurements.length > 0 && (
<g data-ruler-interactive="true" style={{ pointerEvents: 'all', cursor: 'pointer' }}
onClick={(e) => { e.stopPropagation(); setMeasurements([]); }}>
<rect x={8} y={8} width={88} height={26} rx={5}
fill="rgba(239,83,80,0.9)" stroke="white" strokeWidth={1} />
<text x={52} y={25} textAnchor="middle" fill="white" fontSize={12}
fontFamily="sans-serif" fontWeight={600} style={{ userSelect: 'none' }}>
Clear all
</text>
</g>
)}
</svg>
);
}
);
RulerOverlay.displayName = 'RulerOverlay';
@@ -2,20 +2,15 @@ import { useEffect, useRef } from 'react';
import { useScroll } from '@embedpdf/plugin-scroll/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF scroll plugin to the shared ViewerContext.
*/
export function ScrollAPIBridge() {
const activeDocumentId = useActiveDocumentId();
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID and document is ready
if (!activeDocumentId || !documentReady) {
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
return null;
}
return <ScrollAPIBridgeInner documentId={activeDocumentId} />;
}
@@ -40,7 +35,7 @@ function ScrollAPIBridgeInner({ documentId }: { documentId: string }) {
currentPage,
totalPages,
};
// Trigger immediate update for responsive UI
triggerImmediateScrollUpdate(newState.currentPage, newState.totalPages);
@@ -49,10 +44,6 @@ function ScrollAPIBridgeInner({ documentId }: { documentId: string }) {
api: currentScroll
});
}
return () => {
registerBridge('scroll', null);
};
}, [currentPage, totalPages, registerBridge, triggerImmediateScrollUpdate]);
return null;
@@ -2,7 +2,6 @@ import { useEffect, useState, useRef } from 'react';
import { useSearch } from '@embedpdf/plugin-search/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
interface SearchResult {
pageIndex: number;
@@ -12,35 +11,30 @@ interface SearchResult {
}>;
}
/**
* SearchAPIBridge - Updated for embedPDF v2.6.0
* Connects the PDF search plugin to the shared ViewerContext.
*/
export function SearchAPIBridge() {
const activeDocumentId = useActiveDocumentId();
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID and document is ready
if (!activeDocumentId || !documentReady) {
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
return null;
}
return <SearchAPIBridgeInner documentId={activeDocumentId} />;
}
function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
const { provides: search } = useSearch(documentId);
const { registerBridge, scrollActions } = useViewer();
// Keep search ref updated to avoid re-running effects when object reference changes
const searchRef = useRef(search);
const isSearchingRef = useRef(false);
const lastScrolledIndexRef = useRef<number | null>(null);
useEffect(() => {
searchRef.current = search;
}, [search]);
const [localState, setLocalState] = useState({
results: null as SearchResult[] | null,
activeIndex: 0
@@ -48,14 +42,14 @@ function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
// Subscribe to search result changes from EmbedPDF
const subscriptionRef = useRef<(() => void) | null>(null);
useEffect(() => {
// Cleanup previous subscription
if (subscriptionRef.current) {
subscriptionRef.current();
subscriptionRef.current = null;
}
if (!search) return;
subscriptionRef.current = search.onSearchResultStateChange?.((state: any) => {
@@ -93,11 +87,11 @@ function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
lastScrolledIndexRef.current = null;
return;
}
// Only scroll if the active index actually changed
const activeResultIndex = localActiveIndex - 1; // Convert back to 0-based
if (activeResultIndex >= 0 &&
activeResultIndex < localResults.length &&
if (activeResultIndex >= 0 &&
activeResultIndex < localResults.length &&
lastScrolledIndexRef.current !== activeResultIndex) {
const activeResult = localResults[activeResultIndex];
if (activeResult) {
@@ -120,13 +114,13 @@ function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
if (isSearchingRef.current) {
return null;
}
if (!currentSearch?.startSearch || !currentSearch?.searchAllPages) {
return null;
}
isSearchingRef.current = true;
try {
currentSearch.startSearch();
const results = await currentSearch.searchAllPages(query);
@@ -177,10 +171,6 @@ function SearchAPIBridgeInner({ documentId }: { documentId: string }) {
}
});
}
return () => {
registerBridge('search', null);
};
}, [localResults, localActiveIndex, registerBridge]);
return null;
@@ -1,22 +1,17 @@
import { useEffect, useRef } from 'react';
import { useSelectionCapability } from '@embedpdf/plugin-selection/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF selection plugin to the shared ViewerContext.
*/
export function SelectionAPIBridge() {
const { provides: selection } = useSelectionCapability();
const { registerBridge } = useViewer();
const documentReady = useDocumentReady();
const hasSelectionRef = useRef(false);
const selectedTextRef = useRef('');
useEffect(() => {
if (!selection || !documentReady) return;
if (!selection) return;
const buildApi = () => ({
copyToClipboard: () => selection.copyToClipboard(),
@@ -73,9 +68,8 @@ export function SelectionAPIBridge() {
unsubCopy?.();
document.removeEventListener('copy', handleCopy);
document.removeEventListener('keydown', handleKeyDown);
registerBridge('selection', null);
};
}, [selection, documentReady, registerBridge]);
}, [selection]);
return null;
}
@@ -5,11 +5,6 @@ import { useSignature } from '@app/contexts/SignatureContext';
import type { SignatureAPI } from '@app/components/viewer/viewerTypes';
import type { SignParameters } from '@app/hooks/tools/sign/useSignParameters';
import { useViewer } from '@app/contexts/ViewerContext';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF signature (stamp/ink) tools to the shared ViewerContext and SignatureContext.
*/
// Minimum allowed width/height (in pixels) for a signature image or text stamp.
// This prevents rendering issues and ensures signatures are always visible and usable.
@@ -137,7 +132,6 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
const { provides: annotationApi } = useAnnotationCapability();
const { signatureConfig, storeImageData, isPlacementMode, placementPreviewSize, setSignaturesApplied } = useSignature();
const { getZoomState, registerImmediateZoomUpdate } = useViewer();
const documentReady = useDocumentReady();
const [currentZoom, setCurrentZoom] = useState(() => getZoomState()?.currentZoom ?? 1);
const lastStampImageRef = useRef<string | null>(null);
@@ -217,7 +211,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
// Enable keyboard deletion of selected annotations
useEffect(() => {
// Always enable delete key when we have annotation API and are in sign mode
if (!annotationApi || (isPlacementMode === undefined) || !documentReady) return;
if (!annotationApi || (isPlacementMode === undefined)) return;
const handleKeyDown = (event: KeyboardEvent) => {
// Skip delete/backspace while a text input/textarea is focused (e.g., editing textbox)
@@ -397,7 +391,7 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
}), [annotationApi, signatureConfig, placementPreviewSize, applyStampDefaults]);
useEffect(() => {
if (!annotationApi?.onAnnotationEvent || !documentReady) {
if (!annotationApi?.onAnnotationEvent) {
return;
}
@@ -436,10 +430,10 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
return () => {
unsubscribe?.();
};
}, [annotationApi, storeImageData, setSignaturesApplied, documentReady]);
}, [annotationApi, storeImageData, setSignaturesApplied]);
useEffect(() => {
if (!isPlacementMode || !documentReady) {
if (!isPlacementMode) {
return;
}
@@ -453,7 +447,66 @@ export const SignatureAPIBridge = forwardRef<SignatureAPI>(function SignatureAPI
return () => {
cancelled = true;
};
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig, documentReady]);
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
useEffect(() => {
if (!annotationApi?.onAnnotationEvent) {
return;
}
const unsubscribe = annotationApi.onAnnotationEvent(event => {
if (event.type !== 'create' && event.type !== 'update') {
return;
}
const annotation: any = event.annotation;
const annotationId: string | undefined = annotation?.id;
if (!annotationId) {
return;
}
// Mark signatures as not applied when a new signature is placed
if (event.type === 'create') {
setSignaturesApplied(false);
}
const directData =
extractDataUrl(annotation.imageSrc) ||
extractDataUrl(annotation.imageData) ||
extractDataUrl(annotation.appearance) ||
extractDataUrl(annotation.stampData) ||
extractDataUrl(annotation.contents) ||
extractDataUrl(annotation.data) ||
extractDataUrl(annotation.customData) ||
extractDataUrl(annotation.asset);
const dataToStore = directData || lastStampImageRef.current;
if (dataToStore) {
storeImageData(annotationId, dataToStore);
}
});
return () => {
unsubscribe?.();
};
}, [annotationApi, storeImageData, setSignaturesApplied]);
useEffect(() => {
if (!isPlacementMode) {
return;
}
let cancelled = false;
configureStampDefaults().catch((error) => {
if (!cancelled) {
console.error('Error updating signature defaults:', error);
}
});
return () => {
cancelled = true;
};
}, [isPlacementMode, configureStampDefaults, placementPreviewSize, signatureConfig]);
return null; // This is a bridge component with no UI
@@ -2,21 +2,15 @@ import { useEffect, useRef } from 'react';
import { useSpread, SpreadMode } from '@embedpdf/plugin-spread/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useActiveDocumentId } from '@app/components/viewer/useActiveDocumentId';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* SpreadAPIBridge - Updated for embedPDF v2.6.0
* Connects the PDF spread mode (single/dual page) plugin to the shared ViewerContext.
*/
export function SpreadAPIBridge() {
const activeDocumentId = useActiveDocumentId();
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID and document is ready
if (!activeDocumentId || !documentReady) {
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
return null;
}
return <SpreadAPIBridgeInner documentId={activeDocumentId} />;
}
@@ -58,10 +52,6 @@ function SpreadAPIBridgeInner({ documentId }: { documentId: string }) {
});
triggerImmediateSpreadUpdate(spreadMode);
return () => {
registerBridge('spread', null);
};
}, [spreadMode, registerBridge, triggerImmediateSpreadUpdate]);
return null;
@@ -1,29 +1,23 @@
import { useEffect } from 'react';
import { useThumbnailCapability } from '@embedpdf/plugin-thumbnail/react';
import { useViewer } from '@app/contexts/ViewerContext';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* ThumbnailAPIBridge - Updated for embedPDF v2.6.0
* Provides thumbnail generation functionality.
* ThumbnailAPIBridge provides thumbnail generation functionality.
* Exposes thumbnail API to UI components without managing state.
*/
export function ThumbnailAPIBridge() {
const { provides: thumbnail } = useThumbnailCapability();
const { registerBridge } = useViewer();
const documentReady = useDocumentReady();
useEffect(() => {
if (thumbnail && documentReady) {
if (thumbnail) {
registerBridge('thumbnail', {
state: null, // No state - just provides API
api: thumbnail
});
}
return () => {
registerBridge('thumbnail', null);
};
}, [thumbnail, documentReady, registerBridge]);
}, [thumbnail]);
return null;
}
@@ -11,20 +11,15 @@ import {
useFitWidthResize,
} from '@app/utils/viewerZoom';
import { getFirstPageAspectRatioFromStub } from '@app/utils/pageMetadata';
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
/**
* Connects the PDF zoom plugin to the shared ViewerContext.
*/
export function ZoomAPIBridge() {
const activeDocumentId = useActiveDocumentId();
const documentReady = useDocumentReady();
// Don't render the inner component until we have a valid document ID and document is ready
if (!activeDocumentId || !documentReady) {
// Don't render the inner component until we have a valid document ID
if (!activeDocumentId) {
return null;
}
return <ZoomAPIBridgeInner documentId={activeDocumentId} />;
}
@@ -65,7 +60,7 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
// Extract primitive values from zoomState for dependency arrays
const zoomLevel = zoomState?.zoomLevel;
const currentZoomLevel = zoomState?.currentZoomLevel;
// Extract metadata aspect ratio as a primitive to avoid object reference issues
const metadataAspectRatio = getFirstPageAspectRatioFromStub(firstFileStub);
@@ -205,14 +200,14 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
// Subscribe to zoom changes - use ref to avoid re-subscribing when zoom reference changes
const zoomSubscriptionRef = useRef<(() => void) | null>(null);
useEffect(() => {
// Cleanup previous subscription if any
if (zoomSubscriptionRef.current) {
zoomSubscriptionRef.current();
zoomSubscriptionRef.current = null;
}
if (!zoom) {
return;
}
@@ -260,4 +255,4 @@ function ZoomAPIBridgeInner({ documentId }: { documentId: string }) {
}, [zoomStateCurrentZoomLevel, registerBridge, triggerImmediateZoomUpdate]);
return null;
}
}
@@ -1,67 +0,0 @@
import { useState, useEffect } from 'react';
import { useDocumentManagerCapability } from '@embedpdf/plugin-document-manager/react';
/**
* useDocumentReady - Custom hook to track whether a PDF document is fully loaded
* and ready for interaction.
*
* Subscribes to both onDocumentOpened (sets true) and onDocumentClosed (resets
* to false) so the flag correctly tracks the document lifecycle across
* open close reopen transitions.
*
* The initial check is synchronous (getActiveDocument is sync) no debounce
* needed.
*/
export function useDocumentReady() {
const { provides: documentManagerCapability } = useDocumentManagerCapability();
const [documentReady, setDocumentReady] = useState(false);
useEffect(() => {
if (!documentManagerCapability) {
setDocumentReady(false);
return;
}
let mounted = true;
const unsubOpen = documentManagerCapability.onDocumentOpened?.((event: any) => {
if (mounted && (event?.documentId || event?.id)) {
setDocumentReady(true);
}
});
const unsubClose = documentManagerCapability.onDocumentClosed?.(() => {
if (!mounted) return;
try {
const remaining = documentManagerCapability.getActiveDocument?.();
if (!remaining?.id && mounted) {
setDocumentReady(false);
}
} catch {
if (mounted) setDocumentReady(false);
}
});
try {
const activeDoc = documentManagerCapability.getActiveDocument?.();
if (mounted) {
setDocumentReady(!!activeDoc?.id);
}
} catch {
if (mounted) setDocumentReady(false);
}
return () => {
mounted = false;
if (typeof unsubOpen === 'function') {
unsubOpen();
}
if (typeof unsubClose === 'function') {
unsubClose();
}
};
}, [documentManagerCapability]);
return documentReady;
}
@@ -14,12 +14,8 @@ import { useNavigationState, useNavigationGuard } from '@app/contexts/Navigation
import { BASE_PATH, withBasePath } from '@app/constants/app';
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
import TextFieldsIcon from '@mui/icons-material/TextFields';
import StraightenIcon from '@mui/icons-material/Straighten';
export function useViewerRightRailButtons(
isRulerActive?: boolean,
setIsRulerActive?: (v: boolean) => void,
) {
export function useViewerRightRailButtons() {
const { t, i18n } = useTranslation();
const viewer = useViewer();
const { isThumbnailSidebarVisible, isBookmarkSidebarVisible, isAttachmentSidebarVisible, isSearchInterfaceVisible, registerImmediatePanUpdate } = viewer;
@@ -86,8 +82,6 @@ export function useViewerRightRailButtons(
const isFormFillActive = (selectedTool as string) === 'formFill';
const rulerLabel = t('rightRail.ruler', 'Ruler / Measure');
const viewerButtons = useMemo<RightRailButtonWithAction[]>(() => {
const buttons: RightRailButtonWithAction[] = [
{
@@ -143,24 +137,6 @@ export function useViewerRightRailButtons(
setIsPanning(prev => !prev);
},
},
{
id: 'viewer-ruler',
icon: <StraightenIcon sx={{ fontSize: '1.5rem' }} />,
tooltip: rulerLabel,
ariaLabel: rulerLabel,
section: 'top' as const,
order: 25,
active: Boolean(isRulerActive),
onClick: () => {
const next = !isRulerActive;
setIsRulerActive?.(next);
// Disable pan when activating ruler — they conflict
if (next && viewer.getPanState()?.isPanning) {
viewer.panActions.togglePan();
setIsPanning(false);
}
},
},
{
id: 'viewer-rotate-left',
icon: <LocalIcon icon="rotate-left" width="1.5rem" height="1.5rem" />,
@@ -341,9 +317,6 @@ export function useViewerRightRailButtons(
redactionActiveType,
formFillLabel,
isFormFillActive,
rulerLabel,
isRulerActive,
setIsRulerActive,
]);
useRightRailButtons(viewerButtons);
@@ -60,8 +60,6 @@ export interface AppConfig {
isNewUser?: boolean;
defaultHideUnavailableTools?: boolean;
defaultHideUnavailableConversions?: boolean;
pluginsPath?: string;
basePath?: string;
}
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
@@ -1,163 +0,0 @@
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 () => {},
};
};
+2 -2
View File
@@ -151,7 +151,7 @@ interface ViewerContextType {
// Bridge registration - internal use by bridges
registerBridge: <K extends BridgeKey>(
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]> | null
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
) => void;
// Save changes function - registered by EmbedPdfViewer
@@ -242,7 +242,7 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
const registerBridge = useCallback(
<K extends BridgeKey>(
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]> | null
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
) => {
setBridgeRef(bridgeRefs.current, type, ref);
},
@@ -227,7 +227,7 @@ export const createBridgeRegistry = (): ViewerBridgeRegistry => ({
export function registerBridge<K extends BridgeKey>(
registry: ViewerBridgeRegistry,
type: K,
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]> | null
ref: BridgeRef<BridgeStateMap[K], BridgeApiMap[K]>
): void {
registry[type] = ref as ViewerBridgeRegistry[K];
}
@@ -1,7 +1,7 @@
import { useTranslation } from 'react-i18next';
import { ToolType, useToolOperation, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
import { AdjustContrastParameters, defaultParameters } from '@app/hooks/tools/adjustContrast/useAdjustContrastParameters';
import { PDFDocument as PDFLibDocument } from '@cantoo/pdf-lib';
import { PDFDocument as PDFLibDocument } from 'pdf-lib';
import { applyAdjustmentsToCanvas } from '@app/components/tools/adjustContrast/utils';
import { pdfWorkerManager } from '@app/services/pdfWorkerManager';
import { createFileFromApiResponse } from '@app/utils/fileResponseUtils';
@@ -2,7 +2,7 @@ import { useTranslation } from 'react-i18next';
import { useToolOperation, ToolType, CustomProcessorResult } from '@app/hooks/tools/shared/useToolOperation';
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
import { RemoveAnnotationsParameters, defaultParameters } from '@app/hooks/tools/removeAnnotations/useRemoveAnnotationsParameters';
import { PDFDocument, PDFName, PDFRef, PDFDict } from '@cantoo/pdf-lib';
import { PDFDocument, PDFName, PDFRef, PDFDict } from 'pdf-lib';
// Client-side PDF processing using PDF-lib
const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise<CustomProcessorResult> => {
const processedFiles: File[] = [];
@@ -1,4 +1,4 @@
import { PDFFont, PDFPage, rgb } from '@cantoo/pdf-lib';
import { PDFFont, PDFPage, rgb } from 'pdf-lib';
import { wrapText } from '@app/hooks/tools/validateSignature/utils/pdfText';
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
@@ -1,4 +1,4 @@
import { PDFFont, PDFPage } from '@cantoo/pdf-lib';
import { PDFFont, PDFPage } from 'pdf-lib';
import { wrapText } from '@app/hooks/tools/validateSignature/utils/pdfText';
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
@@ -1,5 +1,5 @@
import type { TFunction } from 'i18next';
import { PDFFont, PDFPage } from '@cantoo/pdf-lib';
import { PDFFont, PDFPage } from 'pdf-lib';
import { SignatureValidationSignature } from '@app/types/validateSignature';
import { drawFieldBox } from '@app/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection';
import { drawStatusBadge } from '@app/hooks/tools/validateSignature/outputtedPDFSections/StatusBadgeSection';
@@ -1,4 +1,4 @@
import { PDFFont, PDFPage, rgb } from '@cantoo/pdf-lib';
import { PDFFont, PDFPage, rgb } from 'pdf-lib';
interface StatusBadgeOptions {
page: PDFPage;
@@ -1,5 +1,5 @@
import type { TFunction } from 'i18next';
import { PDFFont, PDFImage, PDFPage } from '@cantoo/pdf-lib';
import { PDFFont, PDFImage, PDFPage } from 'pdf-lib';
import { SignatureValidationReportEntry } from '@app/types/validateSignature';
import { drawFieldBox } from '@app/hooks/tools/validateSignature/outputtedPDFSections/FieldBoxSection';
import { drawThumbnailImage, drawThumbnailPlaceholder } from '@app/hooks/tools/validateSignature/outputtedPDFSections/ThumbnailSection';
@@ -1,4 +1,4 @@
import { PDFFont, PDFPage, PDFImage } from '@cantoo/pdf-lib';
import { PDFFont, PDFPage, PDFImage } from 'pdf-lib';
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
export const drawThumbnailPlaceholder = (
@@ -1,4 +1,4 @@
import { PDFDocument, PDFPage, StandardFonts } from '@cantoo/pdf-lib';
import { PDFDocument, PDFPage, StandardFonts } from 'pdf-lib';
import type { TFunction } from 'i18next';
import { SignatureValidationReportEntry } from '@app/types/validateSignature';
import { REPORT_PDF_FILENAME } from '@app/hooks/tools/validateSignature/utils/signatureUtils';
@@ -1,4 +1,4 @@
import { PDFDocument, PDFFont, PDFImage } from '@cantoo/pdf-lib';
import { PDFDocument, PDFFont, PDFImage } from 'pdf-lib';
import type { TFunction } from 'i18next';
import { colorPalette } from '@app/hooks/tools/validateSignature/utils/pdfPalette';
@@ -1,4 +1,4 @@
import { rgb } from '@cantoo/pdf-lib';
import { rgb } from 'pdf-lib';
type RgbTuple = [number, number, number];
@@ -1,4 +1,4 @@
import { PDFFont } from '@cantoo/pdf-lib';
import { PDFFont } from 'pdf-lib';
export const wrapText = (text: string, font: PDFFont, fontSize: number, maxWidth: number): string[] => {
const lines: string[] = [];
+47 -22
View File
@@ -2,8 +2,8 @@ import { useState, useEffect } from 'react';
import apiClient from '@app/services/apiClient';
import type { EndpointAvailabilityDetails } from '@app/types/endpointAvailability';
// Track whether we've done the global fetch to prevent duplicate requests
let globalFetchDone = false;
// Track globally fetched endpoint sets to prevent duplicate fetches across components
const globalFetchedSets = new Set<string>();
const globalEndpointCache: Record<string, EndpointAvailabilityDetails> = {};
/**
@@ -72,17 +72,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const [error, setError] = useState<string | null>(null);
const fetchAllEndpointStatuses = async (force = false) => {
// Skip if already fetched globally and not forced
if (!force && globalFetchDone) {
console.debug('[useEndpointConfig] Using global cache');
const endpointsKey = [...endpoints].sort().join(',');
// Skip if we already fetched these exact endpoints globally
if (!force && globalFetchedSets.has(endpointsKey)) {
console.debug('[useEndpointConfig] Already fetched these endpoints globally, using cache');
const cached = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
if (cachedDetails) {
acc.status[endpoint] = cachedDetails.enabled;
acc.details[endpoint] = cachedDetails;
} else {
acc.status[endpoint] = true;
}
return acc;
},
@@ -93,7 +93,6 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
setLoading(false);
return;
}
if (!endpoints || endpoints.length === 0) {
setEndpointStatus({});
setEndpointDetails({});
@@ -104,21 +103,45 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
try {
setLoading(true);
setError(null);
console.debug('[useEndpointConfig] Fetching all endpoint statuses from server');
console.debug('[useEndpointConfig] Fetching endpoint statuses', { count: endpoints.length, force });
// Fetch all endpoints at once - no query params needed
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability`);
// Check which endpoints we haven't fetched yet
const newEndpoints = endpoints.filter(ep => !(ep in globalEndpointCache));
if (newEndpoints.length === 0) {
console.debug('[useEndpointConfig] All endpoints already in global cache');
const cached = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
if (cachedDetails) {
acc.status[endpoint] = cachedDetails.enabled;
acc.details[endpoint] = cachedDetails;
}
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
);
setEndpointStatus(cached.status);
setEndpointDetails(prev => ({ ...prev, ...cached.details }));
globalFetchedSets.add(endpointsKey);
setLoading(false);
return;
}
// Populate global cache with all results
Object.entries(response.data).forEach(([endpoint, details]) => {
// Use batch API for efficiency - only fetch new endpoints
const endpointsParam = newEndpoints.join(',');
const response = await apiClient.get<Record<string, EndpointAvailabilityDetails>>(`/api/v1/config/endpoints-availability?endpoints=${encodeURIComponent(endpointsParam)}`);
const statusMap = response.data;
// Update global cache with new results
Object.entries(statusMap).forEach(([endpoint, details]) => {
globalEndpointCache[endpoint] = {
enabled: details?.enabled ?? true,
reason: details?.reason ?? null,
};
});
globalFetchDone = true;
// Return status for the requested endpoints
// Get all requested endpoints from cache (including previously cached ones)
const fullStatus = endpoints.reduce(
(acc, endpoint) => {
const cachedDetails = globalEndpointCache[endpoint];
@@ -135,17 +158,17 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
setEndpointStatus(fullStatus.status);
setEndpointDetails(prev => ({ ...prev, ...fullStatus.details }));
globalFetchedSets.add(endpointsKey);
} catch (err: any) {
// On 401 (auth error), use optimistic fallback instead of disabling
if (err.response?.status === 401) {
console.warn('[useEndpointConfig] 401 error - using optimistic fallback');
endpoints.forEach(endpoint => {
globalEndpointCache[endpoint] = { enabled: true, reason: null };
});
const optimisticStatus = endpoints.reduce(
(acc, endpoint) => {
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
acc.status[endpoint] = true;
acc.details[endpoint] = { enabled: true, reason: null };
acc.details[endpoint] = optimisticDetails;
globalEndpointCache[endpoint] = optimisticDetails;
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
@@ -158,13 +181,14 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
const errorMessage = err instanceof Error ? err.message : 'Unknown error occurred';
setError(errorMessage);
console.error('[EndpointConfig] Failed to check endpoints:', err);
console.error('[EndpointConfig] Failed to check multiple endpoints:', err);
// Fallback: assume all endpoints are enabled on error (optimistic)
const optimisticStatus = endpoints.reduce(
(acc, endpoint) => {
const optimisticDetails: EndpointAvailabilityDetails = { enabled: true, reason: null };
acc.status[endpoint] = true;
acc.details[endpoint] = { enabled: true, reason: null };
acc.details[endpoint] = optimisticDetails;
return acc;
},
{ status: {} as Record<string, boolean>, details: {} as Record<string, EndpointAvailabilityDetails> }
@@ -184,7 +208,8 @@ export function useMultipleEndpointsEnabled(endpoints: string[]): {
useEffect(() => {
const handleJwtAvailable = () => {
console.debug('[useEndpointConfig] JWT available event - clearing cache for refetch with auth');
globalFetchDone = false;
// Clear the global cache to allow refetch with JWT
globalFetchedSets.clear();
Object.keys(globalEndpointCache).forEach(key => delete globalEndpointCache[key]);
fetchAllEndpointStatuses(true);
};
+265 -43
View File
@@ -1,14 +1,32 @@
import { useState, useEffect, useRef } from 'react';
import {
PDFDocument,
} from '@cantoo/pdf-lib';
import {
PdfLibLink,
extractLinksFromPage,
} from '@app/utils/pdfLinkUtils';
PDFDict,
PDFName,
PDFArray,
PDFString,
PDFHexString,
PDFNumber,
PDFRef,
PDFPage,
PDFContext,
} from 'pdf-lib';
export type { PdfLibLink };
export type LinkType = 'internal' | 'external' | 'unknown';
export interface PdfLibLink {
id: string;
/** Index of this annotation in the page's /Annots array (used for deletion matching). */
annotIndex: number;
/** Rectangle in PDF-page coordinate space (top-left origin, unscaled). */
rect: { x: number; y: number; width: number; height: number };
type: LinkType;
/** 0-based target page index (internal links). */
targetPage?: number;
/** URI for external links. */
uri?: string;
}
export interface PdfLibLinksResult {
links: PdfLibLink[];
@@ -25,9 +43,6 @@ interface CachedDoc {
refCount: number;
/** Per-page extracted links (lazy, filled on first request). */
pageLinks: Map<number, { links: PdfLibLink[]; width: number; height: number }>;
/** Set to true when the PDF catalog/pages tree is invalid, so we
* skip link extraction on all subsequent calls without retrying. */
invalidCatalog?: boolean;
}
const docCache = new Map<string, Promise<CachedDoc>>();
@@ -39,17 +54,11 @@ async function acquireDocument(url: string): Promise<CachedDoc> {
const buffer = await response.arrayBuffer();
const doc = await PDFDocument.load(new Uint8Array(buffer), {
ignoreEncryption: true,
updateMetadata: false,
throwOnInvalidObject: false,
});
return { doc, refCount: 0, pageLinks: new Map() };
})();
docCache.set(url, promise);
promise.catch(() => {
docCache.delete(url);
});
}
const cached = await docCache.get(url)!;
cached.refCount++;
@@ -67,7 +76,241 @@ function releaseDocument(url: string): void {
});
}
function num(ctx: PDFContext, value: unknown): number {
if (value instanceof PDFRef) value = ctx.lookup(value);
if (value instanceof PDFNumber) return value.asNumber();
if (typeof value === 'number') return value;
return 0;
}
function str(ctx: PDFContext, value: unknown): string | undefined {
if (value instanceof PDFRef) value = ctx.lookup(value);
if (value instanceof PDFString) return value.decodeText();
if (value instanceof PDFHexString) return value.decodeText();
if (typeof value === 'string') return value;
return undefined;
}
function resolvePageIndex(doc: PDFDocument, pageRef: PDFRef): number | undefined {
const pages = doc.getPages();
for (let i = 0; i < pages.length; i++) {
const ref = pages[i].ref;
if (
ref === pageRef ||
(ref.objectNumber === pageRef.objectNumber &&
ref.generationNumber === pageRef.generationNumber)
) {
return i;
}
}
return undefined;
}
function resolveDestArray(
doc: PDFDocument,
ctx: PDFContext,
destArr: PDFArray,
): number | undefined {
if (destArr.size() < 1) return undefined;
const first = destArr.get(0);
if (first instanceof PDFRef) {
return resolvePageIndex(doc, first);
}
const n = num(ctx, first);
if (typeof n === 'number' && n >= 0) return n;
return undefined;
}
function resolveNamedDest(
doc: PDFDocument,
ctx: PDFContext,
name: string,
): number | undefined {
try {
const catalog = doc.catalog;
const namesRaw = catalog.get(PDFName.of('Names'));
const namesDict = namesRaw instanceof PDFRef ? ctx.lookup(namesRaw) : namesRaw;
if (namesDict instanceof PDFDict) {
const destsRaw = namesDict.get(PDFName.of('Dests'));
const destsTree = destsRaw instanceof PDFRef ? ctx.lookup(destsRaw) : destsRaw;
if (destsTree instanceof PDFDict) {
const result = searchNameTree(doc, ctx, destsTree, name);
if (result !== undefined) return result;
}
}
const destsRaw = catalog.get(PDFName.of('Dests'));
const destsDict = destsRaw instanceof PDFRef ? ctx.lookup(destsRaw) : destsRaw;
if (destsDict instanceof PDFDict) {
const dest = destsDict.get(PDFName.of(name));
const destResolved = dest instanceof PDFRef ? ctx.lookup(dest) : dest;
if (destResolved instanceof PDFArray) {
return resolveDestArray(doc, ctx, destResolved);
}
}
} catch {
// Swallow named dest resolution is best-effort
}
return undefined;
}
function searchNameTree(
doc: PDFDocument,
ctx: PDFContext,
node: PDFDict,
name: string,
): number | undefined {
const namesArr = node.get(PDFName.of('Names'));
const resolved = namesArr instanceof PDFRef ? ctx.lookup(namesArr) : namesArr;
if (resolved instanceof PDFArray) {
for (let i = 0; i < resolved.size(); i += 2) {
const key = str(ctx, resolved.get(i));
if (key === name) {
const val = resolved.get(i + 1);
const valResolved = val instanceof PDFRef ? ctx.lookup(val) : val;
if (valResolved instanceof PDFArray) {
return resolveDestArray(doc, ctx, valResolved);
}
if (valResolved instanceof PDFDict) {
const d = valResolved.get(PDFName.of('D'));
const dResolved = d instanceof PDFRef ? ctx.lookup(d) : d;
if (dResolved instanceof PDFArray) {
return resolveDestArray(doc, ctx, dResolved);
}
}
}
}
}
const kidsArr = node.get(PDFName.of('Kids'));
const kidsResolved = kidsArr instanceof PDFRef ? ctx.lookup(kidsArr) : kidsArr;
if (kidsResolved instanceof PDFArray) {
for (let i = 0; i < kidsResolved.size(); i++) {
const kidRef = kidsResolved.get(i);
const kid = kidRef instanceof PDFRef ? ctx.lookup(kidRef) : kidRef;
if (kid instanceof PDFDict) {
const limits = kid.get(PDFName.of('Limits'));
const limitsResolved = limits instanceof PDFRef ? ctx.lookup(limits) : limits;
if (limitsResolved instanceof PDFArray && limitsResolved.size() >= 2) {
const lo = str(ctx, limitsResolved.get(0)) ?? '';
const hi = str(ctx, limitsResolved.get(1)) ?? '';
if (name < lo || name > hi) continue;
}
const result = searchNameTree(doc, ctx, kid, name);
if (result !== undefined) return result;
}
}
}
return undefined;
}
function extractLinksFromPage(
doc: PDFDocument,
page: PDFPage,
pageIndex: number,
): PdfLibLink[] {
const links: PdfLibLink[] = [];
const ctx = doc.context;
const { height: pageHeight } = page.getSize();
const annotsRaw = page.node.get(PDFName.of('Annots'));
if (!annotsRaw) return links;
const annots = annotsRaw instanceof PDFRef ? ctx.lookup(annotsRaw) : annotsRaw;
if (!(annots instanceof PDFArray)) return links;
for (let i = 0; i < annots.size(); i++) {
try {
const annotRaw = annots.get(i);
const annot = annotRaw instanceof PDFRef ? ctx.lookup(annotRaw) : annotRaw;
if (!(annot instanceof PDFDict)) continue;
const subtype = annot.get(PDFName.of('Subtype'));
if (subtype?.toString() !== '/Link') continue;
const rectRaw = annot.get(PDFName.of('Rect'));
const rect = rectRaw instanceof PDFRef ? ctx.lookup(rectRaw) : rectRaw;
if (!(rect instanceof PDFArray) || rect.size() < 4) continue;
const x1 = num(ctx, rect.get(0));
const y1 = num(ctx, rect.get(1));
const x2 = num(ctx, rect.get(2));
const y2 = num(ctx, rect.get(3));
const left = Math.min(x1, x2);
const bottom = Math.min(y1, y2);
const width = Math.abs(x2 - x1);
const height = Math.abs(y2 - y1);
const top = pageHeight - bottom - height;
let linkType: LinkType = 'unknown';
let targetPage: number | undefined;
let uri: string | undefined;
const actionRaw = annot.get(PDFName.of('A'));
const action = actionRaw instanceof PDFRef ? ctx.lookup(actionRaw) : actionRaw;
if (action instanceof PDFDict) {
const actionType = action.get(PDFName.of('S'))?.toString();
if (actionType === '/URI') {
linkType = 'external';
uri = str(ctx, action.get(PDFName.of('URI')));
} else if (actionType === '/GoTo') {
linkType = 'internal';
const dest = action.get(PDFName.of('D'));
const destResolved = dest instanceof PDFRef ? ctx.lookup(dest) : dest;
if (destResolved instanceof PDFArray) {
targetPage = resolveDestArray(doc, ctx, destResolved);
} else {
const destName = str(ctx, destResolved);
if (destName) {
targetPage = resolveNamedDest(doc, ctx, destName);
}
}
} else if (actionType === '/GoToR') {
linkType = 'external';
uri = str(ctx, action.get(PDFName.of('F')));
} else if (actionType === '/Launch') {
linkType = 'external';
uri = str(ctx, action.get(PDFName.of('F')));
}
}
if (linkType === 'unknown') {
const destRaw = annot.get(PDFName.of('Dest'));
const dest = destRaw instanceof PDFRef ? ctx.lookup(destRaw) : destRaw;
if (dest instanceof PDFArray) {
linkType = 'internal';
targetPage = resolveDestArray(doc, ctx, dest);
} else {
const destName = str(ctx, dest);
if (destName) {
linkType = 'internal';
targetPage = resolveNamedDest(doc, ctx, destName);
}
}
}
links.push({
id: `pdflib-link-${pageIndex}-${i}`,
annotIndex: i,
rect: { x: left, y: top, width, height },
type: linkType,
targetPage,
uri,
});
} catch (e) {
console.warn('[usePdfLibLinks] Failed to parse annotation:', e);
}
}
return links;
}
export function usePdfLibLinks(
pdfUrl: string | null,
@@ -107,41 +350,20 @@ export function usePdfLibLinks(
return;
}
if (cached.invalidCatalog) {
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
releaseDocument(url);
return;
}
let pageData = cached.pageLinks.get(pageIndex);
if (!pageData) {
let pageCount: number;
try {
pageCount = cached.doc.getPageCount();
} catch {
cached.invalidCatalog = true;
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
releaseDocument(url);
return;
}
const pageCount = cached.doc.getPageCount();
if (pageIndex < 0 || pageIndex >= pageCount) {
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
releaseDocument(url);
return;
}
try {
const page = cached.doc.getPage(pageIndex);
const { width, height } = page.getSize();
const links = extractLinksFromPage(cached.doc, page, pageIndex);
pageData = { links, width, height };
cached.pageLinks.set(pageIndex, pageData);
} catch (pageError) {
console.warn(`[usePdfLibLinks] Failed to read page ${pageIndex}:`, pageError);
pageData = { links: [], width: 0, height: 0 };
cached.pageLinks.set(pageIndex, pageData);
}
const page = cached.doc.getPage(pageIndex);
const { width, height } = page.getSize();
const links = extractLinksFromPage(cached.doc, page, pageIndex);
pageData = { links, width, height };
cached.pageLinks.set(pageIndex, pageData);
}
if (!cancelled && mountedRef.current) {
@@ -155,7 +377,7 @@ export function usePdfLibLinks(
releaseDocument(url);
} catch (error) {
console.warn('[usePdfLibLinks] Failed to extract links:', error);
console.error('[usePdfLibLinks] Failed to extract links:', error);
if (!cancelled && mountedRef.current) {
setResult({ links: [], pdfPageWidth: 0, pdfPageHeight: 0, loading: false });
}
+11 -27
View File
@@ -22,7 +22,6 @@ import FileManager from "@app/components/FileManager";
import LocalIcon from "@app/components/shared/LocalIcon";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import AppConfigModal from "@app/components/shared/AppConfigModal";
import { getStartupNavigationAction } from "@app/utils/homePageNavigation";
import "@app/pages/HomePage.css";
@@ -61,37 +60,22 @@ export default function HomePage() {
const { setActiveFileIndex } = useViewer();
const prevFileCountRef = useRef(activeFiles.length);
// Startup/open transition behavior:
// - opening exactly 1 file from empty -> viewer (unless already in fileEditor)
// - opening 2+ files from empty -> fileEditor
// Auto-switch to viewer when going from 0 to 1 file
// Skip this if PDF Text Editor is active - it handles its own empty state
useEffect(() => {
const prevCount = prevFileCountRef.current;
const currentCount = activeFiles.length;
console.log('[HomePage] Navigation effect triggered:', {
prevCount,
currentCount,
currentWorkbench: navigationState.workbench,
selectedToolKey,
});
const action = getStartupNavigationAction(
prevCount,
currentCount,
selectedToolKey,
navigationState.workbench
);
console.log('[HomePage] Navigation action returned:', action);
if (action) {
console.log('[HomePage] Applying navigation:', action);
actions.setWorkbench(action.workbench);
if (typeof action.activeFileIndex === 'number') {
setActiveFileIndex(action.activeFileIndex);
if (
navigationState.workbench !== 'fileEditor' &&
prevCount === 0 &&
currentCount === 1
) {
// PDF Text Editor handles its own empty state with a dropzone
if (selectedToolKey !== 'pdfTextEditor') {
actions.setWorkbench('viewer');
setActiveFileIndex(0);
}
} else {
console.log('[HomePage] No navigation - staying in current workbench');
}
prevFileCountRef.current = currentCount;
-351
View File
@@ -1,351 +0,0 @@
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>
);
}
@@ -1,4 +1,4 @@
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from '@cantoo/pdf-lib';
import { PDFDocument as PDFLibDocument, degrees, PageSizes } from 'pdf-lib';
import { downloadFile } from '@app/services/downloadService';
import { PDFDocument, PDFPage } from '@app/types/pageEditor';

Some files were not shown because too many files have changed in this diff Show More