mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db8e46c451 | ||
|
|
d57c3ddeb7 | ||
|
|
031477b7b5 | ||
|
|
8725ba66bb | ||
|
|
115a24b16d | ||
|
|
c775fed17d | ||
|
|
ae9d29abf0 | ||
|
|
ddf93d2b1a | ||
|
|
e97f93924e | ||
|
|
8d5b3eb36b | ||
|
|
9f26dc4112 | ||
|
|
757a666f5e | ||
|
|
558c75a2b1 | ||
|
|
da2eb54fe8 | ||
|
|
a23c252af5 | ||
|
|
772dd4632e | ||
|
|
d5cf77cf50 | ||
|
|
f25b308e46 | ||
|
|
d3e13967e9 | ||
|
|
0e94ea156f | ||
|
|
46049a0a4a | ||
|
|
3d3c5f79a5 | ||
|
|
330a987faf | ||
|
|
5806dfecf6 | ||
|
|
b653e09c16 | ||
|
|
61f3000cea | ||
|
|
e310493966 | ||
|
|
0a1d2effdc | ||
|
|
b8ce4e47c1 | ||
|
|
946196de43 | ||
|
|
27bd34c29b | ||
|
|
5a1ed50e2b | ||
|
|
e01734fb7d | ||
|
|
7c3c7937b3 | ||
|
|
b1d44d5661 | ||
|
|
71c845bcd8 | ||
|
|
c62277a8e5 | ||
|
|
f3a4dbc903 |
@@ -30,6 +30,91 @@ Set `DOCKER_ENABLE_SECURITY=true` environment variable to enable security featur
|
||||
- **Web Server**: `npm run build` then serve dist/ folder
|
||||
- **Development**: `npm run tauri-dev` for desktop dev mode
|
||||
|
||||
#### Import Paths - CRITICAL
|
||||
**ALWAYS use `@app/*` for imports.** Do not use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer implementation.
|
||||
|
||||
```typescript
|
||||
// ✅ CORRECT - Use @app/* for all imports
|
||||
import { AppLayout } from "@app/components/AppLayout";
|
||||
import { useFileContext } from "@app/contexts/FileContext";
|
||||
import { FileContext } from "@app/contexts/FileContext";
|
||||
|
||||
// ❌ WRONG - Do not use @core/* or @proprietary/* in normal code
|
||||
import { AppLayout } from "@core/components/AppLayout";
|
||||
import { useFileContext } from "@proprietary/contexts/FileContext";
|
||||
```
|
||||
|
||||
**Only use explicit aliases when:**
|
||||
- Building layer-specific override that wraps a lower layer's component
|
||||
- Example: `import { AppProviders as CoreAppProviders } from "@core/components/AppProviders"` when creating proprietary/AppProviders.tsx that extends the core version
|
||||
|
||||
The `@app/*` alias automatically resolves to the correct layer based on build target (core/proprietary/desktop) and handles the fallback cascade.
|
||||
|
||||
#### Component Override Pattern (Stub/Shadow)
|
||||
Use this pattern for desktop-specific or proprietary-specific features WITHOUT runtime checks or conditionals.
|
||||
|
||||
**How it works:**
|
||||
1. Core defines stub component (returns null or no-op)
|
||||
2. Desktop/proprietary overrides with same path/name
|
||||
3. Core imports via `@app/*` - higher layer "shadows" core in those builds
|
||||
4. No `@ts-ignore`, no `isTauri()` checks, no runtime conditionals!
|
||||
|
||||
**Example - Desktop-specific footer:**
|
||||
|
||||
```typescript
|
||||
// core/components/rightRail/RightRailFooterExtensions.tsx (stub)
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions(_props: RightRailFooterExtensionsProps) {
|
||||
return null; // Stub - does nothing in web builds
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// desktop/components/rightRail/RightRailFooterExtensions.tsx (real implementation)
|
||||
import { Box } from '@mantine/core';
|
||||
import { BackendHealthIndicator } from '@app/components/BackendHealthIndicator';
|
||||
|
||||
interface RightRailFooterExtensionsProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function RightRailFooterExtensions({ className }: RightRailFooterExtensionsProps) {
|
||||
return (
|
||||
<Box className={className}>
|
||||
<BackendHealthIndicator />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```typescript
|
||||
// core/components/shared/RightRail.tsx (usage - works in ALL builds)
|
||||
import { RightRailFooterExtensions } from '@app/components/rightRail/RightRailFooterExtensions';
|
||||
|
||||
export function RightRail() {
|
||||
return (
|
||||
<div>
|
||||
{/* In web builds: renders nothing (stub returns null) */}
|
||||
{/* In desktop builds: renders BackendHealthIndicator */}
|
||||
<RightRailFooterExtensions className="right-rail-footer" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Build resolution:**
|
||||
- **Core build**: `@app/*` → `core/*` → Gets stub (returns null)
|
||||
- **Desktop build**: `@app/*` → `desktop/*` → Gets real implementation (shadows core)
|
||||
|
||||
**Benefits:**
|
||||
- No runtime checks or feature flags
|
||||
- Type-safe across all builds
|
||||
- Clean, readable code
|
||||
- Build-time optimization (dead code elimination)
|
||||
|
||||
#### Multi-Tool Workflow Architecture
|
||||
Frontend designed for **stateful document processing**:
|
||||
- Users upload PDFs once, then chain tools (split → merge → compress → view)
|
||||
@@ -37,7 +122,7 @@ Frontend designed for **stateful document processing**:
|
||||
- No file reloading between tools - performance critical for large PDFs (up to 100GB+)
|
||||
|
||||
#### FileContext - Central State Management
|
||||
**Location**: `src/contexts/FileContext.tsx`
|
||||
**Location**: `frontend/src/core/contexts/FileContext.tsx`
|
||||
- **Active files**: Currently loaded PDFs and their variants
|
||||
- **Tool navigation**: Current mode (viewer/pageEditor/fileEditor/toolName)
|
||||
- **Memory management**: PDF document cleanup, blob URL lifecycle, Web Worker management
|
||||
@@ -62,7 +147,7 @@ Without cleanup: browser crashes with memory leaks.
|
||||
|
||||
**Architecture**: Modular hook-based system with clear separation of concerns:
|
||||
|
||||
- **useToolOperation** (`frontend/src/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- **useToolOperation** (`frontend/src/core/hooks/tools/shared/useToolOperation.ts`): Main orchestrator hook
|
||||
- Coordinates all tool operations with consistent interface
|
||||
- Integrates with FileContext for operation tracking
|
||||
- Handles validation, error handling, and UI state management
|
||||
@@ -147,8 +232,34 @@ return useToolOperation({
|
||||
- **Pipeline System**: Automated PDF processing workflows via `PipelineController`
|
||||
- **Security Layer**: Authentication, authorization, and user management (when enabled)
|
||||
|
||||
### Frontend Directory Structure
|
||||
The frontend is organized with a clear separation of concerns:
|
||||
|
||||
- **`frontend/src/core/`**: Main application code (shared, production-ready components)
|
||||
- **`core/components/`**: React components organized by feature
|
||||
- `core/components/tools/`: Individual PDF tool implementations
|
||||
- `core/components/viewer/`: PDF viewer components
|
||||
- `core/components/pageEditor/`: Page manipulation UI
|
||||
- `core/components/tooltips/`: Help tooltips for tools
|
||||
- `core/components/shared/`: Reusable UI components
|
||||
- **`core/contexts/`**: React Context providers
|
||||
- `FileContext.tsx`: Central file state management
|
||||
- `file/`: File reducer and selectors
|
||||
- `toolWorkflow/`: Tool workflow state
|
||||
- **`core/hooks/`**: Custom React hooks
|
||||
- `hooks/tools/`: Tool-specific operation hooks (one directory per tool)
|
||||
- `hooks/tools/shared/`: Shared hook utilities (useToolOperation, etc.)
|
||||
- **`core/constants/`**: Application constants and configuration
|
||||
- **`core/data/`**: Static data (tool taxonomy, etc.)
|
||||
- **`core/services/`**: Business logic services (PDF processing, storage, etc.)
|
||||
|
||||
- **`frontend/src/desktop/`**: Desktop-specific (Tauri) code
|
||||
- **`frontend/src/proprietary/`**: Proprietary/licensed features
|
||||
- **`frontend/src-tauri/`**: Tauri (Rust) native desktop application code
|
||||
- **`frontend/public/`**: Static assets served directly
|
||||
- `public/locales/`: Translation JSON files
|
||||
|
||||
### Component Architecture
|
||||
- **React Components**: Located in `frontend/src/components/` and `frontend/src/tools/`
|
||||
- **Static Assets**: CSS, JS, and resources in `src/main/resources/static/` (legacy) + `frontend/public/` (modern)
|
||||
- **Internationalization**:
|
||||
- Backend: `messages_*.properties` files
|
||||
@@ -203,6 +314,7 @@ return useToolOperation({
|
||||
- **Backend**: Designed to be stateless - files are processed in memory/temp locations only
|
||||
- **Frontend**: Uses IndexedDB for client-side file storage and caching (with thumbnails)
|
||||
- **Security**: When `DOCKER_ENABLE_SECURITY=false`, security-related classes are excluded from compilation
|
||||
- **Import Paths**: ALWAYS use `@app/*` for imports - never use `@core/*` or `@proprietary/*` unless explicitly wrapping/extending a lower layer
|
||||
- **FileContext**: All file operations MUST go through FileContext - never bypass with direct File handling
|
||||
- **Memory Management**: Manual cleanup required for PDF.js documents and blob URLs - don't remove cleanup code
|
||||
- **Tool Development**: New tools should follow `useToolOperation` hook pattern (see `useCompressOperation.ts`)
|
||||
|
||||
@@ -391,13 +391,24 @@ public class EndpointConfiguration {
|
||||
addEndpointToGroup("Advance", "extract-image-scans");
|
||||
addEndpointToGroup("Advance", "repair");
|
||||
addEndpointToGroup("Advance", "auto-rename");
|
||||
addEndpointToGroup("Advance", "handleData");
|
||||
addEndpointToGroup("Advance", "scanner-effect");
|
||||
addEndpointToGroup("Advance", "show-javascript");
|
||||
addEndpointToGroup("Advance", "overlay-pdf");
|
||||
// Backend-only endpoints
|
||||
addEndpointToGroup("Advance", "adjust-contrast");
|
||||
addEndpointToGroup("Advance", "pipeline");
|
||||
|
||||
// Adding endpoints to "Automation" group
|
||||
addEndpointToGroup("Automation", "handleData");
|
||||
addEndpointToGroup("Automation", "automate"); // Alias for handleData (user-friendly name)
|
||||
addEndpointToGroup("Automation", "pipeline");
|
||||
|
||||
// Adding endpoints to "DeveloperTools" group
|
||||
addEndpointToGroup("DeveloperTools", "show-javascript");
|
||||
|
||||
// Adding endpoints to "DeveloperDocs" group (fake endpoints for link-only tools)
|
||||
addEndpointToGroup("DeveloperDocs", "dev-api-docs");
|
||||
addEndpointToGroup("DeveloperDocs", "dev-folder-scanning-docs");
|
||||
addEndpointToGroup("DeveloperDocs", "dev-sso-guide-docs");
|
||||
addEndpointToGroup("DeveloperDocs", "dev-airgapped-docs");
|
||||
|
||||
// CLI
|
||||
addEndpointToGroup("CLI", "compress-pdf");
|
||||
@@ -595,6 +606,12 @@ 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)
|
||||
|
||||
+6
@@ -14,6 +14,7 @@ public class InstallationPathConfig {
|
||||
private static final String CUSTOM_FILES_PATH;
|
||||
private static final String CLIENT_WEBUI_PATH;
|
||||
private static final String PIPELINE_PATH;
|
||||
private static final String PLUGINS_PATH;
|
||||
|
||||
// Config paths
|
||||
private static final String SETTINGS_PATH;
|
||||
@@ -40,6 +41,7 @@ public class InstallationPathConfig {
|
||||
CUSTOM_FILES_PATH = BASE_PATH + "customFiles" + File.separator;
|
||||
CLIENT_WEBUI_PATH = BASE_PATH + "clientWebUI" + File.separator;
|
||||
PIPELINE_PATH = BASE_PATH + "pipeline" + File.separator;
|
||||
PLUGINS_PATH = CUSTOM_FILES_PATH + "plugins" + File.separator;
|
||||
|
||||
// Initialize config paths
|
||||
SETTINGS_PATH = CONFIG_PATH + "settings.yml";
|
||||
@@ -110,6 +112,10 @@ public class InstallationPathConfig {
|
||||
return SIGNATURES_PATH;
|
||||
}
|
||||
|
||||
public static String getPluginsPath() {
|
||||
return PLUGINS_PATH;
|
||||
}
|
||||
|
||||
public static String getPrivateKeyPath() {
|
||||
return BACKUP_PRIVATE_KEY_PATH;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
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,6 +39,7 @@ 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;
|
||||
@@ -393,12 +394,107 @@ 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;
|
||||
private int keyRetentionDays = 7;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
@@ -616,6 +712,8 @@ public class ApplicationProperties {
|
||||
private String appNameNavbar;
|
||||
private List<String> languages;
|
||||
private String logoStyle = "classic"; // Options: "classic" (default) or "modern"
|
||||
private boolean defaultHideUnavailableTools = false;
|
||||
private boolean defaultHideUnavailableConversions = false;
|
||||
|
||||
public String getAppNameNavbar() {
|
||||
return appNameNavbar != null && !appNameNavbar.trim().isEmpty() ? appNameNavbar : null;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package stirling.software.common.model;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/** Form field information with coordinates for interactive form viewer. */
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "Form field with coordinates and metadata")
|
||||
public class FormFieldWithCoordinates {
|
||||
|
||||
@Schema(description = "Fully qualified field name", example = "form1.firstName")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "Display label for the field", example = "First Name")
|
||||
private String label;
|
||||
|
||||
@Schema(description = "Field type: text, checkbox, radio, combobox, listbox, button, signature")
|
||||
private String type;
|
||||
|
||||
@Schema(description = "Current field value")
|
||||
private String value;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Available options (export values) for choice fields"
|
||||
+ " (dropdown, radio, listbox)")
|
||||
private List<String> options;
|
||||
|
||||
@Schema(
|
||||
description =
|
||||
"Human-readable display labels for choice field options,"
|
||||
+ " parallel to the 'options' list. Null when identical to options.")
|
||||
private List<String> displayOptions;
|
||||
|
||||
@Schema(description = "Whether the field is required")
|
||||
private boolean required;
|
||||
|
||||
@Schema(description = "Whether the field is read-only")
|
||||
private boolean readOnly;
|
||||
|
||||
@Schema(description = "Whether this is a multi-select list box")
|
||||
private boolean multiSelect;
|
||||
|
||||
@Schema(description = "Whether this is a multi-line text field")
|
||||
private boolean multiline;
|
||||
|
||||
@Schema(description = "Tooltip/alternate name for the field")
|
||||
private String tooltip;
|
||||
|
||||
@Schema(description = "Widget coordinates on each page (fields can have multiple widgets)")
|
||||
private List<WidgetCoordinates> widgets;
|
||||
|
||||
/**
|
||||
* Coordinates for a single widget annotation (visual representation of the field). A field can
|
||||
* have multiple widgets if it appears on multiple pages.
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
@Schema(description = "Widget coordinates in PDF space")
|
||||
public static class WidgetCoordinates {
|
||||
|
||||
@Schema(description = "Page index (0-based)", example = "0")
|
||||
private int pageIndex;
|
||||
|
||||
@Schema(description = "X coordinate in PDF points (lower-left origin)")
|
||||
private float x;
|
||||
|
||||
@Schema(description = "Y coordinate in PDF points (lower-left origin)")
|
||||
private float y;
|
||||
|
||||
@Schema(description = "Width in PDF points")
|
||||
private float width;
|
||||
|
||||
@Schema(description = "Height in PDF points")
|
||||
private float height;
|
||||
|
||||
@Schema(description = "Export value for this widget (radio/checkbox buttons only)")
|
||||
private String exportValue;
|
||||
|
||||
@Schema(description = "Font size in PDF points")
|
||||
private Float fontSize;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
/** Immutable descriptor that represents a loaded plugin. */
|
||||
@Value
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_EMPTY)
|
||||
public class PluginDescriptor {
|
||||
|
||||
String id;
|
||||
String icon;
|
||||
String name;
|
||||
String description;
|
||||
String version;
|
||||
String author;
|
||||
String frontendLabel;
|
||||
String frontendPath;
|
||||
String iconPath;
|
||||
String minHostVersion;
|
||||
String jarCreatedAt;
|
||||
|
||||
@Builder.Default boolean hasFrontend = false;
|
||||
|
||||
@Builder.Default List<String> backendEndpoints = Collections.emptyList();
|
||||
@Builder.Default Map<String, String> metadata = Collections.emptyMap();
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Value;
|
||||
|
||||
@Value
|
||||
@Builder
|
||||
/**
|
||||
* API-facing representation of a plugin descriptor with a fully resolved frontend URL.
|
||||
*
|
||||
* <p>This DTO is returned to clients so they can render plugin metadata and open plugin UIs.
|
||||
*/
|
||||
public class PluginDescriptorResponse {
|
||||
String id;
|
||||
String icon;
|
||||
String name;
|
||||
String description;
|
||||
String version;
|
||||
String author;
|
||||
String frontendUrl;
|
||||
String frontendLabel;
|
||||
String iconPath;
|
||||
String minHostVersion;
|
||||
String jarCreatedAt;
|
||||
boolean hasFrontend;
|
||||
List<String> backendEndpoints;
|
||||
Map<String, String> metadata;
|
||||
|
||||
/**
|
||||
* Creates a response object from an internal {@link PluginDescriptor}.
|
||||
*
|
||||
* @param descriptor loaded plugin descriptor
|
||||
* @param baseUrl optional API base URL used to build an absolute frontend URL
|
||||
* @return normalized response payload for API clients
|
||||
*/
|
||||
public static PluginDescriptorResponse from(PluginDescriptor descriptor, String baseUrl) {
|
||||
String frontendPath = descriptor.getFrontendPath();
|
||||
String normalizedBase = baseUrl != null ? baseUrl.replaceAll("/+$", "") : "";
|
||||
String normalizedPath = frontendPath != null ? frontendPath.replaceAll("^/+", "/") : "";
|
||||
String frontendUrl =
|
||||
(normalizedBase.isEmpty() || normalizedPath.isEmpty())
|
||||
? (normalizedPath.isEmpty() ? null : normalizedPath)
|
||||
: normalizedBase + normalizedPath;
|
||||
|
||||
return PluginDescriptorResponse.builder()
|
||||
.id(descriptor.getId())
|
||||
.icon(descriptor.getIcon())
|
||||
.name(descriptor.getName())
|
||||
.description(descriptor.getDescription())
|
||||
.version(descriptor.getVersion())
|
||||
.author(descriptor.getAuthor())
|
||||
.frontendUrl(frontendUrl)
|
||||
.frontendLabel(descriptor.getFrontendLabel())
|
||||
.iconPath(descriptor.getIconPath())
|
||||
.hasFrontend(descriptor.isHasFrontend())
|
||||
.backendEndpoints(descriptor.getBackendEndpoints())
|
||||
.metadata(descriptor.getMetadata())
|
||||
.minHostVersion(descriptor.getMinHostVersion())
|
||||
.jarCreatedAt(descriptor.getJarCreatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.nio.file.attribute.FileTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
|
||||
/**
|
||||
* Utility responsible for discovering plugin jars, parsing their metadata, and integrating them
|
||||
* into the Stirling-PDF runtime.
|
||||
*/
|
||||
@Slf4j
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public final class PluginLoader {
|
||||
private static final String JAR_EXTENSION = ".jar";
|
||||
private static final String JAR_MIME_TYPE = "application/java-archive";
|
||||
private static final String METADATA_RESOURCE = "META-INF/stirling-plugin.json";
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
/**
|
||||
* Discovers plugin jar files in the configured plugins directory.
|
||||
*
|
||||
* @return sorted list of valid plugin jar paths
|
||||
*/
|
||||
public static List<Path> listPluginJars() {
|
||||
Path pluginDir = ensurePluginDirectory();
|
||||
if (!Files.isDirectory(pluginDir)) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try (Stream<Path> stream = Files.list(pluginDir)) {
|
||||
return stream.filter(Files::isRegularFile)
|
||||
.filter(PluginLoader::looksLikeJarFile)
|
||||
.filter(PluginLoader::isReadableJarArchive)
|
||||
.sorted(
|
||||
Comparator.comparing(
|
||||
path -> path.getFileName().toString().toLowerCase()))
|
||||
.collect(Collectors.toList());
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to list plugin directory {}: {}", pluginDir, e.getMessage());
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts discovered plugin jar paths into URL entries suitable for class/resource loading.
|
||||
*
|
||||
* @return immutable-style list of valid jar URLs
|
||||
*/
|
||||
public static List<URL> pluginJarUrls() {
|
||||
List<Path> jars = listPluginJars();
|
||||
if (jars.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<URL> urls = new ArrayList<>(jars.size());
|
||||
for (Path jar : jars) {
|
||||
try {
|
||||
urls.add(jar.toUri().toURL());
|
||||
} catch (MalformedURLException e) {
|
||||
log.warn("Skipping plugin jar with invalid URL {}: {}", jar, e.getMessage());
|
||||
}
|
||||
}
|
||||
return urls;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a class loader that can load classes/resources from installed plugins.
|
||||
*
|
||||
* @param parent parent class loader
|
||||
* @return plugin-aware class loader or parent when no plugin jars exist
|
||||
*/
|
||||
public static ClassLoader buildPluginClassLoader(ClassLoader parent) {
|
||||
List<URL> urls = pluginJarUrls();
|
||||
if (urls.isEmpty()) {
|
||||
return parent;
|
||||
}
|
||||
log.info(
|
||||
"Scanning {} plugin jars in {}",
|
||||
urls.size(),
|
||||
InstallationPathConfig.getPluginsPath());
|
||||
return new URLClassLoader(urls.toArray(URL[]::new), parent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads descriptors for all discovered plugin jars.
|
||||
*
|
||||
* @return immutable list of successfully parsed descriptors
|
||||
*/
|
||||
public static List<PluginDescriptor> loadDescriptors() {
|
||||
List<Path> jars = listPluginJars();
|
||||
if (jars.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
List<PluginDescriptor> descriptors = new ArrayList<>();
|
||||
for (Path jar : jars) {
|
||||
PluginDescriptor descriptor = readDescriptorFromJar(jar);
|
||||
if (descriptor != null) {
|
||||
descriptors.add(descriptor);
|
||||
}
|
||||
}
|
||||
return Collections.unmodifiableList(descriptors);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads metadata for one plugin jar and maps it to a descriptor.
|
||||
*
|
||||
* @param jarPath plugin jar path
|
||||
* @return descriptor when valid metadata exists, otherwise {@code null}
|
||||
*/
|
||||
public static PluginDescriptor loadDescriptor(Path jarPath) {
|
||||
return readDescriptorFromJar(jarPath);
|
||||
}
|
||||
|
||||
private static Path ensurePluginDirectory() {
|
||||
Path pluginDir = Path.of(InstallationPathConfig.getPluginsPath());
|
||||
try {
|
||||
return Files.createDirectories(pluginDir);
|
||||
} catch (IOException e) {
|
||||
log.error("Unable to create plugin directory {}", pluginDir, e);
|
||||
return pluginDir;
|
||||
}
|
||||
}
|
||||
|
||||
private static PluginDescriptor readDescriptorFromJar(Path jarPath) {
|
||||
if (!Files.isRegularFile(jarPath)) {
|
||||
log.warn("Plugin jar {} is not a regular file, skipping", jarPath);
|
||||
return null;
|
||||
}
|
||||
try (JarFile jarFile = new JarFile(jarPath.toFile())) {
|
||||
JarEntry entry = jarFile.getJarEntry(METADATA_RESOURCE);
|
||||
if (entry == null) {
|
||||
log.info("Plugin jar {} does not include {}, skipping", jarPath, METADATA_RESOURCE);
|
||||
return null;
|
||||
}
|
||||
PluginMetadata metadata;
|
||||
try (InputStream inputStream = jarFile.getInputStream(entry)) {
|
||||
metadata = OBJECT_MAPPER.readValue(inputStream, PluginMetadata.class);
|
||||
}
|
||||
|
||||
String createdAt = resolveJarTimestamp(jarPath);
|
||||
|
||||
if (metadata.getId() == null || metadata.getId().isBlank()) {
|
||||
log.warn("Plugin metadata in {} is missing required id, ignoring", jarPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
String pluginId = metadata.getId();
|
||||
log.info(
|
||||
"Loaded metadata for plugin '{}': name='{}' version='{}'",
|
||||
pluginId,
|
||||
metadata.getName(),
|
||||
metadata.getVersion());
|
||||
|
||||
return buildDescriptor(metadata, createdAt);
|
||||
} catch (IOException e) {
|
||||
log.warn("Failed to inspect plugin jar {}: {}", jarPath, e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static PluginDescriptor buildDescriptor(PluginMetadata metadata, String jarCreatedAt) {
|
||||
PluginMetadata.PluginFrontend frontend = metadata.getFrontend();
|
||||
String id = metadata.getId();
|
||||
String icon = metadata.getIcon();
|
||||
|
||||
String frontendPath =
|
||||
(frontend != null
|
||||
&& frontend.getEntrypoint() != null
|
||||
&& !frontend.getEntrypoint().isBlank())
|
||||
? ensureLeadingSlash(frontend.getEntrypoint())
|
||||
: "/plugins/" + id + "/index.html";
|
||||
|
||||
return PluginDescriptor.builder()
|
||||
.id(id)
|
||||
.icon(defaultIfEmpty(icon, null))
|
||||
.name(defaultIfEmpty(metadata.getName(), id))
|
||||
.description(defaultIfEmpty(metadata.getDescription(), ""))
|
||||
.version(defaultIfEmpty(metadata.getVersion(), "0.0.0"))
|
||||
.author(metadata.getAuthor())
|
||||
.frontendLabel(frontend != null ? frontend.getLabel() : null)
|
||||
.frontendPath(frontendPath)
|
||||
.iconPath(frontend != null ? frontend.getIconPath() : null)
|
||||
.hasFrontend(frontend != null)
|
||||
.backendEndpoints(
|
||||
metadata.getBackendEndpoints() == null
|
||||
? Collections.emptyList()
|
||||
: metadata.getBackendEndpoints())
|
||||
.metadata(
|
||||
metadata.getMetadata() == null
|
||||
? Collections.emptyMap()
|
||||
: metadata.getMetadata())
|
||||
.minHostVersion(defaultIfEmpty(metadata.getMinHostVersion(), null))
|
||||
.jarCreatedAt(jarCreatedAt)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static String defaultIfEmpty(String value, String fallback) {
|
||||
return (value == null || value.isBlank()) ? fallback : value;
|
||||
}
|
||||
|
||||
private static String ensureLeadingSlash(String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
return "/";
|
||||
}
|
||||
return path.startsWith("/") ? path : "/" + path;
|
||||
}
|
||||
|
||||
private static boolean looksLikeJarFile(Path path) {
|
||||
return path.getFileName().toString().toLowerCase().endsWith(JAR_EXTENSION);
|
||||
}
|
||||
|
||||
private static boolean isReadableJarArchive(Path path) {
|
||||
try {
|
||||
String mimeType = Files.probeContentType(path);
|
||||
if (mimeType != null && !JAR_MIME_TYPE.equals(mimeType)) {
|
||||
log.debug("Ignoring non-jar mime type {} for {}", mimeType, path);
|
||||
return false;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
log.debug("Unable to probe mime type for {}: {}", path, e.getMessage());
|
||||
}
|
||||
|
||||
try (JarFile ignored = new JarFile(path.toFile())) {
|
||||
return true;
|
||||
} catch (IOException e) {
|
||||
log.warn("Skipping invalid jar archive {}: {}", path, e.getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveJarTimestamp(Path jarPath) throws IOException {
|
||||
BasicFileAttributes attrs = Files.readAttributes(jarPath, BasicFileAttributes.class);
|
||||
FileTime creationTime = attrs.creationTime();
|
||||
FileTime lastModifiedTime = attrs.lastModifiedTime();
|
||||
FileTime preferredTime =
|
||||
creationTime == null || creationTime.toMillis() <= 0
|
||||
? lastModifiedTime
|
||||
: creationTime;
|
||||
return preferredTime.toInstant().toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
|
||||
@Getter
|
||||
@Setter
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
/** Model for deserializing {@code META-INF/stirling-plugin.json} from a plugin jar. */
|
||||
public class PluginMetadata {
|
||||
private String id;
|
||||
private String icon;
|
||||
private String name;
|
||||
private String description;
|
||||
private String version;
|
||||
private String author;
|
||||
private String minHostVersion;
|
||||
private PluginFrontend frontend;
|
||||
private List<String> backendEndpoints;
|
||||
private Map<String, String> metadata;
|
||||
|
||||
/** Frontend-specific metadata block declared inside plugin metadata JSON. */
|
||||
@Getter
|
||||
@Setter
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public static class PluginFrontend {
|
||||
private String entrypoint;
|
||||
private String label;
|
||||
private String iconPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package stirling.software.common.plugins;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
@Configuration
|
||||
/** Registers MVC resource handlers that expose static assets from plugin jars. */
|
||||
public class PluginResourceConfig implements WebMvcConfigurer {
|
||||
|
||||
/** Adds {@code /plugins/**} static resource mappings for every discovered plugin jar. */
|
||||
@Override
|
||||
public void addResourceHandlers(ResourceHandlerRegistry registry) {
|
||||
List<String> locations =
|
||||
PluginLoader.pluginJarUrls().stream()
|
||||
.map(url -> "jar:" + url + "!/META-INF/resources/plugins/")
|
||||
.collect(Collectors.toList());
|
||||
|
||||
if (!locations.isEmpty()) {
|
||||
registry.addResourceHandler("/plugins/**")
|
||||
.addResourceLocations(locations.toArray(String[]::new))
|
||||
.setCachePeriod(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -25,6 +26,9 @@ import lombok.extern.slf4j.Slf4j;
|
||||
public class MobileScannerService {
|
||||
|
||||
private static final long SESSION_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
|
||||
private static final Pattern FILENAME_SANITIZE_PATTERN = Pattern.compile("[^a-zA-Z0-9._-]");
|
||||
private static final Pattern SESSION_ID_VALIDATION_PATTERN = Pattern.compile("[a-zA-Z0-9-]+");
|
||||
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
|
||||
private final Map<String, SessionData> activeSessions = new ConcurrentHashMap<>();
|
||||
private final Path tempDirectory;
|
||||
|
||||
@@ -121,7 +125,8 @@ public class MobileScannerService {
|
||||
// Handle duplicate filenames
|
||||
int counter = 1;
|
||||
while (Files.exists(filePath)) {
|
||||
String nameWithoutExt = safeFilename.replaceFirst("[.][^.]+$", "");
|
||||
String nameWithoutExt =
|
||||
FILE_EXTENSION_PATTERN.matcher(safeFilename).replaceFirst("");
|
||||
String ext =
|
||||
safeFilename.contains(".")
|
||||
? safeFilename.substring(safeFilename.lastIndexOf("."))
|
||||
@@ -271,14 +276,14 @@ public class MobileScannerService {
|
||||
throw new IllegalArgumentException("Session ID cannot be empty");
|
||||
}
|
||||
// Basic validation: alphanumeric and hyphens only
|
||||
if (!sessionId.matches("[a-zA-Z0-9-]+")) {
|
||||
if (!SESSION_ID_VALIDATION_PATTERN.matcher(sessionId).matches()) {
|
||||
throw new IllegalArgumentException("Invalid session ID format");
|
||||
}
|
||||
}
|
||||
|
||||
private String sanitizeFilename(String filename) {
|
||||
// Remove path traversal attempts and dangerous characters
|
||||
String sanitized = filename.replaceAll("[^a-zA-Z0-9._-]", "_");
|
||||
String sanitized = FILENAME_SANITIZE_PATTERN.matcher(filename).replaceAll("_");
|
||||
// Ensure we have a non-empty, safe filename
|
||||
if (sanitized.isBlank()) {
|
||||
sanitized = "upload-" + System.currentTimeMillis();
|
||||
|
||||
@@ -47,6 +47,7 @@ public class SvgSanitizer {
|
||||
private static final Pattern DATA_SCRIPT_PATTERN =
|
||||
Pattern.compile(
|
||||
"^\\s*data\\s*:[^,]*(?:script|javascript|vbscript)", Pattern.CASE_INSENSITIVE);
|
||||
private static final Pattern NULL_BYTE_PATTERN = Pattern.compile("\u0000");
|
||||
private final SsrfProtectionService ssrfProtectionService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@@ -210,7 +211,7 @@ public class SvgSanitizer {
|
||||
|
||||
String result = url.trim();
|
||||
|
||||
result = result.replaceAll("\u0000", "");
|
||||
result = NULL_BYTE_PATTERN.matcher(result).replaceAll("");
|
||||
|
||||
for (int i = 0; i < 3; i++) {
|
||||
try {
|
||||
|
||||
@@ -9,9 +9,11 @@ import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
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;
|
||||
@@ -27,6 +29,7 @@ import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.configuration.ConfigInitializer;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.plugins.PluginLoader;
|
||||
|
||||
@Slf4j
|
||||
@EnableScheduling
|
||||
@@ -38,6 +41,10 @@ import stirling.software.common.model.ApplicationProperties;
|
||||
})
|
||||
public class SPDFApplication {
|
||||
|
||||
private static final Pattern PORT_SUFFIX_PATTERN = Pattern.compile(".+:\\d+$");
|
||||
private static final Pattern URL_SCHEME_PATTERN =
|
||||
Pattern.compile("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
|
||||
private static final Pattern TRAILING_SLASH_PATTERN = Pattern.compile("/+$");
|
||||
private static String serverPortStatic;
|
||||
private static String baseUrlStatic;
|
||||
private static String contextPathStatic;
|
||||
@@ -54,11 +61,11 @@ public class SPDFApplication {
|
||||
}
|
||||
|
||||
public static void main(String[] args) throws IOException, InterruptedException {
|
||||
SpringApplication app = new SpringApplication(SPDFApplication.class);
|
||||
SpringApplicationBuilder builder = new SpringApplicationBuilder(SPDFApplication.class);
|
||||
|
||||
Properties props = new Properties();
|
||||
|
||||
app.setAdditionalProfiles(getActiveProfile(args));
|
||||
builder.profiles(getActiveProfile(args));
|
||||
|
||||
ConfigInitializer initializer = new ConfigInitializer();
|
||||
try {
|
||||
@@ -106,8 +113,13 @@ public class SPDFApplication {
|
||||
if (!props.isEmpty()) {
|
||||
finalProps.putAll(props);
|
||||
}
|
||||
ClassLoader pluginClassLoader =
|
||||
PluginLoader.buildPluginClassLoader(SPDFApplication.class.getClassLoader());
|
||||
if (pluginClassLoader != SPDFApplication.class.getClassLoader()) {
|
||||
Thread.currentThread().setContextClassLoader(pluginClassLoader);
|
||||
}
|
||||
SpringApplication app = builder.build();
|
||||
app.setDefaultProperties(finalProps);
|
||||
|
||||
app.run(args);
|
||||
|
||||
// Ensure directories are created
|
||||
@@ -244,8 +256,8 @@ public class SPDFApplication {
|
||||
String trimmedBase =
|
||||
(backendUrl == null || backendUrl.isBlank())
|
||||
? "http://localhost"
|
||||
: backendUrl.trim().replaceAll("/+$", "");
|
||||
boolean hasScheme = trimmedBase.matches("^[a-zA-Z][a-zA-Z0-9+.-]*://.*");
|
||||
: TRAILING_SLASH_PATTERN.matcher(backendUrl.trim()).replaceAll("");
|
||||
boolean hasScheme = URL_SCHEME_PATTERN.matcher(trimmedBase).matches();
|
||||
String baseForParsing = hasScheme ? trimmedBase : "http://" + trimmedBase;
|
||||
Integer parsedPort = parsePort(port);
|
||||
|
||||
@@ -298,7 +310,7 @@ public class SPDFApplication {
|
||||
if (port == null) {
|
||||
return trimmedBase;
|
||||
}
|
||||
if (trimmedBase.matches(".+:\\d+$")) {
|
||||
if (PORT_SUFFIX_PATTERN.matcher(trimmedBase).matches()) {
|
||||
return trimmedBase;
|
||||
}
|
||||
return trimmedBase + ":" + port;
|
||||
|
||||
@@ -8,6 +8,7 @@ import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.multipdf.PDFMergerUtility;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
@@ -51,6 +52,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequiredArgsConstructor
|
||||
public class MergeController {
|
||||
|
||||
private static final Pattern QUOTE_WRAP_PATTERN = Pattern.compile("^\"|\"$");
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@@ -173,7 +175,7 @@ public class MergeController {
|
||||
String[] parts = inside.split(",");
|
||||
String[] result = new String[parts.length];
|
||||
for (int i = 0; i < parts.length; i++) {
|
||||
result[i] = parts[i].trim().replaceAll("^\"|\"$", "");
|
||||
result[i] = QUOTE_WRAP_PATTERN.matcher(parts[i].trim()).replaceAll("");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
+223
-6
@@ -1,7 +1,9 @@
|
||||
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;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.http.MediaType;
|
||||
@@ -36,6 +38,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequiredArgsConstructor
|
||||
public class ConvertPdfJsonController {
|
||||
|
||||
private static final Pattern FILE_EXTENSION_PATTERN = Pattern.compile("[.][^.]+$");
|
||||
private final PdfJsonConversionService pdfJsonConversionService;
|
||||
|
||||
@Autowired(required = false)
|
||||
@@ -56,10 +59,13 @@ public class ConvertPdfJsonController {
|
||||
}
|
||||
|
||||
byte[] jsonBytes = pdfJsonConversionService.convertPdfToJson(inputFile, lightweight);
|
||||
logJsonResponse("pdf/text-editor", jsonBytes);
|
||||
String originalName = inputFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(originalName))
|
||||
.replaceFirst("")
|
||||
: "document";
|
||||
String docName = baseName + ".json";
|
||||
return WebResponseUtils.bytesToWebResponse(jsonBytes, docName, MediaType.APPLICATION_JSON);
|
||||
@@ -82,7 +88,9 @@ public class ConvertPdfJsonController {
|
||||
String originalName = jsonFile.getOriginalFilename();
|
||||
String baseName =
|
||||
(originalName != null && !originalName.isBlank())
|
||||
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(originalName))
|
||||
.replaceFirst("")
|
||||
: "document";
|
||||
String docName = baseName.endsWith(".pdf") ? baseName : baseName + ".pdf";
|
||||
return WebResponseUtils.bytesToWebResponse(pdfBytes, docName);
|
||||
@@ -108,14 +116,17 @@ public class ConvertPdfJsonController {
|
||||
// Scope job to authenticated user if security is enabled
|
||||
String scopedJobKey = getScopedJobKey(baseJobId);
|
||||
|
||||
log.info("Extracting metadata for PDF, assigned jobId: {}", scopedJobKey);
|
||||
log.debug("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())
|
||||
? Filenames.toSimpleFileName(originalName).replaceFirst("[.][^.]+$", "")
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(originalName))
|
||||
.replaceFirst("")
|
||||
: "document";
|
||||
String docName = baseName + "_metadata.json";
|
||||
|
||||
@@ -152,7 +163,9 @@ public class ConvertPdfJsonController {
|
||||
|
||||
String baseName =
|
||||
(filename != null && !filename.isBlank())
|
||||
? Filenames.toSimpleFileName(filename).replaceFirst("[.][^.]+$", "")
|
||||
? FILE_EXTENSION_PATTERN
|
||||
.matcher(Filenames.toSimpleFileName(filename))
|
||||
.replaceFirst("")
|
||||
: Optional.ofNullable(document.getMetadata())
|
||||
.map(PdfJsonMetadata::getTitle)
|
||||
.filter(title -> title != null && !title.isBlank())
|
||||
@@ -175,11 +188,33 @@ 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);
|
||||
}
|
||||
|
||||
@AutoJobPostMapping(value = "/pdf/text-editor/clear-cache/{jobId}")
|
||||
@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)
|
||||
@Operation(
|
||||
summary = "Clear cached PDF document for text editor",
|
||||
description =
|
||||
@@ -209,6 +244,188 @@ 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.
|
||||
*
|
||||
|
||||
+45
-7
@@ -1,5 +1,6 @@
|
||||
package stirling.software.SPDF.controller.api.misc;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
@@ -8,20 +9,23 @@ import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Hidden;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||
import stirling.software.SPDF.config.InitialSetup;
|
||||
import stirling.software.SPDF.service.plugin.PluginService;
|
||||
import stirling.software.common.annotations.api.ConfigApi;
|
||||
import stirling.software.common.configuration.AppConfig;
|
||||
import stirling.software.common.configuration.InstallationPathConfig;
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.plugins.PluginDescriptorResponse;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
import stirling.software.common.service.UserServiceInterface;
|
||||
|
||||
@@ -37,6 +41,7 @@ public class ConfigController {
|
||||
private final UserServiceInterface userService;
|
||||
private final stirling.software.common.service.LicenseServiceInterface licenseService;
|
||||
private final stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig;
|
||||
private final PluginService pluginService;
|
||||
|
||||
public ConfigController(
|
||||
ApplicationProperties applicationProperties,
|
||||
@@ -48,7 +53,8 @@ public class ConfigController {
|
||||
UserServiceInterface userService,
|
||||
@org.springframework.beans.factory.annotation.Autowired(required = false)
|
||||
stirling.software.common.service.LicenseServiceInterface licenseService,
|
||||
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig) {
|
||||
stirling.software.SPDF.config.ExternalAppDepConfig externalAppDepConfig,
|
||||
PluginService pluginService) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
this.applicationContext = applicationContext;
|
||||
this.endpointConfiguration = endpointConfiguration;
|
||||
@@ -56,6 +62,7 @@ public class ConfigController {
|
||||
this.userService = userService;
|
||||
this.licenseService = licenseService;
|
||||
this.externalAppDepConfig = externalAppDepConfig;
|
||||
this.pluginService = pluginService;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,6 +160,14 @@ public class ConfigController {
|
||||
configData.put("logoStyle", applicationProperties.getUi().getLogoStyle());
|
||||
configData.put("defaultLocale", applicationProperties.getSystem().getDefaultLocale());
|
||||
|
||||
// User preference defaults
|
||||
configData.put(
|
||||
"defaultHideUnavailableTools",
|
||||
applicationProperties.getUi().isDefaultHideUnavailableTools());
|
||||
configData.put(
|
||||
"defaultHideUnavailableConversions",
|
||||
applicationProperties.getUi().isDefaultHideUnavailableConversions());
|
||||
|
||||
// Security settings
|
||||
// enableLogin requires both the config flag AND proprietary features to be loaded
|
||||
// If userService is null, proprietary module isn't loaded
|
||||
@@ -282,6 +297,10 @@ public class ConfigController {
|
||||
// Version/machine info not available
|
||||
}
|
||||
|
||||
// config directory path
|
||||
configData.put("basePath", InstallationPathConfig.getPath());
|
||||
configData.put("pluginsPath", InstallationPathConfig.getPluginsPath());
|
||||
|
||||
return ResponseEntity.ok(configData);
|
||||
|
||||
} catch (Exception e) {
|
||||
@@ -291,6 +310,23 @@ public class ConfigController {
|
||||
}
|
||||
}
|
||||
|
||||
@GetMapping("/plugins")
|
||||
public ResponseEntity<List<PluginDescriptorResponse>> getPlugins(HttpServletRequest request) {
|
||||
String baseUrl =
|
||||
ServletUriComponentsBuilder.fromRequestUri(request)
|
||||
.replacePath(null)
|
||||
.replaceQuery(null)
|
||||
.build()
|
||||
.toUriString();
|
||||
|
||||
List<PluginDescriptorResponse> mapped =
|
||||
pluginService.getPlugins().stream()
|
||||
.map(descriptor -> PluginDescriptorResponse.from(descriptor, baseUrl))
|
||||
.toList();
|
||||
|
||||
return ResponseEntity.ok(mapped);
|
||||
}
|
||||
|
||||
@GetMapping("/endpoint-enabled")
|
||||
public ResponseEntity<Boolean> isEndpointEnabled(
|
||||
@RequestParam(name = "endpoint") String endpoint) {
|
||||
@@ -312,11 +348,13 @@ public class ConfigController {
|
||||
|
||||
@GetMapping("/endpoints-availability")
|
||||
public ResponseEntity<Map<String, EndpointAvailability>> getEndpointAvailability(
|
||||
@RequestParam(name = "endpoints")
|
||||
@Size(min = 1, max = 100, message = "Must provide between 1 and 100 endpoints")
|
||||
List<@NotBlank String> endpoints) {
|
||||
@RequestParam(name = "endpoints", required = false) List<String> endpoints) {
|
||||
Collection<String> toCheck =
|
||||
(endpoints == null || endpoints.isEmpty())
|
||||
? endpointConfiguration.getAllEndpoints()
|
||||
: endpoints;
|
||||
Map<String, EndpointAvailability> result = new HashMap<>();
|
||||
for (String endpoint : endpoints) {
|
||||
for (String endpoint : toCheck) {
|
||||
String trimmedEndpoint = endpoint.trim();
|
||||
result.put(
|
||||
trimmedEndpoint,
|
||||
|
||||
+2
-1
@@ -57,6 +57,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
@RequiredArgsConstructor
|
||||
public class StampController {
|
||||
|
||||
private static final Pattern NEWLINE_PATTERN = Pattern.compile("\\r?\\n");
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final TempFileManager tempFileManager;
|
||||
|
||||
@@ -266,7 +267,7 @@ public class StampController {
|
||||
.getEscapedNewlinePattern()
|
||||
.matcher(processedStampText)
|
||||
.replaceAll("\n");
|
||||
String[] lines = normalizedText.split("\\r?\\n");
|
||||
String[] lines = NEWLINE_PATTERN.split(normalizedText);
|
||||
|
||||
PDRectangle pageSize = page.getMediaBox();
|
||||
|
||||
|
||||
+8
-4
@@ -21,6 +21,7 @@ import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
@@ -44,6 +45,7 @@ import stirling.software.common.util.FileMonitor;
|
||||
public class PipelineDirectoryProcessor {
|
||||
|
||||
private static final int MAX_DIRECTORY_DEPTH = 50; // Prevent excessive recursion
|
||||
private static final Pattern WATCHED_FOLDERS_PATTERN = Pattern.compile("\\\\?watchedFolders");
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final ApiDocService apiDocService;
|
||||
@@ -433,10 +435,12 @@ public class PipelineDirectoryProcessor {
|
||||
|
||||
private Path determineOutputPath(PipelineConfig config, Path dir) {
|
||||
String outputDir =
|
||||
config.getOutputDir()
|
||||
.replace("{outputFolder}", finishedFoldersDir)
|
||||
.replace("{folderName}", dir.toString())
|
||||
.replaceAll("\\\\?watchedFolders", "");
|
||||
WATCHED_FOLDERS_PATTERN
|
||||
.matcher(
|
||||
config.getOutputDir()
|
||||
.replace("{outputFolder}", finishedFoldersDir)
|
||||
.replace("{folderName}", dir.toString()))
|
||||
.replaceAll("");
|
||||
return Paths.get(outputDir).isAbsolute() ? Paths.get(outputDir) : Paths.get(".", outputDir);
|
||||
}
|
||||
|
||||
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
package stirling.software.SPDF.controller.web;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.URI;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Optional;
|
||||
import java.util.jar.JarEntry;
|
||||
import java.util.jar.JarFile;
|
||||
|
||||
import org.springframework.core.io.ByteArrayResource;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.MediaTypeFactory;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.stereotype.Controller;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.service.plugin.PluginService;
|
||||
|
||||
@Controller
|
||||
@Slf4j
|
||||
/**
|
||||
* Serves static frontend assets embedded in plugin jars under {@code META-INF/resources/plugins}.
|
||||
*/
|
||||
public class PluginFrontendController {
|
||||
private static final String PLUGIN_RESOURCE_ROOT = "META-INF/resources/plugins/";
|
||||
|
||||
private final PluginService pluginService;
|
||||
|
||||
/**
|
||||
* @param pluginService service used to resolve plugin jar locations
|
||||
*/
|
||||
public PluginFrontendController(PluginService pluginService) {
|
||||
this.pluginService = pluginService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirects plugin root requests to the conventional {@code index.html} entrypoint.
|
||||
*
|
||||
* @param pluginId requested plugin identifier
|
||||
* @return permanent redirect to plugin index page
|
||||
*/
|
||||
@GetMapping("/plugins/{pluginId}")
|
||||
public ResponseEntity<Void> redirectToIndex(@PathVariable String pluginId) {
|
||||
return ResponseEntity.status(301)
|
||||
.location(URI.create("/plugins/" + pluginId + "/index.html"))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams an asset from the requested plugin jar, while validating path boundaries to avoid
|
||||
* traversal outside the plugin resource root.
|
||||
*
|
||||
* @param request incoming servlet request used to extract suffix path
|
||||
* @param pluginId requested plugin identifier
|
||||
* @return asset content when found; suitable HTTP error otherwise
|
||||
*/
|
||||
@GetMapping("/plugins/{pluginId}/**")
|
||||
public ResponseEntity<ByteArrayResource> servePluginAsset(
|
||||
HttpServletRequest request, @PathVariable String pluginId) {
|
||||
try {
|
||||
String suffix = resolveSuffix(request, pluginId);
|
||||
if (suffix == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
if (suffix.contains("..")) {
|
||||
log.warn(
|
||||
"[PluginFrontend] Blocked path traversal attempt for {}: {}",
|
||||
pluginId,
|
||||
suffix);
|
||||
return ResponseEntity.badRequest().build();
|
||||
}
|
||||
|
||||
Optional<Path> jarPath = pluginService.getPluginJarPath(pluginId);
|
||||
if (jarPath.isEmpty() || !Files.isRegularFile(jarPath.get())) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
String resourcePath = PLUGIN_RESOURCE_ROOT + pluginId + suffix;
|
||||
return serveResourceFromJar(jarPath.get(), resourcePath);
|
||||
} catch (IOException e) {
|
||||
log.error("[PluginFrontend] Failed to stream plugin asset for {}", pluginId, e);
|
||||
return ResponseEntity.status(500).build();
|
||||
}
|
||||
}
|
||||
|
||||
private static String resolveSuffix(HttpServletRequest request, String pluginId) {
|
||||
String contextPath = Optional.ofNullable(request.getContextPath()).orElse("");
|
||||
String requestUri = Optional.ofNullable(request.getRequestURI()).orElse("");
|
||||
String prefix = contextPath + "/plugins/" + pluginId;
|
||||
if (!requestUri.startsWith(prefix)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String suffix = requestUri.substring(prefix.length());
|
||||
if (suffix.isEmpty() || "/".equals(suffix)) {
|
||||
return "/index.html";
|
||||
}
|
||||
return suffix;
|
||||
}
|
||||
|
||||
private static ResponseEntity<ByteArrayResource> serveResourceFromJar(
|
||||
Path jarPath, String resourcePath) throws IOException {
|
||||
try (JarFile jarFile = new JarFile(jarPath.toFile())) {
|
||||
JarEntry entry = jarFile.getJarEntry(resourcePath);
|
||||
if (entry == null || entry.isDirectory()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
}
|
||||
|
||||
byte[] content;
|
||||
try (InputStream stream = jarFile.getInputStream(entry)) {
|
||||
content = stream.readAllBytes();
|
||||
}
|
||||
|
||||
MediaType mediaType =
|
||||
MediaTypeFactory.getMediaType(entry.getName())
|
||||
.orElse(MediaType.APPLICATION_OCTET_STREAM);
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(mediaType)
|
||||
.cacheControl(CacheControl.noStore())
|
||||
.body(new ByteArrayResource(content));
|
||||
}
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
@@ -28,6 +29,8 @@ public class ReactRoutingController {
|
||||
|
||||
private static final org.slf4j.Logger log =
|
||||
org.slf4j.LoggerFactory.getLogger(ReactRoutingController.class);
|
||||
private static final Pattern BASE_HREF_PATTERN =
|
||||
Pattern.compile("<base href=\\\"[^\\\"]*\\\"\\s*/?>");
|
||||
|
||||
@Value("${server.servlet.context-path:/}")
|
||||
private String contextPath;
|
||||
@@ -94,9 +97,9 @@ public class ReactRoutingController {
|
||||
html = html.replace("%BASE_URL%", baseUrl);
|
||||
// Also rewrite any existing <base> tag (Vite may have baked one in)
|
||||
html =
|
||||
html.replaceFirst(
|
||||
"<base href=\\\"[^\\\"]*\\\"\\s*/?>",
|
||||
"<base href=\\\"" + baseUrl + "\\\" />");
|
||||
BASE_HREF_PATTERN
|
||||
.matcher(html)
|
||||
.replaceFirst("<base href=\\\"" + baseUrl + "\\\" />");
|
||||
|
||||
// Inject context path as a global variable for API calls
|
||||
String contextPathScript =
|
||||
|
||||
+753
-113
File diff suppressed because it is too large
Load Diff
@@ -37,23 +37,68 @@ 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<>()));
|
||||
stream.getCOSObject(),
|
||||
Collections.newSetFromMap(new IdentityHashMap<>()),
|
||||
SerializationContext.DEFAULT);
|
||||
}
|
||||
|
||||
public PdfJsonStream serializeStream(COSStream cosStream) throws IOException {
|
||||
if (cosStream == null) {
|
||||
return null;
|
||||
}
|
||||
return serializeStream(cosStream, Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
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);
|
||||
}
|
||||
|
||||
public PdfJsonCosValue serializeCosValue(COSBase base) throws IOException {
|
||||
return serializeCosValue(base, Collections.newSetFromMap(new IdentityHashMap<>()));
|
||||
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);
|
||||
}
|
||||
|
||||
public COSBase deserializeCosValue(PdfJsonCosValue value, PDDocument document)
|
||||
@@ -165,8 +210,8 @@ public class PdfJsonCosMapper {
|
||||
return cosStream;
|
||||
}
|
||||
|
||||
private PdfJsonCosValue serializeCosValue(COSBase base, Set<COSBase> visited)
|
||||
throws IOException {
|
||||
private PdfJsonCosValue serializeCosValue(
|
||||
COSBase base, Set<COSBase> visited, SerializationContext context) throws IOException {
|
||||
if (base == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -220,21 +265,23 @@ public class PdfJsonCosMapper {
|
||||
if (base instanceof COSArray array) {
|
||||
List<PdfJsonCosValue> items = new ArrayList<>(array.size());
|
||||
for (COSBase item : array) {
|
||||
PdfJsonCosValue serialized = serializeCosValue(item, visited);
|
||||
PdfJsonCosValue serialized = serializeCosValue(item, visited, context);
|
||||
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));
|
||||
builder.type(PdfJsonCosValue.Type.STREAM).stream(
|
||||
serializeStream(stream, visited, context));
|
||||
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);
|
||||
serializeCosValue(
|
||||
dictionary.getDictionaryObject(key), visited, context);
|
||||
entries.put(key.getName(), serialized);
|
||||
}
|
||||
builder.type(PdfJsonCosValue.Type.DICTIONARY).entries(entries);
|
||||
@@ -248,16 +295,23 @@ public class PdfJsonCosMapper {
|
||||
}
|
||||
}
|
||||
|
||||
private PdfJsonStream serializeStream(COSStream cosStream, Set<COSBase> visited)
|
||||
private PdfJsonStream serializeStream(
|
||||
COSStream cosStream, Set<COSBase> visited, SerializationContext context)
|
||||
throws IOException {
|
||||
Map<String, PdfJsonCosValue> dictionary = new LinkedHashMap<>();
|
||||
for (COSName key : cosStream.keySet()) {
|
||||
COSBase value = cosStream.getDictionaryObject(key);
|
||||
PdfJsonCosValue serialized = serializeCosValue(value, visited);
|
||||
PdfJsonCosValue serialized = serializeCosValue(value, visited, context);
|
||||
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()) {
|
||||
|
||||
+17
-9
@@ -7,6 +7,7 @@ import java.io.InputStream;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
@@ -310,6 +311,11 @@ public class PdfJsonFallbackFontService {
|
||||
"classpath:/static/fonts/DejaVuSansMono-BoldOblique.ttf",
|
||||
"DejaVuSansMono-BoldOblique",
|
||||
"ttf")));
|
||||
private static final Pattern BOLD_FONT_WEIGHT_PATTERN =
|
||||
Pattern.compile(".*[_-]?[6-9]00(wght)?.*");
|
||||
private static final Pattern FONT_NAME_DELIMITER_PATTERN = Pattern.compile("[-_,+]");
|
||||
private static final Pattern WHITESPACE_PATTERN = Pattern.compile("\\s+");
|
||||
private static final Pattern PATTERN = Pattern.compile("^[A-Z]{6}\\+");
|
||||
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final stirling.software.common.model.ApplicationProperties applicationProperties;
|
||||
@@ -418,16 +424,18 @@ public class PdfJsonFallbackFontService {
|
||||
// Normalize font name: remove subset prefix (e.g. "PXAAAC+"), convert to lowercase,
|
||||
// remove spaces
|
||||
String normalized =
|
||||
originalFontName
|
||||
.replaceAll("^[A-Z]{6}\\+", "") // Remove subset prefix
|
||||
.toLowerCase()
|
||||
.replaceAll("\\s+", ""); // Remove spaces (e.g. "Times New Roman" ->
|
||||
WHITESPACE_PATTERN
|
||||
.matcher(
|
||||
PATTERN.matcher(originalFontName)
|
||||
.replaceAll("") // Remove subset prefix
|
||||
.toLowerCase())
|
||||
.replaceAll(""); // Remove spaces (e.g. "Times New Roman" ->
|
||||
// "timesnewroman")
|
||||
|
||||
// Extract base name without weight/style suffixes
|
||||
// Split on common delimiters: hyphen, underscore, comma, plus
|
||||
// Handles: "Arimo_700wght" -> "arimo", "Arial-Bold" -> "arial", "Arial,Bold" -> "arial"
|
||||
String baseName = normalized.split("[-_,+]")[0];
|
||||
String baseName = FONT_NAME_DELIMITER_PATTERN.split(normalized)[0];
|
||||
|
||||
String aliasedFontId = FONT_NAME_ALIASES.get(baseName);
|
||||
if (aliasedFontId != null) {
|
||||
@@ -470,7 +478,7 @@ public class PdfJsonFallbackFontService {
|
||||
|
||||
// Check for numeric weight indicators (600-900 = bold)
|
||||
// Handles: "Arimo_700wght", "Arial-700", "Font-w700"
|
||||
if (normalizedFontName.matches(".*[_-]?[6-9]00(wght)?.*")) {
|
||||
if (BOLD_FONT_WEIGHT_PATTERN.matcher(normalizedFontName).matches()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -514,7 +522,7 @@ public class PdfJsonFallbackFontService {
|
||||
// Supported: Liberation (Sans/Serif/Mono), Noto Sans, DejaVu (Sans/Serif/Mono)
|
||||
boolean isSupported =
|
||||
baseFontId.startsWith("fallback-liberation-")
|
||||
|| baseFontId.equals("fallback-noto-sans")
|
||||
|| "fallback-noto-sans".equals(baseFontId)
|
||||
|| baseFontId.startsWith("fallback-dejavu-");
|
||||
|
||||
if (!isSupported) {
|
||||
@@ -523,8 +531,8 @@ public class PdfJsonFallbackFontService {
|
||||
|
||||
// DejaVu Sans and Mono use "oblique" instead of "italic"
|
||||
boolean useOblique =
|
||||
baseFontId.equals("fallback-dejavu-sans")
|
||||
|| baseFontId.equals("fallback-dejavu-mono");
|
||||
"fallback-dejavu-sans".equals(baseFontId)
|
||||
|| "fallback-dejavu-mono".equals(baseFontId);
|
||||
|
||||
if (isBold && isItalic) {
|
||||
return baseFontId + (useOblique ? "-boldoblique" : "-bolditalic");
|
||||
|
||||
@@ -9,6 +9,7 @@ import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -27,6 +28,7 @@ import stirling.software.common.configuration.InstallationPathConfig;
|
||||
@Slf4j
|
||||
public class SharedSignatureService {
|
||||
|
||||
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private final ObjectMapper objectMapper;
|
||||
@@ -105,7 +107,7 @@ public class SharedSignatureService {
|
||||
throw new IllegalArgumentException("Invalid filename");
|
||||
}
|
||||
// Only allow alphanumeric, hyphen, underscore, and dot (for extensions)
|
||||
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
|
||||
if (!FILENAME_VALIDATION_PATTERN.matcher(fileName).matches()) {
|
||||
throw new IllegalArgumentException("Filename contains invalid characters");
|
||||
}
|
||||
}
|
||||
@@ -113,7 +115,7 @@ public class SharedSignatureService {
|
||||
private String validateAndNormalizeExtension(String extension) {
|
||||
String normalized = extension.toLowerCase().trim();
|
||||
// Whitelist only safe image extensions
|
||||
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
|
||||
if ("png".equals(normalized) || "jpg".equals(normalized) || "jpeg".equals(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported image extension: " + extension);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package stirling.software.SPDF.service.plugin;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.plugins.PluginDescriptor;
|
||||
import stirling.software.common.plugins.PluginLoader;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class PluginService {
|
||||
private final List<PluginDescriptor> plugins;
|
||||
private final Map<String, Path> pluginJarPaths;
|
||||
|
||||
public PluginService() {
|
||||
List<Path> jars = PluginLoader.listPluginJars();
|
||||
Map<String, Path> jarMap = new LinkedHashMap<>();
|
||||
List<PluginDescriptor> descriptors = new java.util.ArrayList<>();
|
||||
|
||||
for (Path jar : jars) {
|
||||
PluginDescriptor descriptor = PluginLoader.loadDescriptor(jar);
|
||||
if (descriptor == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String pluginId = descriptor.getId();
|
||||
if (jarMap.containsKey(pluginId)) {
|
||||
log.warn(
|
||||
"Duplicate plugin id '{}' detected in {}. Keeping first jar at {}",
|
||||
pluginId,
|
||||
jar,
|
||||
jarMap.get(pluginId));
|
||||
continue;
|
||||
}
|
||||
|
||||
descriptors.add(descriptor);
|
||||
jarMap.put(pluginId, jar);
|
||||
}
|
||||
|
||||
this.plugins = Collections.unmodifiableList(descriptors);
|
||||
this.pluginJarPaths = Collections.unmodifiableMap(jarMap);
|
||||
}
|
||||
|
||||
public List<PluginDescriptor> getPlugins() {
|
||||
return plugins;
|
||||
}
|
||||
|
||||
public Optional<Path> getPluginJarPath(String pluginId) {
|
||||
return Optional.ofNullable(pluginJarPaths.get(pluginId));
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ 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,7 +64,10 @@ 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
|
||||
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
|
||||
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.
|
||||
validation: # PDF signature validation settings
|
||||
trust:
|
||||
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
||||
@@ -230,10 +233,12 @@ ui:
|
||||
appNameNavbar: "" # name displayed on the navigation bar
|
||||
logoStyle: classic # Options: 'classic' (default - classic S icon) or 'modern' (minimalist logo)
|
||||
languages: [] # If empty, all languages are enabled. To display only German and Polish ["de_DE", "pl_PL"]. British English is always enabled.
|
||||
defaultHideUnavailableTools: false # Default user preference: hide disabled tools instead of greying them out
|
||||
defaultHideUnavailableConversions: false # Default user preference: hide disabled conversion options instead of greying them out
|
||||
|
||||
endpoints:
|
||||
toRemove: [] # list endpoints to disable (e.g. ['img-to-pdf', 'remove-pages'])
|
||||
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice'])
|
||||
groupsToRemove: [] # list groups to disable (e.g. ['LibreOffice', 'DeveloperTools', 'DeveloperDocs', 'Automation'])
|
||||
|
||||
metrics:
|
||||
enabled: true # 'true' to enable Info APIs (`/api/*`) endpoints, 'false' to disable
|
||||
|
||||
+4
-2
@@ -18,6 +18,7 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -46,6 +47,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
public class ConvertWebsiteToPdfTest {
|
||||
|
||||
private static final Pattern PDF_FILENAME_PATTERN = Pattern.compile("[A-Za-z0-9_]+\\.pdf");
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private RuntimePathConfig runtimePathConfig;
|
||||
|
||||
@@ -142,7 +144,7 @@ public class ConvertWebsiteToPdfTest {
|
||||
|
||||
assertTrue(out.endsWith(".pdf"));
|
||||
// Only A–Z, a–z, 0–9, underscore and dot allowed
|
||||
assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf"));
|
||||
assertTrue(PDF_FILENAME_PATTERN.matcher(out).matches());
|
||||
// no truncation here (source not that long)
|
||||
assertTrue(out.length() <= 54);
|
||||
}
|
||||
@@ -159,7 +161,7 @@ public class ConvertWebsiteToPdfTest {
|
||||
String out = (String) m.invoke(sut, longUrl);
|
||||
|
||||
assertTrue(out.endsWith(".pdf"));
|
||||
assertTrue(out.matches("[A-Za-z0-9_]+\\.pdf"));
|
||||
assertTrue(PDF_FILENAME_PATTERN.matcher(out).matches());
|
||||
// safeName limited to 50 -> total max 54 including '.pdf'
|
||||
assertTrue(out.length() <= 54, "Filename should be truncated to 50 + '.pdf'");
|
||||
}
|
||||
|
||||
+25
-9
@@ -5,6 +5,7 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Method;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentInformation;
|
||||
@@ -26,6 +27,18 @@ import stirling.software.common.util.TempFileManager;
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class StampControllerTest {
|
||||
|
||||
private static final Pattern UUID_HEX_PATTERN = Pattern.compile("[0-9a-f]{8}");
|
||||
private static final Pattern DATE_LITERAL_REGEX =
|
||||
Pattern.compile("@date is \\d{4}-\\d{2}-\\d{2}");
|
||||
private static final Pattern DATE_TIME_MIN_PATTERN =
|
||||
Pattern.compile("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}");
|
||||
private static final Pattern DATE_SLASH_PATTERN = Pattern.compile("\\d{2}/\\d{2}/\\d{4}");
|
||||
private static final Pattern DAY_LABEL_PATTERN = Pattern.compile("Day: \\d{2}");
|
||||
private static final Pattern MONTH_LABEL_PATTERN = Pattern.compile("Month: \\d{2}");
|
||||
private static final Pattern DATE_TIME_FULL_PATTERN =
|
||||
Pattern.compile("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}");
|
||||
private static final Pattern TIME_LABEL_PATTERN = Pattern.compile("Time: \\d{2}:\\d{2}:\\d{2}");
|
||||
private static final Pattern DATE_LABEL_PATTERN = Pattern.compile("Date: \\d{4}-\\d{2}-\\d{2}");
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private TempFileManager tempFileManager;
|
||||
|
||||
@@ -173,7 +186,7 @@ class StampControllerTest {
|
||||
void testDateReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Date: @date", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("Date: \\d{4}-\\d{2}-\\d{2}"),
|
||||
DATE_LABEL_PATTERN.matcher(result).matches(),
|
||||
"Date should match YYYY-MM-DD format");
|
||||
}
|
||||
|
||||
@@ -182,7 +195,7 @@ class StampControllerTest {
|
||||
void testTimeReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Time: @time", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("Time: \\d{2}:\\d{2}:\\d{2}"),
|
||||
TIME_LABEL_PATTERN.matcher(result).matches(),
|
||||
"Time should match HH:mm:ss format");
|
||||
}
|
||||
|
||||
@@ -192,7 +205,7 @@ class StampControllerTest {
|
||||
String result = invokeProcessStampText("@datetime", 1, 1, "test.pdf", null);
|
||||
// DateTime format: YYYY-MM-DD HH:mm:ss
|
||||
assertTrue(
|
||||
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"),
|
||||
DATE_TIME_FULL_PATTERN.matcher(result).matches(),
|
||||
"DateTime should match YYYY-MM-DD HH:mm:ss format");
|
||||
}
|
||||
|
||||
@@ -208,14 +221,15 @@ class StampControllerTest {
|
||||
@DisplayName("Should replace @month with zero-padded month")
|
||||
void testMonthReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Month: @month", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("Month: \\d{2}"), "Month should be zero-padded");
|
||||
assertTrue(
|
||||
MONTH_LABEL_PATTERN.matcher(result).matches(), "Month should be zero-padded");
|
||||
}
|
||||
|
||||
@Test
|
||||
@DisplayName("Should replace @day with zero-padded day")
|
||||
void testDayReplacement() throws Exception {
|
||||
String result = invokeProcessStampText("Day: @day", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("Day: \\d{2}"), "Day should be zero-padded");
|
||||
assertTrue(DAY_LABEL_PATTERN.matcher(result).matches(), "Day should be zero-padded");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -228,7 +242,7 @@ class StampControllerTest {
|
||||
void testCustomDateFormatSlash() throws Exception {
|
||||
String result = invokeProcessStampText("@date{dd/MM/yyyy}", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("\\d{2}/\\d{2}/\\d{4}"),
|
||||
DATE_SLASH_PATTERN.matcher(result).matches(),
|
||||
"Should match dd/MM/yyyy format: " + result);
|
||||
}
|
||||
|
||||
@@ -238,7 +252,7 @@ class StampControllerTest {
|
||||
String result =
|
||||
invokeProcessStampText("@date{yyyy-MM-dd HH:mm}", 1, 1, "test.pdf", null);
|
||||
assertTrue(
|
||||
result.matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}"),
|
||||
DATE_TIME_MIN_PATTERN.matcher(result).matches(),
|
||||
"Should match yyyy-MM-dd HH:mm format: " + result);
|
||||
}
|
||||
|
||||
@@ -345,7 +359,7 @@ class StampControllerTest {
|
||||
// @@date should become @date, and @date should be replaced with actual date
|
||||
assertTrue(result.startsWith("@date is "), "Should start with literal @date");
|
||||
assertTrue(
|
||||
result.matches("@date is \\d{4}-\\d{2}-\\d{2}"),
|
||||
DATE_LITERAL_REGEX.matcher(result).matches(),
|
||||
"Should have date after: " + result);
|
||||
}
|
||||
|
||||
@@ -463,7 +477,9 @@ class StampControllerTest {
|
||||
@DisplayName("UUID should contain only hex characters")
|
||||
void testUuidFormat() throws Exception {
|
||||
String result = invokeProcessStampText("@uuid", 1, 1, "test.pdf", null);
|
||||
assertTrue(result.matches("[0-9a-f]{8}"), "UUID should be 8 hex characters: " + result);
|
||||
assertTrue(
|
||||
UUID_HEX_PATTERN.matcher(result).matches(),
|
||||
"UUID should be 8 hex characters: " + result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+30
@@ -27,6 +27,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.model.FormFieldWithCoordinates;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
@@ -104,12 +105,40 @@ public class FormFillController {
|
||||
|
||||
requirePdf(file);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
|
||||
FormUtils.repairMissingWidgetPageReferences(document);
|
||||
FormUtils.FormFieldExtraction extraction =
|
||||
FormUtils.extractFieldsWithTemplate(document);
|
||||
return ResponseEntity.ok(extraction);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/fields-with-coordinates", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Inspect PDF form fields with widget coordinates",
|
||||
description =
|
||||
"Returns metadata describing each field in the provided PDF form, "
|
||||
+ "including precise widget coordinates for interactive rendering")
|
||||
public ResponseEntity<List<FormFieldWithCoordinates>> listFieldsWithCoordinates(
|
||||
@Parameter(
|
||||
description = "The input PDF file",
|
||||
required = true,
|
||||
content =
|
||||
@Content(
|
||||
mediaType = MediaType.APPLICATION_PDF_VALUE,
|
||||
schema = @Schema(type = "string", format = "binary")))
|
||||
@RequestParam("file")
|
||||
MultipartFile file)
|
||||
throws IOException {
|
||||
|
||||
requirePdf(file);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file, true)) {
|
||||
FormUtils.repairMissingWidgetPageReferences(document);
|
||||
List<FormFieldWithCoordinates> fields =
|
||||
FormUtils.extractFormFieldsWithCoordinates(document);
|
||||
return ResponseEntity.ok(fields);
|
||||
}
|
||||
}
|
||||
|
||||
@PostMapping(value = "/modify-fields", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@Operation(
|
||||
summary = "Modify existing form fields",
|
||||
@@ -215,6 +244,7 @@ public class FormFillController {
|
||||
|
||||
String baseName = buildBaseName(file, suffix);
|
||||
try (PDDocument document = pdfDocumentFactory.load(file)) {
|
||||
FormUtils.repairMissingWidgetPageReferences(document);
|
||||
processor.accept(document);
|
||||
return saveDocument(document, baseName);
|
||||
}
|
||||
|
||||
+10
-3
@@ -2,7 +2,7 @@ package stirling.software.proprietary.security.configuration;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||
@@ -11,15 +11,22 @@ import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CacheConfig {
|
||||
|
||||
@Value("${security.jwt.keyRetentionDays}")
|
||||
private int keyRetentionDays;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@Autowired
|
||||
public CacheConfig(ApplicationProperties applicationProperties) {
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
int keyRetentionDays = applicationProperties.getSecurity().getJwt().getKeyRetentionDays();
|
||||
CaffeineCacheManager cacheManager = new CaffeineCacheManager();
|
||||
cacheManager.setCaffeine(
|
||||
Caffeine.newBuilder()
|
||||
|
||||
+2
-1
@@ -361,7 +361,8 @@ public class SecurityConfiguration {
|
||||
securityProperties.getOauth2(),
|
||||
userService,
|
||||
jwtService,
|
||||
licenseSettingsService))
|
||||
licenseSettingsService,
|
||||
applicationProperties))
|
||||
.failureHandler(new CustomOAuth2AuthenticationFailureHandler())
|
||||
// Add existing Authorities from the database
|
||||
.userInfoEndpoint(
|
||||
|
||||
+208
-10
@@ -26,6 +26,7 @@ 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;
|
||||
@@ -34,12 +35,15 @@ 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
|
||||
@@ -55,7 +59,9 @@ 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
|
||||
@@ -171,16 +177,52 @@ public class AuthController {
|
||||
claims.put("authType", AuthenticationType.WEB.toString());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
|
||||
String token = jwtService.generateToken(user.getUsername(), claims);
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Record successful login
|
||||
loginAttemptService.loginSucceeded(username);
|
||||
log.info("Login successful for user: {} from IP: {}", username, ip);
|
||||
log.info(
|
||||
"Login successful for user: {} from IP: {} (desktop: {})",
|
||||
username,
|
||||
ip,
|
||||
isDesktopClient);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", token, "expires_in", 3600)));
|
||||
"session",
|
||||
Map.of(
|
||||
"access_token",
|
||||
token,
|
||||
"expires_in",
|
||||
getTokenExpirySeconds(isDesktopClient))));
|
||||
|
||||
} catch (UsernameNotFoundException e) {
|
||||
String username = request.getUsername();
|
||||
@@ -272,25 +314,92 @@ public class AuthController {
|
||||
.body(Map.of("error", "No token found"));
|
||||
}
|
||||
|
||||
jwtService.validateToken(token);
|
||||
String username = jwtService.extractUsername(token);
|
||||
// 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"));
|
||||
}
|
||||
|
||||
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||
User user = (User) userDetails;
|
||||
|
||||
Map<String, Object> claims = new HashMap<>();
|
||||
claims.put("authType", user.getAuthenticationType());
|
||||
claims.put("role", user.getRolesAsString());
|
||||
Map<String, Object> newClaims = new HashMap<>();
|
||||
newClaims.put("authType", user.getAuthenticationType());
|
||||
newClaims.put("role", user.getRolesAsString());
|
||||
|
||||
String newToken = jwtService.generateToken(username, claims);
|
||||
// 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
|
||||
|
||||
log.debug("Token refreshed for user: {}", username);
|
||||
|
||||
return ResponseEntity.ok(
|
||||
Map.of(
|
||||
"user", buildUserResponse(user),
|
||||
"session", Map.of("access_token", newToken, "expires_in", 3600)));
|
||||
"session",
|
||||
Map.of(
|
||||
"access_token",
|
||||
newToken,
|
||||
"expires_in",
|
||||
getTokenExpirySeconds(isDesktopClient))));
|
||||
|
||||
} 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)
|
||||
@@ -532,6 +641,95 @@ 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)
|
||||
|
||||
+3
-1
@@ -9,6 +9,7 @@ import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
@@ -36,6 +37,7 @@ import stirling.software.common.configuration.RuntimePathConfig;
|
||||
@RequiredArgsConstructor
|
||||
public class UIDataTessdataController {
|
||||
|
||||
private static final Pattern INVALID_LANG_CHARS_PATTERN = Pattern.compile("[^A-Za-z0-9_+\\-]");
|
||||
private final RuntimePathConfig runtimePathConfig;
|
||||
private static volatile List<String> cachedRemoteTessdata = null;
|
||||
private static volatile long cachedRemoteTessdataExpiry = 0L;
|
||||
@@ -88,7 +90,7 @@ public class UIDataTessdataController {
|
||||
failed.add(language);
|
||||
continue;
|
||||
}
|
||||
String safeLang = language.replaceAll("[^A-Za-z0-9_+\\-]", "");
|
||||
String safeLang = INVALID_LANG_CHARS_PATTERN.matcher(language).replaceAll("");
|
||||
if (!safeLang.equals(language)) {
|
||||
failed.add(language);
|
||||
continue;
|
||||
|
||||
+23
-3
@@ -36,6 +36,7 @@ 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
|
||||
@@ -48,6 +49,7 @@ 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)
|
||||
@@ -150,9 +152,27 @@ public class CustomOAuth2AuthenticationSuccessHandler
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication, Map.of("authType", AuthenticationType.OAUTH2));
|
||||
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);
|
||||
}
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
|
||||
+22
-4
@@ -37,6 +37,7 @@ 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
|
||||
@@ -191,10 +192,27 @@ public class CustomSaml2AuthenticationSuccessHandler
|
||||
|
||||
// Generate JWT if v2 is enabled
|
||||
if (jwtService.isJwtEnabled()) {
|
||||
String jwt =
|
||||
jwtService.generateToken(
|
||||
authentication,
|
||||
Map.of("authType", AuthenticationType.SAML2));
|
||||
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);
|
||||
}
|
||||
|
||||
// Build context-aware redirect URL based on the original request
|
||||
String redirectUrl =
|
||||
|
||||
+137
-23
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -19,6 +20,9 @@ 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;
|
||||
@@ -30,6 +34,8 @@ 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;
|
||||
@@ -38,18 +44,20 @@ import stirling.software.proprietary.security.saml2.CustomSaml2AuthenticatedPrin
|
||||
@Service
|
||||
public class JwtService implements JwtServiceInterface {
|
||||
|
||||
private static final String ISSUER = "https://stirling.com";
|
||||
private static final long EXPIRATION = 43200000;
|
||||
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
|
||||
|
||||
private final KeyPersistenceServiceInterface keyPersistenceService;
|
||||
private final boolean v2Enabled;
|
||||
private final ApplicationProperties.Security securityProperties;
|
||||
|
||||
@Autowired
|
||||
public JwtService(
|
||||
@Qualifier("v2Enabled") boolean v2Enabled,
|
||||
KeyPersistenceServiceInterface keyPersistenceService) {
|
||||
KeyPersistenceServiceInterface keyPersistenceService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.v2Enabled = v2Enabled;
|
||||
this.keyPersistenceService = keyPersistenceService;
|
||||
this.securityProperties = applicationProperties.getSecurity();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -84,9 +92,10 @@ public class JwtService implements JwtServiceInterface {
|
||||
Jwts.builder()
|
||||
.claims(claims)
|
||||
.subject(username)
|
||||
.issuer(ISSUER)
|
||||
.issuer(JwtConstants.ISSUER)
|
||||
.issuedAt(new Date())
|
||||
.expiration(new Date(System.currentTimeMillis() + EXPIRATION))
|
||||
.expiration(
|
||||
new Date(System.currentTimeMillis() + getExpirationMillis()))
|
||||
.signWith(keyPair.getPrivate(), Jwts.SIG.RS256);
|
||||
|
||||
String keyId = activeKey.getKeyId();
|
||||
@@ -100,6 +109,40 @@ 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);
|
||||
@@ -114,12 +157,23 @@ 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());
|
||||
@@ -130,11 +184,21 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
|
||||
private <T> T extractClaim(String token, Function<Claims, T> claimsResolver) {
|
||||
final Claims claims = extractAllClaims(token);
|
||||
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);
|
||||
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;
|
||||
@@ -176,11 +240,12 @@ 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);
|
||||
return tryAllKeys(token, allowExpired);
|
||||
}
|
||||
|
||||
return Jwts.parser()
|
||||
.verifyWith(keyPair.getPublic())
|
||||
.clockSkewSeconds(getAllowedClockSkewSeconds())
|
||||
.build()
|
||||
.parseSignedClaims(token)
|
||||
.getPayload();
|
||||
@@ -191,7 +256,13 @@ public class JwtService implements JwtServiceInterface {
|
||||
log.warn("Invalid token: {}", e.getMessage());
|
||||
throw new AuthenticationFailureException("Invalid token", e);
|
||||
} catch (ExpiredJwtException e) {
|
||||
log.warn("The token has expired: {}", e.getMessage());
|
||||
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());
|
||||
throw new AuthenticationFailureException("The token has expired", e);
|
||||
} catch (UnsupportedJwtException e) {
|
||||
log.warn("The token is unsupported: {}", e.getMessage());
|
||||
@@ -202,7 +273,8 @@ public class JwtService implements JwtServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
private Claims tryAllKeys(String token) throws AuthenticationFailureException {
|
||||
private Claims tryAllKeys(String token, boolean allowExpired)
|
||||
throws AuthenticationFailureException {
|
||||
// First try the active key
|
||||
try {
|
||||
JwtVerificationKey activeKey = keyPersistenceService.getActiveKey();
|
||||
@@ -210,9 +282,18 @@ 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) {
|
||||
@@ -230,9 +311,15 @@ 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) {
|
||||
@@ -266,24 +353,51 @@ 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 {
|
||||
PublicKey signingKey =
|
||||
keyPersistenceService.decodePublicKey(
|
||||
keyPersistenceService.getActiveKey().getVerifyingKey());
|
||||
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;
|
||||
}
|
||||
|
||||
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());
|
||||
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());
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
+28
@@ -25,6 +25,16 @@ 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
|
||||
*
|
||||
@@ -41,6 +51,15 @@ 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
|
||||
*
|
||||
@@ -49,6 +68,15 @@ 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
|
||||
*
|
||||
|
||||
+191
-13
@@ -10,8 +10,10 @@ 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;
|
||||
@@ -41,6 +43,7 @@ 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;
|
||||
@@ -59,19 +62,119 @@ 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();
|
||||
loadKeyPair();
|
||||
loadExistingKeysFromDisk();
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to initialize keystore, using in-memory generation", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void loadKeyPair() {
|
||||
if (activeKey == null) {
|
||||
/**
|
||||
* 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);
|
||||
generateAndStoreKeypair();
|
||||
}
|
||||
}
|
||||
@@ -84,10 +187,11 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
KeyPair keyPair = generateRSAKeypair();
|
||||
String keyId = generateKeyId();
|
||||
|
||||
storePrivateKey(keyId, keyPair.getPrivate());
|
||||
storeKeyPair(keyId, keyPair);
|
||||
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);
|
||||
}
|
||||
@@ -200,16 +304,43 @@ public class KeyPersistenceService implements KeyPersistenceServiceInterface {
|
||||
}
|
||||
}
|
||||
|
||||
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 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());
|
||||
|
||||
// Set read/write to only the owner
|
||||
keyFile.toFile().setReadable(true, true);
|
||||
keyFile.toFile().setWritable(true, true);
|
||||
keyFile.toFile().setExecutable(false, false);
|
||||
// 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());
|
||||
}
|
||||
|
||||
private PrivateKey loadPrivateKey(String keyId)
|
||||
@@ -229,6 +360,53 @@ 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());
|
||||
}
|
||||
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Instant;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
@@ -33,6 +34,7 @@ public class TotpService {
|
||||
private static final String HMAC_ALGORITHM = "HmacSHA1";
|
||||
private static final String DEFAULT_ISSUER = "Stirling PDF";
|
||||
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||
private static final Pattern TOTP_CODE_PATTERN = Pattern.compile("\\d{6}");
|
||||
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
@@ -71,7 +73,7 @@ public class TotpService {
|
||||
}
|
||||
|
||||
String normalizedCode = code.replace(" ", "");
|
||||
if (!normalizedCode.matches("\\d{6}")) {
|
||||
if (!TOTP_CODE_PATTERN.matcher(normalizedCode).matches()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
+82
@@ -0,0 +1,82 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -10,6 +10,7 @@ import java.nio.file.StandardOpenOption;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -32,6 +33,7 @@ import stirling.software.proprietary.model.api.signature.SavedSignatureResponse;
|
||||
@Slf4j
|
||||
public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
|
||||
private static final Pattern FILENAME_VALIDATION_PATTERN = Pattern.compile("^[a-zA-Z0-9_.-]+$");
|
||||
private final String SIGNATURE_BASE_PATH;
|
||||
private final String ALL_USERS_FOLDER = "ALL_USERS";
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
@@ -366,14 +368,14 @@ public class SignatureService implements PersonalSignatureServiceInterface {
|
||||
if (fileName.contains("..") || fileName.contains("/") || fileName.contains("\\")) {
|
||||
throw new IllegalArgumentException("Invalid filename");
|
||||
}
|
||||
if (!fileName.matches("^[a-zA-Z0-9_.-]+$")) {
|
||||
if (!FILENAME_VALIDATION_PATTERN.matcher(fileName).matches()) {
|
||||
throw new IllegalArgumentException("Filename contains invalid characters");
|
||||
}
|
||||
}
|
||||
|
||||
private String validateAndNormalizeExtension(String extension) {
|
||||
String normalized = extension.toLowerCase().trim();
|
||||
if (normalized.equals("png") || normalized.equals("jpg") || normalized.equals("jpeg")) {
|
||||
if ("png".equals(normalized) || "jpg".equals(normalized) || "jpeg".equals(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
throw new IllegalArgumentException("Unsupported image extension: " + extension);
|
||||
|
||||
@@ -5,6 +5,7 @@ import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.IdentityHashMap;
|
||||
@@ -18,6 +19,7 @@ import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import org.apache.pdfbox.cos.COSArray;
|
||||
import org.apache.pdfbox.cos.COSBase;
|
||||
import org.apache.pdfbox.cos.COSDictionary;
|
||||
import org.apache.pdfbox.cos.COSName;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
@@ -46,6 +48,7 @@ import lombok.experimental.UtilityClass;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.model.FormFieldWithCoordinates;
|
||||
import stirling.software.common.util.ApplicationContextProvider;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.RegexPatternUtils;
|
||||
@@ -67,6 +70,13 @@ public class FormUtils {
|
||||
public final Set<String> CHOICE_FIELD_TYPES =
|
||||
Set.of(FIELD_TYPE_COMBOBOX, FIELD_TYPE_LISTBOX, FIELD_TYPE_RADIO);
|
||||
|
||||
/**
|
||||
* Threshold in PDF points for considering two widgets to be on the same line. Fields whose
|
||||
* y-coordinates differ by less than this value are sorted left-to-right by x-coordinate instead
|
||||
* of top-to-bottom.
|
||||
*/
|
||||
private static final float SAME_LINE_THRESHOLD_PT = 10.0f;
|
||||
|
||||
/**
|
||||
* Returns a normalized logical type string for the supplied PDFBox field instance. Centralized
|
||||
* so all callers share identical mapping logic.
|
||||
@@ -109,6 +119,8 @@ public class FormUtils {
|
||||
List<FormFieldInfo> fields = new ArrayList<>();
|
||||
Map<String, Integer> typeCounters = new HashMap<>();
|
||||
Map<Integer, Integer> pageOrderCounters = new HashMap<>();
|
||||
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
|
||||
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
if (!(field instanceof PDTerminalField terminalField)) {
|
||||
continue;
|
||||
@@ -125,7 +137,7 @@ public class FormUtils {
|
||||
|
||||
String currentValue = safeValue(terminalField);
|
||||
boolean required = field.isRequired();
|
||||
int pageIndex = resolveFirstWidgetPageIndex(document, terminalField);
|
||||
int pageIndex = resolveFirstWidgetPageIndex(document, terminalField, annotationPageMap);
|
||||
List<String> options = resolveOptions(terminalField);
|
||||
String tooltip = resolveTooltip(terminalField);
|
||||
int typeIndex = typeCounters.merge(type, 1, Integer::sum);
|
||||
@@ -164,6 +176,396 @@ public class FormUtils {
|
||||
return Collections.unmodifiableList(fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract form fields with widget coordinates for the interactive form viewer.
|
||||
*
|
||||
* @param document PDF document
|
||||
* @return List of form fields with coordinates and metadata
|
||||
*/
|
||||
public List<FormFieldWithCoordinates> extractFormFieldsWithCoordinates(PDDocument document) {
|
||||
if (document == null) return List.of();
|
||||
|
||||
PDAcroForm acroForm = getAcroFormSafely(document);
|
||||
if (acroForm == null) return List.of();
|
||||
|
||||
List<FormFieldWithCoordinates> fields = new ArrayList<>();
|
||||
Map<String, Integer> typeCounters = new HashMap<>();
|
||||
|
||||
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
|
||||
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
if (!(field instanceof PDTerminalField terminalField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String type = detectFieldType(terminalField);
|
||||
String name =
|
||||
Optional.ofNullable(field.getFullyQualifiedName())
|
||||
.orElseGet(field::getPartialName);
|
||||
if (name == null || name.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String currentValue = safeValue(terminalField);
|
||||
boolean required = field.isRequired();
|
||||
boolean readOnly = field.isReadOnly();
|
||||
List<String> options = resolveOptions(terminalField);
|
||||
List<String> displayOptions = resolveDisplayOptions(terminalField);
|
||||
String tooltip = resolveTooltip(terminalField);
|
||||
int typeIndex = typeCounters.merge(type, 1, Integer::sum);
|
||||
String displayLabel =
|
||||
deriveDisplayLabel(field, name, tooltip, type, typeIndex, options);
|
||||
boolean multiSelect = resolveMultiSelect(terminalField);
|
||||
boolean multiline =
|
||||
terminalField instanceof PDTextField
|
||||
&& ((PDTextField) terminalField).isMultiline();
|
||||
|
||||
// Extract widget coordinates
|
||||
List<FormFieldWithCoordinates.WidgetCoordinates> widgets =
|
||||
extractWidgetCoordinates(document, terminalField, annotationPageMap);
|
||||
|
||||
// Only include displayOptions when they differ from export options
|
||||
List<String> displayOptsToSend = null;
|
||||
if (displayOptions != null
|
||||
&& !displayOptions.isEmpty()
|
||||
&& !displayOptions.equals(options)) {
|
||||
displayOptsToSend = displayOptions;
|
||||
}
|
||||
|
||||
fields.add(
|
||||
FormFieldWithCoordinates.builder()
|
||||
.name(name)
|
||||
.label(displayLabel)
|
||||
.type(type)
|
||||
.value(currentValue)
|
||||
.options(options.isEmpty() ? null : options)
|
||||
.displayOptions(displayOptsToSend)
|
||||
.required(required)
|
||||
.readOnly(readOnly)
|
||||
.multiSelect(multiSelect)
|
||||
.multiline(multiline)
|
||||
.tooltip(tooltip)
|
||||
.widgets(widgets.isEmpty() ? null : widgets)
|
||||
.build());
|
||||
}
|
||||
|
||||
// Sort by page and position
|
||||
fields.sort(new FieldCoordinateComparator());
|
||||
|
||||
log.debug("Total fields processed: {}", fields.size());
|
||||
log.debug(
|
||||
"Fields WITH widgets: {}",
|
||||
fields.stream()
|
||||
.filter(f -> f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
.count());
|
||||
log.debug(
|
||||
"Fields WITHOUT widgets: {}",
|
||||
fields.stream()
|
||||
.filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty())
|
||||
.count());
|
||||
|
||||
fields.stream()
|
||||
.filter(f -> f.getWidgets() == null || f.getWidgets().isEmpty())
|
||||
.forEach(
|
||||
f ->
|
||||
log.debug(
|
||||
"Field '{}' type={} has NO widget coordinates",
|
||||
f.getName(),
|
||||
f.getType()));
|
||||
|
||||
return Collections.unmodifiableList(fields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract widget coordinates for a form field.
|
||||
*
|
||||
* @param document PDF document
|
||||
* @param field Terminal field
|
||||
* @return List of widget coordinates
|
||||
*/
|
||||
private List<FormFieldWithCoordinates.WidgetCoordinates> extractWidgetCoordinates(
|
||||
PDDocument document,
|
||||
PDTerminalField field,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
List<FormFieldWithCoordinates.WidgetCoordinates> result = new ArrayList<>();
|
||||
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
|
||||
log.debug(
|
||||
"Field '{}' type={} has {} widgets",
|
||||
field.getFullyQualifiedName(),
|
||||
field.getClass().getSimpleName(),
|
||||
widgets != null ? widgets.size() : 0);
|
||||
|
||||
if (widgets == null || widgets.isEmpty()) {
|
||||
// Some fields (especially text fields) might be their own widget annotation
|
||||
log.trace(
|
||||
"Field '{}' has no widgets, checking if field acts as its own annotation",
|
||||
field.getFullyQualifiedName());
|
||||
try {
|
||||
COSDictionary fieldDict = field.getCOSObject();
|
||||
COSBase rectBase = fieldDict.getDictionaryObject(COSName.RECT);
|
||||
if (rectBase instanceof COSArray rectArray) {
|
||||
int pageIndex =
|
||||
findPageIndexForAnnotation(document, fieldDict, annotationPageMap);
|
||||
if (pageIndex >= 0) {
|
||||
PDRectangle rectangle = new PDRectangle(rectArray);
|
||||
result.add(
|
||||
createWidgetCoordinates(
|
||||
document, rectangle, pageIndex, null, field));
|
||||
} else {
|
||||
log.warn(
|
||||
"Found rectangle for field '{}' but could not resolve page index",
|
||||
field.getFullyQualifiedName());
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Could not extract direct rectangle for field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// For radio buttons, pre-resolve export values per widget
|
||||
List<String> exportValues = null;
|
||||
if (field instanceof PDRadioButton radio) {
|
||||
exportValues = radio.getExportValues();
|
||||
}
|
||||
|
||||
for (int i = 0; i < widgets.size(); i++) {
|
||||
PDAnnotationWidget widget = widgets.get(i);
|
||||
try {
|
||||
PDRectangle rectangle = widget.getRectangle();
|
||||
if (rectangle == null) {
|
||||
log.warn(
|
||||
"Field '{}' widget {} has NULL rectangle",
|
||||
field.getFullyQualifiedName(),
|
||||
i);
|
||||
continue;
|
||||
}
|
||||
|
||||
int pageIndex = resolveWidgetPageIndex(document, widget, annotationPageMap);
|
||||
if (pageIndex < 0) {
|
||||
log.warn(
|
||||
"Field '{}' widget {} could not resolve page index",
|
||||
field.getFullyQualifiedName(),
|
||||
i);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Resolve export value for radio/checkbox widgets
|
||||
String exportValue = null;
|
||||
if (exportValues != null && i < exportValues.size()) {
|
||||
exportValue = exportValues.get(i);
|
||||
} else if (field instanceof PDButton) {
|
||||
// Fall back to appearance state name from the widget's normal appearance
|
||||
try {
|
||||
var ap = widget.getAppearance();
|
||||
if (ap != null && ap.getNormalAppearance() != null) {
|
||||
var normalAp = ap.getNormalAppearance();
|
||||
if (normalAp.isSubDictionary()) {
|
||||
for (var cosName : normalAp.getSubDictionary().keySet()) {
|
||||
String key = cosName.getName();
|
||||
if (!"Off".equals(key)) {
|
||||
exportValue = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace(
|
||||
"Could not extract export value for widget in '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
result.add(
|
||||
createWidgetCoordinates(
|
||||
document, rectangle, pageIndex, exportValue, field));
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to extract coordinates for widget in field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private FormFieldWithCoordinates.WidgetCoordinates createWidgetCoordinates(
|
||||
PDDocument document,
|
||||
PDRectangle rectangle,
|
||||
int pageIndex,
|
||||
String exportValue,
|
||||
PDTerminalField field) {
|
||||
if (pageIndex < 0 || pageIndex >= document.getNumberOfPages()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
PDPage page = document.getPage(pageIndex);
|
||||
PDRectangle cropBox = page.getCropBox();
|
||||
|
||||
// Use CropBox dimensions for the y-flip.
|
||||
// Note: getWidth() and getHeight() return dimensions BEFORE rotation.
|
||||
float cropHeight = cropBox.getHeight();
|
||||
|
||||
// Get absolute widget coordinates (in MediaBox space, un-rotated)
|
||||
float pdfX = rectangle.getLowerLeftX();
|
||||
float pdfY = rectangle.getLowerLeftY();
|
||||
float width = rectangle.getWidth();
|
||||
float height = rectangle.getHeight();
|
||||
|
||||
// Adjust relative to CropBox origin
|
||||
float relativeX = pdfX - cropBox.getLowerLeftX();
|
||||
float relativeY = pdfY - cropBox.getLowerLeftY();
|
||||
|
||||
// Convert from PDF lower-left origin to CSS upper-left origin (y-flip).
|
||||
// Widget /Rect coordinates are always in un-rotated PDF user space.
|
||||
// The embedpdf viewer wraps all page content inside a <Rotate> CSS
|
||||
// component that handles visual rotation — we must NOT apply any
|
||||
// rotation transform here, or widgets would be double-rotated.
|
||||
float finalX = relativeX;
|
||||
float finalY = cropHeight - relativeY - height;
|
||||
float finalW = width;
|
||||
float finalH = height;
|
||||
|
||||
// Validate coordinates are within reasonable bounds
|
||||
if (finalX < -1.0f
|
||||
|| finalY < -1.0f
|
||||
|| finalX > cropBox.getWidth() * 2 // Allow some horizontal overflow
|
||||
|| finalY > cropHeight + 1.0f) {
|
||||
log.warn(
|
||||
"Widget coordinates out of bounds for field '{}': page={}, x={}, y={}, w={}, h={}",
|
||||
field.getFullyQualifiedName(),
|
||||
pageIndex,
|
||||
finalX,
|
||||
finalY,
|
||||
finalW,
|
||||
finalH);
|
||||
return null;
|
||||
}
|
||||
|
||||
return FormFieldWithCoordinates.WidgetCoordinates.builder()
|
||||
.pageIndex(pageIndex)
|
||||
.x(finalX)
|
||||
.y(finalY)
|
||||
.width(finalW)
|
||||
.height(finalH)
|
||||
.exportValue(exportValue)
|
||||
.fontSize(extractFontSize(field))
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs widgets with missing page references by scanning all pages and setting the /P entry
|
||||
* for orphan widgets.
|
||||
*
|
||||
* <p>This should be called BEFORE extracting form field coordinates.
|
||||
*
|
||||
* @param document PDF document to repair
|
||||
*/
|
||||
public void repairMissingWidgetPageReferences(PDDocument document) {
|
||||
try {
|
||||
PDAcroForm acroForm = getAcroFormSafely(document);
|
||||
if (acroForm == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug("Checking for widgets with missing page references...");
|
||||
int repairedCount = 0;
|
||||
|
||||
Map<COSDictionary, Integer> annotationPageMap = buildAnnotationPageMap(document);
|
||||
|
||||
for (PDField field : acroForm.getFieldTree()) {
|
||||
if (!(field instanceof PDTerminalField terminalField)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
List<PDAnnotationWidget> widgets = terminalField.getWidgets();
|
||||
|
||||
if (widgets == null || widgets.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (PDAnnotationWidget widget : widgets) {
|
||||
if (widget.getPage() == null) {
|
||||
Integer pageIndex = annotationPageMap.get(widget.getCOSObject());
|
||||
if (pageIndex != null && pageIndex >= 0) {
|
||||
PDPage foundPage = document.getPage(pageIndex);
|
||||
widget.setPage(foundPage);
|
||||
repairedCount++;
|
||||
log.debug(
|
||||
"Repaired widget for field '{}' - set page reference via map",
|
||||
field.getFullyQualifiedName());
|
||||
} else {
|
||||
log.warn(
|
||||
"Could not find page for widget in field '{}'",
|
||||
field.getFullyQualifiedName());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (repairedCount > 0) {
|
||||
log.debug(
|
||||
"Successfully repaired {} widgets with missing page references",
|
||||
repairedCount);
|
||||
} else {
|
||||
log.debug("No widgets needed repair");
|
||||
}
|
||||
|
||||
} catch (Exception e) {
|
||||
log.error("Error repairing widget page references: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private int findPageIndexForAnnotation(
|
||||
PDDocument document,
|
||||
COSDictionary annotDict,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
try {
|
||||
// Method 0: Check the pre-built lookup map (fastest)
|
||||
if (annotationPageMap != null) {
|
||||
Integer idx = annotationPageMap.get(annotDict);
|
||||
if (idx != null) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
|
||||
// Method 1: Check the /P entry if it points to a page
|
||||
COSBase base = annotDict.getDictionaryObject(COSName.P);
|
||||
COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null;
|
||||
if (pageDict != null) {
|
||||
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||
if (document.getPage(i).getCOSObject() == pageDict) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: Fallback search through all pages' annotations
|
||||
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||
PDPage page = document.getPage(i);
|
||||
List<PDAnnotation> annotations = page.getAnnotations();
|
||||
if (annotations != null) {
|
||||
for (PDAnnotation annot : annotations) {
|
||||
if (annot != null && annot.getCOSObject() == annotDict) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace("Error finding page for annotation: {}", e.getMessage());
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a single record object (field-name -> value placeholder) that can be directly submitted
|
||||
* to /api/v1/form/fill as the 'data' JSON. For checkboxes a boolean false is supplied unless
|
||||
@@ -312,7 +714,24 @@ public class FormUtils {
|
||||
return;
|
||||
}
|
||||
|
||||
flattenViaRendering(document, acroForm);
|
||||
if (acroForm == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Use PDFBox's built-in field flattening which bakes form field values
|
||||
// into the page content stream as static text/graphics, removing the
|
||||
// interactive form structure but preserving all other document content
|
||||
// (images, text, annotations, etc.) at full quality.
|
||||
try {
|
||||
ensureAppearances(acroForm);
|
||||
acroForm.flatten();
|
||||
} catch (Exception e) {
|
||||
log.warn(
|
||||
"PDFBox acroForm.flatten() failed, falling back to rendering: {}",
|
||||
e.getMessage(),
|
||||
e);
|
||||
flattenViaRendering(document, acroForm);
|
||||
}
|
||||
}
|
||||
|
||||
private void rebuildDocumentFromImages(PDDocument document, PDFRenderer renderer, int dpi)
|
||||
@@ -385,7 +804,7 @@ public class FormUtils {
|
||||
|
||||
PDPage page = widget.getPage();
|
||||
if (page == null) {
|
||||
page = resolveWidgetPage(document, widget);
|
||||
page = resolveWidgetPage(document, widget, null);
|
||||
if (page != null) {
|
||||
widget.setPage(page);
|
||||
}
|
||||
@@ -820,6 +1239,16 @@ public class FormUtils {
|
||||
|
||||
private String safeValue(PDTerminalField field) {
|
||||
try {
|
||||
// PDChoice.getValueAsString() returns a raw COS string representation
|
||||
// that doesn't reliably reflect the selected value. Use getValue()
|
||||
// which returns the proper List<String> of selected options.
|
||||
if (field instanceof PDChoice choiceField) {
|
||||
List<String> selected = choiceField.getValue();
|
||||
if (selected == null || selected.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
return String.join(",", selected);
|
||||
}
|
||||
return field.getValueAsString();
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
@@ -833,14 +1262,25 @@ public class FormUtils {
|
||||
List<String> resolveOptions(PDTerminalField field) {
|
||||
try {
|
||||
if (field instanceof PDChoice choice) {
|
||||
List<String> display = choice.getOptionsDisplayValues();
|
||||
if (display != null && !display.isEmpty()) {
|
||||
return new ArrayList<>(display);
|
||||
}
|
||||
LinkedHashSet<String> allowed = new LinkedHashSet<>();
|
||||
List<String> exportValues = choice.getOptionsExportValues();
|
||||
if (exportValues != null && !exportValues.isEmpty()) {
|
||||
return new ArrayList<>(exportValues);
|
||||
List<String> displayValues = choice.getOptionsDisplayValues();
|
||||
|
||||
if (exportValues != null) {
|
||||
exportValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
}
|
||||
if (displayValues != null) {
|
||||
displayValues.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.forEach(allowed::add);
|
||||
}
|
||||
return new ArrayList<>(allowed);
|
||||
} else if (field instanceof PDRadioButton radio) {
|
||||
List<String> exports = radio.getExportValues();
|
||||
if (exports != null && !exports.isEmpty()) {
|
||||
@@ -861,6 +1301,29 @@ public class FormUtils {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the display-value labels for a choice field's options. For radio / checkbox this
|
||||
* returns an empty list (no separate display values). For PDChoice fields, if the PDF provides
|
||||
* distinct display values, those are returned; otherwise an empty list (indicating that the
|
||||
* export values from {@link #resolveOptions} should be shown directly).
|
||||
*/
|
||||
List<String> resolveDisplayOptions(PDTerminalField field) {
|
||||
try {
|
||||
if (field instanceof PDChoice choice) {
|
||||
List<String> display = choice.getOptionsDisplayValues();
|
||||
if (display != null && !display.isEmpty()) {
|
||||
return new ArrayList<>(display);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to resolve display options for field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
return Collections.emptyList();
|
||||
}
|
||||
|
||||
private boolean resolveMultiSelect(PDTerminalField field) {
|
||||
if (field instanceof PDListBox listBox) {
|
||||
try {
|
||||
@@ -875,6 +1338,44 @@ public class FormUtils {
|
||||
return false;
|
||||
}
|
||||
|
||||
private Float extractFontSize(PDTerminalField field) {
|
||||
try {
|
||||
String da = null;
|
||||
if (field instanceof PDVariableText vt) {
|
||||
da = vt.getDefaultAppearance();
|
||||
}
|
||||
|
||||
if (da == null || da.isBlank()) {
|
||||
// Check parent/acroform default appearance if field's is missing
|
||||
PDAcroForm form = field.getAcroForm();
|
||||
if (form != null) {
|
||||
da = form.getDefaultAppearance();
|
||||
}
|
||||
}
|
||||
|
||||
if (da != null && !da.isBlank()) {
|
||||
// Standard DA looks like: /Helv 12 Tf 0 g
|
||||
// We want the number before 'Tf'
|
||||
String[] tokens = da.split("\\s+");
|
||||
for (int i = 0; i < tokens.length; i++) {
|
||||
if ("Tf".equals(tokens[i]) && i > 0) {
|
||||
try {
|
||||
float size = Float.parseFloat(tokens[i - 1]);
|
||||
return size > 0 ? size : null;
|
||||
} catch (NumberFormatException ignored) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.trace(
|
||||
"Could not extract font size for field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private boolean isSettableCheckBoxState(String state) {
|
||||
if (state == null) return false;
|
||||
String trimmed = state.trim();
|
||||
@@ -952,6 +1453,13 @@ public class FormUtils {
|
||||
|
||||
if (simplified.isEmpty()) return true;
|
||||
|
||||
// Detect UUID-like hex strings (e.g. "cdc47b7041524571 7b2d93017fe77bf7")
|
||||
// Standard UUIDs are 32 hex characters; require at least that to avoid
|
||||
// false positives on short hex-like field names.
|
||||
String nospaces = simplified.replaceAll("\\s+", "");
|
||||
if (nospaces.length() >= 32 && nospaces.matches("^[0-9a-fA-F]{8}[0-9a-fA-F]{24,}$"))
|
||||
return true;
|
||||
|
||||
return patterns.getGenericFieldNamePattern().matcher(simplified).matches()
|
||||
|| patterns.getSimpleFormFieldPattern().matcher(simplified).matches()
|
||||
|| patterns.getOptionalTNumericPattern().matcher(simplified).matches();
|
||||
@@ -1007,7 +1515,7 @@ public class FormUtils {
|
||||
|
||||
PDAnnotationWidget widget = widgets.get(0);
|
||||
PDRectangle originalRectangle = cloneRectangle(widget.getRectangle());
|
||||
PDPage page = resolveWidgetPage(document, widget);
|
||||
PDPage page = resolveWidgetPage(document, widget, null);
|
||||
if (page == null || originalRectangle == null) {
|
||||
log.warn(
|
||||
"Unable to resolve widget page or rectangle for '{}'; skipping",
|
||||
@@ -1064,7 +1572,7 @@ public class FormUtils {
|
||||
desiredName,
|
||||
modification.label(),
|
||||
resolvedType,
|
||||
determineWidgetPageIndex(document, widget),
|
||||
determineWidgetPageIndex(document, widget, null),
|
||||
originalRectangle.getLowerLeftX(),
|
||||
originalRectangle.getLowerLeftY(),
|
||||
originalRectangle.getWidth(),
|
||||
@@ -1205,59 +1713,43 @@ public class FormUtils {
|
||||
return null;
|
||||
}
|
||||
|
||||
private int resolveFirstWidgetPageIndex(PDDocument document, PDTerminalField field) {
|
||||
private int resolveFirstWidgetPageIndex(
|
||||
PDDocument document,
|
||||
PDTerminalField field,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets == null || widgets.isEmpty()) {
|
||||
return -1;
|
||||
}
|
||||
Map<PDAnnotationWidget, Integer> widgetPageFallbacks = null;
|
||||
for (PDAnnotationWidget widget : widgets) {
|
||||
int idx = resolveWidgetPageIndex(document, widget);
|
||||
int idx = resolveWidgetPageIndex(document, widget, annotationPageMap);
|
||||
if (idx >= 0) {
|
||||
return idx;
|
||||
}
|
||||
try {
|
||||
COSDictionary widgetDictionary = widget.getCOSObject();
|
||||
if (widgetDictionary != null
|
||||
&& widgetDictionary.getDictionaryObject(COSName.P) == null) {
|
||||
if (widgetPageFallbacks == null) {
|
||||
widgetPageFallbacks = buildWidgetPageFallbackMap(document);
|
||||
}
|
||||
Integer fallbackIndex = widgetPageFallbacks.get(widget);
|
||||
if (fallbackIndex != null && fallbackIndex >= 0) {
|
||||
return fallbackIndex;
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to inspect widget page reference for field '{}': {}",
|
||||
field.getFullyQualifiedName(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private int resolveWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) {
|
||||
private int resolveWidgetPageIndex(
|
||||
PDDocument document,
|
||||
PDAnnotationWidget widget,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
if (document == null || widget == null) {
|
||||
return -1;
|
||||
}
|
||||
try {
|
||||
COSDictionary widgetDictionary = widget.getCOSObject();
|
||||
if (widgetDictionary != null
|
||||
&& widgetDictionary.getDictionaryObject(COSName.P) == null) {
|
||||
Map<PDAnnotationWidget, Integer> fallback = buildWidgetPageFallbackMap(document);
|
||||
Integer index = fallback.get(widget);
|
||||
if (index != null) {
|
||||
return index;
|
||||
}
|
||||
|
||||
// Method 0: Check the pre-built lookup map (fastest)
|
||||
if (annotationPageMap != null) {
|
||||
Integer idx = annotationPageMap.get(widget.getCOSObject());
|
||||
if (idx != null) {
|
||||
return idx;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Widget page lookup via fallback map failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
PDPage page = widget.getPage();
|
||||
if (page != null) {
|
||||
// indexOf is O(N), still slower than map but better than scanning annotations
|
||||
int idx = document.getPages().indexOf(page);
|
||||
if (idx >= 0) {
|
||||
return idx;
|
||||
@@ -1267,14 +1759,36 @@ public class FormUtils {
|
||||
log.debug("Widget page lookup failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// Method 1: Check the /P entry if it points to a page
|
||||
try {
|
||||
COSDictionary widgetDictionary = widget.getCOSObject();
|
||||
if (widgetDictionary != null) {
|
||||
COSBase base = widgetDictionary.getDictionaryObject(COSName.P);
|
||||
COSDictionary pageDict = (base instanceof COSDictionary c) ? c : null;
|
||||
if (pageDict != null) {
|
||||
for (int i = 0; i < document.getNumberOfPages(); i++) {
|
||||
if (document.getPage(i).getCOSObject() == pageDict) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Widget page lookup via /P entry failed: {}", e.getMessage());
|
||||
}
|
||||
|
||||
// Method 2: Fallback search through all pages' annotations
|
||||
int pageCount = document.getNumberOfPages();
|
||||
COSDictionary widgetDict = widget.getCOSObject();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
try {
|
||||
PDPage candidate = document.getPage(i);
|
||||
List<PDAnnotation> annotations = candidate.getAnnotations();
|
||||
for (PDAnnotation annotation : annotations) {
|
||||
if (annotation == widget) {
|
||||
return i;
|
||||
if (annotations != null) {
|
||||
for (PDAnnotation annot : annotations) {
|
||||
if (annot != null && annot.getCOSObject() == widgetDict) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
@@ -1317,7 +1831,7 @@ public class FormUtils {
|
||||
List<PDAnnotationWidget> widgets = field.getWidgets();
|
||||
if (widgets != null) {
|
||||
for (PDAnnotationWidget widget : widgets) {
|
||||
PDPage page = resolveWidgetPage(document, widget);
|
||||
PDPage page = resolveWidgetPage(document, widget, null);
|
||||
if (page != null) {
|
||||
page.getAnnotations().remove(widget);
|
||||
}
|
||||
@@ -1437,7 +1951,10 @@ public class FormUtils {
|
||||
rectangle.getHeight());
|
||||
}
|
||||
|
||||
private PDPage resolveWidgetPage(PDDocument document, PDAnnotationWidget widget) {
|
||||
private PDPage resolveWidgetPage(
|
||||
PDDocument document,
|
||||
PDAnnotationWidget widget,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
if (widget == null) {
|
||||
return null;
|
||||
}
|
||||
@@ -1445,7 +1962,7 @@ public class FormUtils {
|
||||
if (page != null) {
|
||||
return page;
|
||||
}
|
||||
int pageIndex = determineWidgetPageIndex(document, widget);
|
||||
int pageIndex = determineWidgetPageIndex(document, widget, annotationPageMap);
|
||||
if (pageIndex >= 0) {
|
||||
try {
|
||||
return document.getPage(pageIndex);
|
||||
@@ -1456,11 +1973,21 @@ public class FormUtils {
|
||||
return null;
|
||||
}
|
||||
|
||||
private int determineWidgetPageIndex(PDDocument document, PDAnnotationWidget widget) {
|
||||
private int determineWidgetPageIndex(
|
||||
PDDocument document,
|
||||
PDAnnotationWidget widget,
|
||||
Map<COSDictionary, Integer> annotationPageMap) {
|
||||
if (document == null || widget == null) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (annotationPageMap != null) {
|
||||
Integer idx = annotationPageMap.get(widget.getCOSObject());
|
||||
if (idx != null) {
|
||||
return idx;
|
||||
}
|
||||
}
|
||||
|
||||
PDPage directPage = widget.getPage();
|
||||
if (directPage != null) {
|
||||
int index = 0;
|
||||
@@ -1488,6 +2015,33 @@ public class FormUtils {
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a map of annotation COS dictionaries to their respective page index. Scan once
|
||||
* per-document to avoid O(N^2) lookups during field extraction.
|
||||
*/
|
||||
public Map<COSDictionary, Integer> buildAnnotationPageMap(PDDocument document) {
|
||||
if (document == null) {
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
|
||||
Map<COSDictionary, Integer> map = new HashMap<>();
|
||||
int pageCount = document.getNumberOfPages();
|
||||
for (int i = 0; i < pageCount; i++) {
|
||||
try {
|
||||
PDPage page = document.getPage(i);
|
||||
List<PDAnnotation> annotations = page.getAnnotations();
|
||||
for (PDAnnotation annot : annotations) {
|
||||
if (annot != null) {
|
||||
map.putIfAbsent(annot.getCOSObject(), i);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to index annotations for page {}: {}", i, e.getMessage());
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private Map<PDAnnotationWidget, Integer> buildWidgetPageFallbackMap(PDDocument document) {
|
||||
if (document == null) {
|
||||
return Collections.emptyMap();
|
||||
@@ -1760,4 +2314,46 @@ public class FormUtils {
|
||||
boolean multiSelect,
|
||||
String tooltip,
|
||||
int pageOrder) {}
|
||||
|
||||
/**
|
||||
* Comparator for sorting form fields by page, then vertically (top-to-bottom), then
|
||||
* horizontally (left-to-right) for fields on approximately the same line.
|
||||
*/
|
||||
static final class FieldCoordinateComparator implements Comparator<FormFieldWithCoordinates> {
|
||||
|
||||
private static int firstWidgetPageIndex(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().get(0).getPageIndex()
|
||||
: -1;
|
||||
}
|
||||
|
||||
private static float firstWidgetY(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().get(0).getY()
|
||||
: 0;
|
||||
}
|
||||
|
||||
private static float firstWidgetX(FormFieldWithCoordinates f) {
|
||||
return (f.getWidgets() != null && !f.getWidgets().isEmpty())
|
||||
? f.getWidgets().get(0).getX()
|
||||
: 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int compare(FormFieldWithCoordinates a, FormFieldWithCoordinates b) {
|
||||
int pageA = firstWidgetPageIndex(a);
|
||||
int pageB = firstWidgetPageIndex(b);
|
||||
int pageCompare = Integer.compare(pageA, pageB);
|
||||
if (pageCompare != 0) return pageCompare;
|
||||
|
||||
float yA = firstWidgetY(a);
|
||||
float yB = firstWidgetY(b);
|
||||
|
||||
// Fields on approximately the same line should be sorted left-to-right
|
||||
if (Math.abs(yA - yB) < SAME_LINE_THRESHOLD_PT) {
|
||||
return Float.compare(firstWidgetX(a), firstWidgetX(b));
|
||||
}
|
||||
return Float.compare(yA, yB);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+86
-3
@@ -10,6 +10,8 @@ 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;
|
||||
|
||||
@@ -36,6 +38,7 @@ 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;
|
||||
|
||||
@@ -53,11 +56,17 @@ 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(
|
||||
@@ -67,7 +76,9 @@ class AuthControllerLoginTest {
|
||||
loginAttemptService,
|
||||
mfaService,
|
||||
totpService,
|
||||
securityProperties);
|
||||
refreshRateLimitService,
|
||||
securityProperties,
|
||||
applicationProperties);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller).build();
|
||||
}
|
||||
|
||||
@@ -175,7 +186,11 @@ class AuthControllerLoginTest {
|
||||
void refreshReturnsNewTokenWhenValid() throws Exception {
|
||||
User user = buildUser();
|
||||
when(jwtService.extractToken(any())).thenReturn("old");
|
||||
when(jwtService.extractUsername("old")).thenReturn("user@example.com");
|
||||
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(userDetailsService.loadUserByUsername("user@example.com")).thenReturn(user);
|
||||
when(jwtService.generateToken(eq("user@example.com"), any(Map.class)))
|
||||
.thenReturn("new-token");
|
||||
@@ -184,7 +199,75 @@ class AuthControllerLoginTest {
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(jsonPath("$.user").exists())
|
||||
.andExpect(jsonPath("$.session.access_token").value("new-token"))
|
||||
.andExpect(jsonPath("$.session.expires_in").value(3600));
|
||||
.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());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+7
-1
@@ -37,13 +37,19 @@ 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);
|
||||
licenseSettingsService,
|
||||
applicationProperties);
|
||||
|
||||
when(userService.usernameExistsIgnoreCase("user")).thenReturn(false);
|
||||
when(licenseSettingsService.isOAuthEligible(null)).thenReturn(true);
|
||||
|
||||
+3
-15
@@ -31,6 +31,7 @@ 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;
|
||||
@@ -64,7 +65,8 @@ class JwtServiceTest {
|
||||
Base64.getEncoder().encodeToString(testKeyPair.getPublic().getEncoded());
|
||||
testVerificationKey = new JwtVerificationKey("test-key-id", encodedPublicKey);
|
||||
|
||||
jwtService = new JwtService(true, keystoreService);
|
||||
ApplicationProperties applicationProperties = new ApplicationProperties();
|
||||
jwtService = new JwtService(true, keystoreService, applicationProperties);
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -73,8 +75,6 @@ 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,8 +94,6 @@ 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);
|
||||
|
||||
@@ -114,8 +112,6 @@ 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");
|
||||
|
||||
@@ -179,8 +175,6 @@ 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);
|
||||
|
||||
@@ -207,8 +201,6 @@ 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);
|
||||
|
||||
@@ -281,8 +273,6 @@ 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);
|
||||
|
||||
@@ -307,8 +297,6 @@ 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);
|
||||
|
||||
|
||||
+4
-1
@@ -8,6 +8,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import java.lang.reflect.Method;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -16,6 +17,8 @@ import stirling.software.proprietary.security.util.Base32Codec;
|
||||
|
||||
class TotpServiceTest {
|
||||
|
||||
private static final Pattern PATTERN = Pattern.compile("[A-Z2-7]+");
|
||||
|
||||
private TotpService buildService(String appName) {
|
||||
ApplicationProperties properties = new ApplicationProperties();
|
||||
ApplicationProperties.Ui ui = new ApplicationProperties.Ui();
|
||||
@@ -32,7 +35,7 @@ class TotpServiceTest {
|
||||
|
||||
assertNotNull(secret);
|
||||
assertEquals(32, secret.length());
|
||||
assertTrue(secret.matches("[A-Z2-7]+"));
|
||||
assertTrue(PATTERN.matcher(secret).matches());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ plugins {
|
||||
id "org.springframework.boot" version "3.5.9"
|
||||
id "org.springdoc.openapi-gradle-plugin" version "1.9.0"
|
||||
id "io.swagger.swaggerhub" version "1.3.2"
|
||||
id "com.diffplug.spotless" version "8.2.1"
|
||||
id "com.diffplug.spotless" version "8.1.0"
|
||||
id "com.github.jk1.dependency-license-report" version "3.0.1"
|
||||
//id "nebula.lint" version "19.0.3"
|
||||
id "org.sonarqube" version "7.2.2.6593"
|
||||
@@ -67,7 +67,7 @@ springBoot {
|
||||
|
||||
allprojects {
|
||||
group = 'stirling.software'
|
||||
version = '2.4.6'
|
||||
version = '2.5.1'
|
||||
|
||||
configurations.configureEach {
|
||||
exclude group: 'commons-logging', module: 'commons-logging'
|
||||
|
||||
Generated
+269
-177
@@ -10,29 +10,31 @@
|
||||
"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.5.0",
|
||||
"@embedpdf/engines": "^2.5.0",
|
||||
"@embedpdf/models": "^2.5.0",
|
||||
"@embedpdf/plugin-annotation": "^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",
|
||||
"@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",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -49,6 +51,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-http": "^2.5.6",
|
||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||
@@ -59,7 +62,6 @@
|
||||
"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",
|
||||
@@ -364,6 +366,21 @@
|
||||
"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",
|
||||
@@ -553,13 +570,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/core": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.5.0.tgz",
|
||||
"integrity": "sha512-nI7GnA5xCNtJHAdKBLPKJVvi4+yAKjy1sysaDf+qp+z3D81Hy8oAcl///QTaZ9ob0SL2jyqi3x//hKl0Rwmgrw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-2.6.0.tgz",
|
||||
"integrity": "sha512-859GUvZ3BLpJuKTiwcPPMNn9CSlMaPjQ4yXnyQRngfbvDAiijIIpVLaC98B08Nx6QsUcD3cs/6+wkB888lNsDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "2.5.0",
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/engines": "2.6.0",
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -570,9 +587,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/engines": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.5.0.tgz",
|
||||
"integrity": "sha512-SEknNmQrYvkAZgJllRKXuvXSrHSndDQsr7b3mrIVa9bzV6TeZua0a/YUlvI3/jf74Sdajru3XKPe22iHEOH4Zg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-2.6.0.tgz",
|
||||
"integrity": "sha512-zW3927u0wbFBD2tQLWbE45DEBIMkZyN7n5O2p70er6u7mP1XYEz7Ud9NxcPL/3b5MzDfPBTSyxM3T12e+ZeAxw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/fonts-arabic": "1.0.0",
|
||||
@@ -582,8 +599,8 @@
|
||||
"@embedpdf/fonts-latin": "1.0.0",
|
||||
"@embedpdf/fonts-sc": "1.0.0",
|
||||
"@embedpdf/fonts-tc": "1.0.0",
|
||||
"@embedpdf/models": "2.5.0",
|
||||
"@embedpdf/pdfium": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0",
|
||||
"@embedpdf/pdfium": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -636,31 +653,48 @@
|
||||
"license": "OFL-1.1"
|
||||
},
|
||||
"node_modules/@embedpdf/models": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.5.0.tgz",
|
||||
"integrity": "sha512-wu7XgargYBQEh46hVnfsmkTF6TvuoP9nAkTASR60s5ourjlT12qL9RiFLpwGkOBfs8E58h8V5hkgKsra5t03Lw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-2.6.0.tgz",
|
||||
"integrity": "sha512-6zuoJE79WXyRXKhJXhl+8p4njuC1nxPpKYRIs54PRLgTkHOLaou+G+ZunEd99XOoVssHLCjxWBUpg46ihQwXDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/pdfium": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.5.0.tgz",
|
||||
"integrity": "sha512-2VEO4cNZsV8ig9upS+C+x3Tb58aqNxiAdaUMlD2ZZT8FgszhsV9xMyEuM2maFRdjeT7EO37FtzYBdXc/K67ivA==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-2.6.0.tgz",
|
||||
"integrity": "sha512-eYXU1VvVI0e9OqOzvsTcsU6YSLq9F7jcAiIbtMB+NxApvvH3kHz3FPEcf8ha2ZiLftF5OAD8K89SSE5GLE6t1A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-annotation": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.5.0.tgz",
|
||||
"integrity": "sha512-S5zCeWU3hM9jrnaGuW5RAXt+AzXXvQbFtAdCtxHW1hFADiZ97FKr8KS9MGCkkj6C9madtZP6iUJikvnhoLCABQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-2.6.0.tgz",
|
||||
"integrity": "sha512-FJgGy6lhKrWsiJjh7jZ92NwMBob5GOwfYejQl28JFk6muEQORLtysz5gaeyMpMIyxnfjlf9Eqv8Z6LBBfGLGOA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0",
|
||||
"@embedpdf/utils": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0",
|
||||
"@embedpdf/utils": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-history": "2.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.5.0",
|
||||
"@embedpdf/plugin-selection": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-history": "2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.6.0",
|
||||
"@embedpdf/plugin-selection": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"svelte": ">=5 <6",
|
||||
"vue": ">=3.2.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -669,15 +703,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-bookmark": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-bookmark/-/plugin-bookmark-2.5.0.tgz",
|
||||
"integrity": "sha512-2N5kGoamUrQqWZC5SMWIhdyBHqZN/CdcGf8GVH71FFw3AU6rmZ1AD/AkLzgqoYGIuZFE8ACckdrhtbpsZMmSDQ==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -686,15 +720,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-document-manager": {
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -703,15 +737,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-export": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.5.0.tgz",
|
||||
"integrity": "sha512-KC9jXqwcxe76QqfxLx0tnrSdFoApTFOpT+dwrvox186uxYKSmSt1JHFWe4THB/A63hCNr8uMwyswYdFO8fWNHw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-2.6.0.tgz",
|
||||
"integrity": "sha512-i1Xy7qUipVVLDPnnY22hm3RNMx33lvuNbCuPggql5Ws6WBLG9YhDsK+v0JVe2sDSlifa5SJwuBlMHZWPRTZyxg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -720,15 +754,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-history": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.5.0.tgz",
|
||||
"integrity": "sha512-Av9NBSE9Or1Y6cXcNWpx0bBZN3yI4vywa6kSNjhaqOrgpQDWMaTO57eApJpyHzBodqEztY+klE9YJ7MH88zm6w==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-2.6.0.tgz",
|
||||
"integrity": "sha512-cfVoBjkIbFiRsQu/cwPEi0rrTAF7jriAGzABWawnSTKYEPFrU3LDHO7TewgBz45kHl9pSwvRexaIdTR8ECIKbQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -737,15 +771,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-interaction-manager": {
|
||||
"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==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-2.6.0.tgz",
|
||||
"integrity": "sha512-9bruF6M6GKVdABRTinHsZ+izf2tDQwDEcNI0CHVc5gurrz3CQfAGP2sJkv8uQrXyYTK3zV2Oq6zGknk7Hdx9mA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -754,17 +788,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-pan": {
|
||||
"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==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-2.6.0.tgz",
|
||||
"integrity": "sha512-r8AXcXUy6NMYDaQeixScbeFfmZIvWpUUjx3gxjP4J90xfxXnuz/g/lnh4D2DBaiK4mt6crIVBpXU9IUwMIcUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.5.0",
|
||||
"@embedpdf/plugin-viewport": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.6.0",
|
||||
"@embedpdf/plugin-viewport": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -773,15 +807,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-print": {
|
||||
"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==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-print/-/plugin-print-2.6.0.tgz",
|
||||
"integrity": "sha512-cgWRqVtRgCCLCn1ViuZEFr+ZJ3QI61/5s9tl3T9x81rwkBN4HT582BYzyRnLBzTMYKxkQZDr1WxfS8ctdlHEUQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=18.0.0",
|
||||
"react-dom": ">=18.0.0",
|
||||
@@ -790,20 +824,20 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-redaction": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.5.0.tgz",
|
||||
"integrity": "sha512-G0cm1hLWi09gU8WV+IShq2XHkmLtEbk+EvD3dIiyJV2kbOjgwGSC2Ezt8br3DzH6R/0bF6RAbDIpFyS2Q0oMfg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-redaction/-/plugin-redaction-2.6.0.tgz",
|
||||
"integrity": "sha512-DdDnmOl9K0N4dpTeUohavxQyrfollhkjT+zdfkna3Fc7F4jfl3Vg6uKoGmT71A+Vp4uTGNLt6cNCscbJW9E9kQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0",
|
||||
"@embedpdf/utils": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0",
|
||||
"@embedpdf/utils": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -812,15 +846,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-render": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.5.0.tgz",
|
||||
"integrity": "sha512-nrTmg8cVMohcKYiQ/7erErsaWlyaq20OtXbVjmnPNnqz4amJLAjlPyudTJRlWWPyIiri9SF4A0ue5ICDY2sypg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-2.6.0.tgz",
|
||||
"integrity": "sha512-Rk4QCxDOzhQrvKPt/G3G+p5ELwnKFkC5ljHMd7ND23atR9E3wm5W3+Nx3FaAYYPrpfqQ7BrbKnfQ7SkUbDxS3w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -829,15 +863,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-rotate": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.5.0.tgz",
|
||||
"integrity": "sha512-crFsXduaxNZJmVRfgklBpO4x4i9cRxPmfFBvdIoyJ1ea6AGOCL0rQKQcfqHTFdgtPzlVUiIg6Hi2v+033jdLUg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-2.6.0.tgz",
|
||||
"integrity": "sha512-zgF2S5cfkOxkOWrwoLQLN8scJgKBEhyhVOv/RNdeAKP6qE3h28AGRmDeMsekBDbiInlIxIHzynE5vVcTNf5EnQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -846,16 +880,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-scroll": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.5.0.tgz",
|
||||
"integrity": "sha512-AdLuSgvAaukLl1uQ0FbswcAIPFaR3Jk2ZbEJpWLd9E6iQ+66Cta0Sz8d5J6ndx7VBlRYAZwoqiXF85utJxpQ5g==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-2.6.0.tgz",
|
||||
"integrity": "sha512-BEgSy6cs9+MLCS0Z3/FYMdA4Ygt6ddYIAg28XlF20kN3tLj8BQUo5qx6adI+SlwrFFGY52VAjjK7VSBuGfn19g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-viewport": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-viewport": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -864,15 +898,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-search": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.5.0.tgz",
|
||||
"integrity": "sha512-ycHJh05vBZ1PTSdEMgdx6K1py0oklwbwY2eXO4nD54EN9EVZgWlYC4Q+u8nyGOiNL6VmJYcqJ0HmjSWBdmGWBw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-2.6.0.tgz",
|
||||
"integrity": "sha512-GSzJkmuK9LE7LmlTwnDl71KdD9prHlCjgFs5Tm0K8qjELOSH+oduFXusIuf654+UQveDYczpzBVUcqb4yBf1xA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -881,17 +915,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-selection": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.5.0.tgz",
|
||||
"integrity": "sha512-M3WDjahig/6KE83SZGvTaJWhqEOIzH002k2fpJVuks926UBnfgYCH8uqV7SOUQTneQDmIa0PlyFiuEXDw1Ocrw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-2.6.0.tgz",
|
||||
"integrity": "sha512-VrW0duVxLwaquInwmuNDMz8o0tfCDwe3j81fvTUDW/s7KqnzFbxK7vEuq5TEtxWuSng2DXxyX3r1ntCm4X/NCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0",
|
||||
"@embedpdf/utils": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0",
|
||||
"@embedpdf/utils": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-interaction-manager": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -900,15 +934,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-spread": {
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -917,16 +951,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-thumbnail": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.5.0.tgz",
|
||||
"integrity": "sha512-iWofJSXKbWrgvS2fe8v3U1+e2wjBRXD2i1DUcJKnTrqyfjZ8YzUomc5EzdG2RT7uUjtqrcu7463TZ9JHXUkASQ==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-2.6.0.tgz",
|
||||
"integrity": "sha512-Sj4jCV1MNk+19zKWX4KfSl5c0YHrqVG83pEYfqexjSkSX7y7HRwAOtMBtNd3uLInPPSBnzDxj+KlJlIe8RPPJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-render": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-render": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -935,18 +969,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-tiling": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.5.0.tgz",
|
||||
"integrity": "sha512-oih0GyGOJvfaXPLSEY+qfC05UUU1ZkADEbr6uCwRMmdHIXu/0ZTJnAToegfWXtfE+Sw0J5wscVkipXXIX5azlw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-2.6.0.tgz",
|
||||
"integrity": "sha512-qyiHWljryHWQ7uzip2WDg4x28o/1QM0wh9oIyz5WlBnrDaK6bLJGsWUym5P6WfLp0Y8h6GFAslNNcgjBv6E3qw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-render": "2.5.0",
|
||||
"@embedpdf/plugin-scroll": "2.5.0",
|
||||
"@embedpdf/plugin-viewport": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-render": "2.6.0",
|
||||
"@embedpdf/plugin-scroll": "2.6.0",
|
||||
"@embedpdf/plugin-viewport": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -955,15 +989,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-viewport": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.5.0.tgz",
|
||||
"integrity": "sha512-z0AXHA9Z3rZdCLje7P2NsQbxKLJ4b/l8lgzXOVn5Ow/pIPE0D2P3fn9WzImHTNI1RNrZMdkW9OH3lfkEXFTqHw==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-2.6.0.tgz",
|
||||
"integrity": "sha512-Ea7s+LivQ4ph01mVngU2tu2Ni/zulxzIyiifCpMaBMHmvjGFQjJcNNYHR90YuM8keto82KCszxdNDuAEEzT6Wg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -972,17 +1006,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-zoom": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-2.5.0.tgz",
|
||||
"integrity": "sha512-HWJlqXOXdv/kttV+XWCCStUZAeLl66AuaO8BsnPlAPwEADLLCH4tR4XqJQoWr7/r5watKP7UeQ00FsWu0oGclw==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "2.5.0"
|
||||
"@embedpdf/models": "2.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "2.5.0",
|
||||
"@embedpdf/plugin-scroll": "2.5.0",
|
||||
"@embedpdf/plugin-viewport": "2.5.0",
|
||||
"@embedpdf/core": "2.6.0",
|
||||
"@embedpdf/plugin-scroll": "2.6.0",
|
||||
"@embedpdf/plugin-viewport": "2.6.0",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -991,9 +1025,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/utils": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.5.0.tgz",
|
||||
"integrity": "sha512-JjYj6BRzu9oesA1JOqKPFMEWKinjvJIjziWu1j6lDXxLsE59bkShjUKbaEG+lkXRspuZRWNP++rzE2p2Ht4veg==",
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-2.6.0.tgz",
|
||||
"integrity": "sha512-FT6U6L3Et688urUTyISpYH05w4sG+WzoWxaI7aPU4ieh4c/vVgadUtjZj/QCC8v+DebPYRAX1gpUY7e0Y0HlTQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -4180,6 +4214,15 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-dialog": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
|
||||
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-fs": {
|
||||
"version": "2.4.5",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz",
|
||||
@@ -6452,11 +6495,23 @@
|
||||
"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"
|
||||
@@ -6469,9 +6524,18 @@
|
||||
"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",
|
||||
@@ -6590,6 +6654,12 @@
|
||||
"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",
|
||||
@@ -8839,6 +8909,22 @@
|
||||
"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",
|
||||
@@ -10685,6 +10771,15 @@
|
||||
"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",
|
||||
@@ -11240,24 +11335,6 @@
|
||||
"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",
|
||||
@@ -12965,6 +13042,21 @@
|
||||
"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",
|
||||
|
||||
+25
-23
@@ -7,28 +7,29 @@
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@embedpdf/core": "^2.5.0",
|
||||
"@embedpdf/engines": "^2.5.0",
|
||||
"@embedpdf/models": "^2.5.0",
|
||||
"@embedpdf/plugin-annotation": "^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",
|
||||
"@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",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -45,6 +46,7 @@
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tanstack/react-virtual": "^3.13.12",
|
||||
"@tauri-apps/api": "^2.9.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
"@tauri-apps/plugin-http": "^2.5.6",
|
||||
"@tauri-apps/plugin-shell": "^2.3.4",
|
||||
@@ -55,7 +57,7 @@
|
||||
"i18next-browser-languagedetector": "^8.2.0",
|
||||
"jszip": "^3.10.1",
|
||||
"license-report": "^6.8.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"@cantoo/pdf-lib": "^2.5.3",
|
||||
"pdfjs-dist": "^5.4.149",
|
||||
"peerjs": "^1.5.5",
|
||||
"posthog-js": "^1.268.0",
|
||||
|
||||
@@ -17,6 +17,12 @@ confirmClose = "Confirm Close"
|
||||
confirmCloseCancel = "Cancel"
|
||||
confirmCloseConfirm = "Close File"
|
||||
confirmCloseMessage = "Are you sure you want to close this file?"
|
||||
confirmCloseDiscard = "Discard changes and close"
|
||||
confirmCloseSave = "Save and close"
|
||||
confirmCloseUnsaved = "This file has unsaved changes."
|
||||
confirmCloseUnsavedList = "You have {{count}} file{{plural}} with unsaved changes.\n\n{{fileList}}"
|
||||
confirmCloseSaveFailedTitle = "Save Failed"
|
||||
confirmCloseSaveFailed = "Saved with errors. {{count}} file{{plural}} could not be saved."
|
||||
confirmPasswordErrorMessage = "New Password and Confirm New Password must match."
|
||||
custom = "Custom..."
|
||||
customPosition = "Custom Position"
|
||||
@@ -39,6 +45,8 @@ edit = "Edit"
|
||||
editYourNewFiles = "Edit your new file(s)"
|
||||
exportAndContinue = "Export & Continue"
|
||||
false = "False"
|
||||
fileSavedToDisk = "File saved to disk"
|
||||
fileNotSavedToDisk = "Not saved to disk"
|
||||
fileSelected = "Selected: {{filename}}"
|
||||
filesSelected = "{{count}} files selected"
|
||||
font = "Font"
|
||||
@@ -829,8 +837,9 @@ label = "Username"
|
||||
[admin.settings.endpoints]
|
||||
description = "Control which API endpoints and endpoint groups are available."
|
||||
management = "Endpoint Management"
|
||||
note = "Note: Disabling endpoints restricts API access but does not remove UI components. Restart required for changes to take effect."
|
||||
title = "API Endpoints"
|
||||
userDefaults = "User Preference Defaults"
|
||||
userDefaultsDescription = "Set default values for user preferences. Users can override these in their personal settings."
|
||||
|
||||
[admin.settings.endpoints.groupsToRemove]
|
||||
description = "Select endpoint groups to disable"
|
||||
@@ -840,6 +849,14 @@ label = "Disabled Endpoint Groups"
|
||||
description = "Select individual endpoints to disable"
|
||||
label = "Disabled Endpoints"
|
||||
|
||||
[admin.settings.endpoints.defaultHideUnavailableTools]
|
||||
description = "Remove disabled tools instead of showing them greyed out"
|
||||
label = "Hide unavailable tools by default"
|
||||
|
||||
[admin.settings.endpoints.defaultHideUnavailableConversions]
|
||||
description = "Remove disabled conversion options instead of showing them greyed out"
|
||||
label = "Hide unavailable conversions by default"
|
||||
|
||||
[admin.settings.enterpriseRequired]
|
||||
message = "An Enterprise license is required to access {{featureName}}. You are viewing demo data for reference."
|
||||
title = "Enterprise License Required"
|
||||
@@ -1219,9 +1236,21 @@ label = "Enable Key Cleanup"
|
||||
description = "Automatically rotate JWT signing keys periodically"
|
||||
label = "Enable Key Rotation"
|
||||
|
||||
[admin.settings.security.jwt.keyRetentionDays]
|
||||
description = "Number of days to retain old JWT keys for verification"
|
||||
label = "Key Retention Days"
|
||||
[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.persistence]
|
||||
description = "Store JWT keys persistently to survive server restarts"
|
||||
@@ -1410,13 +1439,16 @@ 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"
|
||||
@@ -1446,6 +1478,7 @@ notesStamps = "Notes & Stamps"
|
||||
opacity = "Opacity"
|
||||
pen = "Pen"
|
||||
polygon = "Polygon"
|
||||
properties = "Properties"
|
||||
rectangle = "Rectangle"
|
||||
redo = "Redo"
|
||||
saveChanges = "Save Changes"
|
||||
@@ -1471,6 +1504,7 @@ 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)"
|
||||
@@ -3300,6 +3334,10 @@ desc = "Build multi-step workflows by chaining together PDF actions. Ideal for r
|
||||
tags = "workflow,sequence,automation,automate,batch,batch processing,pipeline,chain,multi-step,recurring,scheduled,automatic,process multiple,bulk operations"
|
||||
title = "Automate"
|
||||
|
||||
[home.formFill]
|
||||
desc = "Fill PDF form fields interactively with a visual editor"
|
||||
title = "Fill Form"
|
||||
|
||||
[home.autoRename]
|
||||
desc = "Auto renames a PDF file based on its detected header"
|
||||
tags = "auto-detect,header-based,organize,relabel,auto rename,automatic rename,smart rename,rename by content,filename,file naming,detect title"
|
||||
@@ -4197,6 +4235,57 @@ 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)"
|
||||
@@ -4823,6 +4912,7 @@ account = "Account"
|
||||
activity = "Activity"
|
||||
adminSettings = "Admin Settings"
|
||||
allTools = "Tools"
|
||||
plugins = "Plugins"
|
||||
automate = "Automate"
|
||||
config = "Config"
|
||||
files = "Files"
|
||||
@@ -4879,6 +4969,9 @@ applyRedactions = "Apply Redactions"
|
||||
applyWarning = "⚠️ Permanent application, cannot be undone and the data underneath will be deleted"
|
||||
boxRedaction = "Box draw redaction"
|
||||
colourPicker = "Colour Picker"
|
||||
colorLabel = "Redaction Colour"
|
||||
active = "Redaction Mode Active"
|
||||
activate = "Activate Redaction Tool"
|
||||
controlsTitle = "Manual Redaction Controls"
|
||||
convertPDFToImageLabel = "Convert PDF to PDF-Image (Used to remove text behind the box)"
|
||||
export = "Export"
|
||||
@@ -5274,36 +5367,6 @@ 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"
|
||||
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"
|
||||
toggleSidebar = "Toggle Sidebar"
|
||||
toggleTheme = "Toggle Theme"
|
||||
|
||||
[rotate]
|
||||
rotateLeft = "Rotate Anticlockwise"
|
||||
rotateRight = "Rotate Clockwise"
|
||||
@@ -5563,6 +5626,21 @@ title = "Policies & Privacy"
|
||||
[settings.preferences]
|
||||
title = "Preferences"
|
||||
|
||||
[settings.plugins]
|
||||
author = "Author: {{author}}"
|
||||
count = "Installed plugins {{count}}"
|
||||
createdAt = "Created on {{date}}"
|
||||
description = "Browse, install, and configure extensions."
|
||||
empty = "No plugins found. Drop a plugin JAR in {{path}}."
|
||||
error = "Failed to load plugins"
|
||||
label = "Plugins"
|
||||
loading = "Loading plugins..."
|
||||
minHost = "min. v{{version}}"
|
||||
noDescription = "No description"
|
||||
sectionTitle = "Extensions"
|
||||
title = "Plugins"
|
||||
unknownAuthor = "unknown"
|
||||
|
||||
[settings.security]
|
||||
description = "Update your password to keep your account secure."
|
||||
title = "Security"
|
||||
@@ -6141,11 +6219,6 @@ title = "API Documentation"
|
||||
[tableExtraxt]
|
||||
tags = "CSV,Table Extraction,extract,convert"
|
||||
|
||||
[textAlign]
|
||||
center = "Center"
|
||||
left = "Left"
|
||||
right = "Right"
|
||||
|
||||
[theme]
|
||||
toggle = "Toggle Theme"
|
||||
|
||||
@@ -6211,6 +6284,15 @@ verification = "Verification"
|
||||
noSearchResults = "No tools found"
|
||||
noTools = "No tools available"
|
||||
|
||||
[plugins]
|
||||
closeViewer = "Close plugin viewer"
|
||||
noDescription = "No description provided."
|
||||
open = "Open UI"
|
||||
refresh = "Refresh"
|
||||
shortTitle = "Plugins"
|
||||
title = "Installed plugins"
|
||||
version = "v{{version}}"
|
||||
|
||||
[unlockPDFForms]
|
||||
description = "This tool will remove read-only restrictions from PDF form fields, making them editable and fillable."
|
||||
filenamePrefix = "unlocked_forms"
|
||||
@@ -6421,18 +6503,23 @@ 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"
|
||||
noSupport = "Attachment support is unavailable for this viewer."
|
||||
noDocument = "Open a PDF to view its attachments."
|
||||
loading = "Loading attachments..."
|
||||
empty = "No attachments in this document"
|
||||
noMatch = "No attachments match your search"
|
||||
|
||||
[viewer.formBar]
|
||||
title = "Form Fields"
|
||||
unsavedBadge = "Unsaved"
|
||||
unsavedDesc = "You have unsaved changes"
|
||||
hasFieldsDesc = "This PDF contains fillable fields"
|
||||
dismiss = "Dismiss"
|
||||
apply = "Apply Changes"
|
||||
download = "Download PDF"
|
||||
|
||||
[viewPdf]
|
||||
header = "View PDF"
|
||||
|
||||
@@ -264,8 +264,8 @@ preview = "Selezione posizione"
|
||||
previewDisclaimer = "L'anteprima è approssimativa. Il risultato finale può variare a causa delle metriche dei font PDF."
|
||||
submit = "Aggiungi numeri di pagina"
|
||||
title = "Aggiungi numeri di pagina"
|
||||
zeroPad = "Zero‑pad Width (Bates Stamping)"
|
||||
zeroPadTooltip = "Zero‑pad (Bates Stamp) page numbers to this width (e.g., 3 ⇒ 001). Set 0 to disable."
|
||||
zeroPad = "Larghezza del riempimento con zeri (Timbro Bates)"
|
||||
zeroPadTooltip = "Riempire con zeri (timbro Bates) i numeri di pagina fino a questa larghezza (ad esempio, 3 ⇒ 001). Impostare a 0 per disabilitare."
|
||||
|
||||
[addPageNumbers.error]
|
||||
failed = "Operazione di aggiunta dei numeri di pagina non riuscita"
|
||||
@@ -391,13 +391,13 @@ title = "Risultati timbro"
|
||||
[AddStampRequest.template]
|
||||
custom = "Personalizzato"
|
||||
dateHeader = "Data intestazione"
|
||||
doc-info = "Document Info"
|
||||
draft = "Draft Watermark"
|
||||
doc-info = "Informazioni sul documento"
|
||||
draft = "Filigrana di bozza"
|
||||
draftWatermark = "Bozza Filigrana"
|
||||
european-date = "European Date (DD/MM/YYYY)"
|
||||
european-date = "Note legali (DD/MM/YYYY)"
|
||||
europeanDate = "Data europea"
|
||||
legal-footer = "Legal Footer"
|
||||
page-numbers = "Page Numbers"
|
||||
legal-footer = "Note legali"
|
||||
page-numbers = "Numeri di pagina"
|
||||
pageNumberFooter = "Piè di pagina con numero di pagina"
|
||||
timestamp = "Timestamp"
|
||||
|
||||
@@ -921,23 +921,23 @@ description = "Percorso dell'eseguibile WeasyPrint per conversione da HTML a PDF
|
||||
label = "Eseguibile WeasyPrint"
|
||||
|
||||
[admin.settings.general.customPaths.pipeline]
|
||||
label = "Directory pipeline"
|
||||
label = "Pipeline della directory"
|
||||
|
||||
[admin.settings.general.customPaths.pipeline.finishedFoldersDir]
|
||||
description = "Directory di output dei PDF elaborati (lascia vuoto per default: /pipeline/finishedFolders)"
|
||||
label = "Directory cartelle completate"
|
||||
|
||||
[admin.settings.general.customPaths.pipeline.pipelineDir]
|
||||
description = "Base directory for pipeline resources (leave empty for default: /pipeline)"
|
||||
label = "Pipeline Directory"
|
||||
description = "Directory di base per le risorse della pipeline (lascia vuoto per il valore predefinito: /pipeline)"
|
||||
label = "Pipeline della directory"
|
||||
|
||||
[admin.settings.general.customPaths.pipeline.watchedFoldersDir]
|
||||
description = "Directory in cui la pipeline monitora i PDF in arrivo (lascia vuoto per predefinito: /pipeline/watchedFolders)"
|
||||
label = "Directory cartelle monitorate"
|
||||
|
||||
[admin.settings.general.customPaths.pipeline.watchedFoldersDirs]
|
||||
description = "Directories where pipeline monitors for incoming PDFs (one per line or comma-separated; leave empty for default: /pipeline/watchedFolders)"
|
||||
label = "Watched Folders Directories"
|
||||
description = "Directory in cui la pipeline monitora i PDF in arrivo (una per riga o separate da virgola; lascia vuoto per il valore predefinito: /pipeline/watchedFolders)"
|
||||
label = "Cartelle e directory controllate"
|
||||
|
||||
[admin.settings.general.defaultLocale]
|
||||
description = "La lingua predefinita per i nuovi utenti (es. en_US, es_ES)"
|
||||
@@ -2285,7 +2285,7 @@ title = "Questi PDF sembrano molto diversi"
|
||||
|
||||
[compare.edited]
|
||||
label = "PDF modificato"
|
||||
placeholder = "Select the edited PDF"
|
||||
placeholder = "Seleziona il PDF modificato"
|
||||
|
||||
[compare.error]
|
||||
filesMissing = "Impossibile trovare i file selezionati. Selezionali di nuovo."
|
||||
@@ -2560,8 +2560,8 @@ selectSourceFormatFirst = "Seleziona prima un formato sorgente"
|
||||
settings = "Impostazioni"
|
||||
single = "Singolo"
|
||||
sourceFormatPlaceholder = "Formato sorgente"
|
||||
strictMode = "Strict Mode"
|
||||
strictModeDesc = "Error if conversion is not perfect (uses VeraPDF verification)"
|
||||
strictMode = "Modalità Stretta"
|
||||
strictModeDesc = "Errore se la conversione non è perfetta (utilizza la verifica VeraPDF)"
|
||||
svgPdfOptions = "Opzioni da SVG a PDF"
|
||||
svgVectorNote = "I file SVG vengono renderizzati come grafica vettoriale per un output nitido a qualsiasi risoluzione. Le dimensioni dell'SVG determinano le dimensioni della pagina PDF."
|
||||
targetFormatPlaceholder = "Formato di destinazione"
|
||||
@@ -4887,7 +4887,6 @@ failed = "Si è verificato un errore durante la redazione del PDF."
|
||||
[redact.manual]
|
||||
apply = "Applica"
|
||||
applyChanges = "Applica modifiche"
|
||||
applyRedactions = "Apply Redactions"
|
||||
applyWarning = "⚠️ Applicazione permanente, non può essere annullata e i dati sottostanti verranno eliminati"
|
||||
boxRedaction = "Redazione con riquadro"
|
||||
colourPicker = "Selettore colore"
|
||||
@@ -4915,6 +4914,7 @@ upload = "Carica"
|
||||
zoom = "Zoom"
|
||||
zoomIn = "Ingrandisci"
|
||||
zoomOut = "Riduci"
|
||||
applyRedactions = "Apply Redactions"
|
||||
|
||||
[redact.manual.pageRedactionNumbers]
|
||||
placeholder = "(es. 1,2,8 o 4,7,12-16 o 2n-1)"
|
||||
@@ -5669,7 +5669,7 @@ title = "Accedi al server"
|
||||
subtitle = "Inserisci l'URL del tuo server self-hosted"
|
||||
testing = "Verifica connessione..."
|
||||
title = "Connetti al server"
|
||||
useLast = "Last used server: {{serverUrl}}"
|
||||
useLast = "Ultimo server utilizzato: {{serverUrl}}"
|
||||
|
||||
[setup.server.error]
|
||||
configFetch = "Impossibile recuperare la configurazione del server. Controlla l'URL e riprova."
|
||||
|
||||
Generated
+45
@@ -907,6 +907,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
dependencies = [
|
||||
"bitflags 2.10.0",
|
||||
"block2",
|
||||
"libc",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
@@ -3531,6 +3533,30 @@ dependencies = [
|
||||
"webpki-roots 1.0.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfd"
|
||||
version = "0.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"raw-window-handle",
|
||||
"wasm-bindgen",
|
||||
"wasm-bindgen-futures",
|
||||
"web-sys",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -4229,6 +4255,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-log",
|
||||
@@ -4609,6 +4636,24 @@ dependencies = [
|
||||
"windows-result 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
"rfd",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tauri-plugin-fs",
|
||||
"thiserror 2.0.18",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.5"
|
||||
|
||||
@@ -28,6 +28,7 @@ tauri = { version = "2.9.0", features = [ "devtools"] }
|
||||
tauri-plugin-log = "2.8.0"
|
||||
tauri-plugin-shell = "2.3.4"
|
||||
tauri-plugin-fs = "2.4.5"
|
||||
tauri-plugin-dialog = "2.4.2"
|
||||
tauri-plugin-http = { version = "2.5.6", features = ["dangerous-settings"] }
|
||||
tauri-plugin-single-instance = { version = "2.3.7", features = ["deep-link"] }
|
||||
tauri-plugin-store = "2.4.2"
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<?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>
|
||||
@@ -7,6 +7,7 @@
|
||||
],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-destroy",
|
||||
"http:default",
|
||||
{
|
||||
"identifier": "http:allow-fetch",
|
||||
@@ -40,6 +41,18 @@
|
||||
"identifier": "fs:allow-read-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-write-file",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
{
|
||||
"identifier": "fs:allow-remove",
|
||||
"allow": [{ "path": "**" }]
|
||||
},
|
||||
"dialog:default",
|
||||
"dialog:allow-message",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"opener:default",
|
||||
"shell:allow-open"
|
||||
]
|
||||
|
||||
@@ -33,3 +33,12 @@ pub async fn clear_opened_files() -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Command to atomically get and clear opened file paths
|
||||
#[tauri::command]
|
||||
pub async fn pop_opened_files() -> Result<Vec<String>, String> {
|
||||
let mut opened_files = OPENED_FILES.lock().unwrap();
|
||||
let all_files = opened_files.clone();
|
||||
opened_files.clear();
|
||||
add_log(format!("📂 Returning and clearing {} opened file(s)", all_files.len()));
|
||||
Ok(all_files)
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ pub mod auth;
|
||||
pub mod default_app;
|
||||
|
||||
pub use backend::{cleanup_backend, get_backend_port, start_backend};
|
||||
pub use files::{add_opened_file, clear_opened_files, get_opened_files};
|
||||
pub use files::{add_opened_file, clear_opened_files, get_opened_files, pop_opened_files};
|
||||
pub use connection::{
|
||||
get_connection_config,
|
||||
is_first_launch,
|
||||
|
||||
@@ -16,6 +16,7 @@ use commands::{
|
||||
get_backend_port,
|
||||
get_connection_config,
|
||||
get_opened_files,
|
||||
pop_opened_files,
|
||||
get_refresh_token,
|
||||
get_user_info,
|
||||
is_first_launch,
|
||||
@@ -55,6 +56,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_store::Builder::new().build())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
@@ -139,6 +141,7 @@ pub fn run() {
|
||||
start_backend,
|
||||
get_backend_port,
|
||||
get_opened_files,
|
||||
pop_opened_files,
|
||||
clear_opened_files,
|
||||
get_tauri_logs,
|
||||
get_connection_config,
|
||||
@@ -170,9 +173,9 @@ pub fn run() {
|
||||
app_handle.cleanup_before_exit();
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::CloseRequested {.. }, .. } => {
|
||||
add_log("🔄 Window close requested, cleaning up...".to_string());
|
||||
cleanup_backend();
|
||||
// Allow the window to close
|
||||
add_log("🔄 Window close requested (will cleanup on actual exit)...".to_string());
|
||||
// Don't cleanup here - let JavaScript handler prevent close if needed
|
||||
// Backend cleanup happens in ExitRequested when window actually closes
|
||||
}
|
||||
RunEvent::WindowEvent { event: WindowEvent::DragDrop(drag_drop_event), .. } => {
|
||||
use tauri::DragDropEvent;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "Stirling-PDF",
|
||||
"version": "2.4.6",
|
||||
"version": "2.5.1",
|
||||
"identifier": "stirling.pdf.dev",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -23,6 +23,7 @@
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"publisher": "Stirling PDF Inc.",
|
||||
"targets": [
|
||||
"deb",
|
||||
"rpm",
|
||||
@@ -76,7 +77,8 @@
|
||||
"minimumSystemVersion": "10.15",
|
||||
"signingIdentity": null,
|
||||
"entitlements": null,
|
||||
"providerShortName": null
|
||||
"providerShortName": null,
|
||||
"infoPlist": "Info.plist"
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { RainbowThemeProvider } from "@app/components/shared/RainbowThemeProvide
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import HomePage from "@app/pages/HomePage";
|
||||
import MobileScannerPage from "@app/pages/MobileScannerPage";
|
||||
import PluginPage from "@app/pages/PluginPage";
|
||||
import Onboarding from "@app/components/onboarding/Onboarding";
|
||||
|
||||
// Import global styles
|
||||
@@ -43,6 +44,15 @@ export default function App() {
|
||||
/>
|
||||
|
||||
{/* All other routes need AppProviders for backend integration */}
|
||||
<Route
|
||||
path="/plugins/:id"
|
||||
element={
|
||||
<AppProviders>
|
||||
<PluginPage />
|
||||
</AppProviders>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="*"
|
||||
element={
|
||||
|
||||
@@ -2,13 +2,14 @@ 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";
|
||||
import { HotkeyProvider } from "@app/contexts/HotkeyContext";
|
||||
import { SidebarProvider } from "@app/contexts/SidebarContext";
|
||||
import { PreferencesProvider } from "@app/contexts/PreferencesContext";
|
||||
import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions } from "@app/contexts/AppConfigContext";
|
||||
import { PreferencesProvider, usePreferences } from "@app/contexts/PreferencesContext";
|
||||
import { AppConfigProvider, AppConfigProviderProps, AppConfigRetryOptions, useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { RightRailProvider } from "@app/contexts/RightRailContext";
|
||||
import { ViewerProvider } from "@app/contexts/ViewerContext";
|
||||
import { SignatureProvider } from "@app/contexts/SignatureContext";
|
||||
@@ -23,6 +24,7 @@ import { useAppInitialization } from "@app/hooks/useAppInitialization";
|
||||
import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
import AppConfigLoader from '@app/components/shared/AppConfigLoader';
|
||||
import { RedactionProvider } from "@app/contexts/RedactionContext";
|
||||
import { FormFillProvider } from "@app/tools/formFill/FormFillContext";
|
||||
|
||||
// Component to initialize scarf tracking (must be inside AppConfigProvider)
|
||||
function ScarfTrackingInitializer() {
|
||||
@@ -69,6 +71,24 @@ export interface AppProvidersProps {
|
||||
appConfigProviderProps?: Partial<AppConfigProviderOverrides>;
|
||||
}
|
||||
|
||||
// Component to sync server defaults to preferences when AppConfig loads
|
||||
function ServerDefaultsSync() {
|
||||
const { config } = useAppConfig();
|
||||
const { updateServerDefaults } = usePreferences();
|
||||
|
||||
useEffect(() => {
|
||||
if (config) {
|
||||
const serverDefaults = {
|
||||
hideUnavailableTools: config.defaultHideUnavailableTools ?? false,
|
||||
hideUnavailableConversions: config.defaultHideUnavailableConversions ?? false,
|
||||
};
|
||||
updateServerDefaults(serverDefaults);
|
||||
}
|
||||
}, [config, updateServerDefaults]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core application providers
|
||||
* Contains all providers needed for the core
|
||||
@@ -85,10 +105,12 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
>
|
||||
<ScarfTrackingInitializer />
|
||||
<AppConfigLoader />
|
||||
<ServerDefaultsSync />
|
||||
<FileContextProvider enableUrlSync={true} enablePersistence={true}>
|
||||
<AppInitializer />
|
||||
<BrandingAssetManager />
|
||||
<ToolRegistryProvider>
|
||||
<PluginRegistryProvider>
|
||||
<ToolRegistryProvider>
|
||||
<NavigationProvider>
|
||||
<FilesModalProvider>
|
||||
<ToolWorkflowProvider>
|
||||
@@ -98,6 +120,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
<PageEditorProvider>
|
||||
<SignatureProvider>
|
||||
<RedactionProvider>
|
||||
<FormFillProvider>
|
||||
<AnnotationProvider>
|
||||
<RightRailProvider>
|
||||
<TourOrchestrationProvider>
|
||||
@@ -107,6 +130,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
</TourOrchestrationProvider>
|
||||
</RightRailProvider>
|
||||
</AnnotationProvider>
|
||||
</FormFillProvider>
|
||||
</RedactionProvider>
|
||||
</SignatureProvider>
|
||||
</PageEditorProvider>
|
||||
@@ -117,6 +141,7 @@ export function AppProviders({ children, appConfigRetryOptions, appConfigProvide
|
||||
</FilesModalProvider>
|
||||
</NavigationProvider>
|
||||
</ToolRegistryProvider>
|
||||
</PluginRegistryProvider>
|
||||
</FileContextProvider>
|
||||
</AppConfigProvider>
|
||||
</BannerProvider>
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { useLogoAssets } from '@app/hooks/useLogoAssets';
|
||||
import styles from '@app/components/fileEditor/FileEditor.module.css';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
|
||||
|
||||
interface AddFileCardProps {
|
||||
onFileSelect: (files: File[]) => void;
|
||||
@@ -33,9 +34,15 @@ const AddFileCard = ({
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleNativeUploadClick = (e: React.MouseEvent) => {
|
||||
const handleNativeUploadClick = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
fileInputRef.current?.click();
|
||||
const files = await openFilesFromDisk({
|
||||
multiple,
|
||||
onFallbackOpen: () => fileInputRef.current?.click()
|
||||
});
|
||||
if (files.length > 0) {
|
||||
onFileSelect(files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenFilesModal = (e: React.MouseEvent) => {
|
||||
@@ -179,4 +186,4 @@ const AddFileCard = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default AddFileCard;
|
||||
export default AddFileCard;
|
||||
|
||||
@@ -12,7 +12,7 @@ import AddFileCard from '@app/components/fileEditor/AddFileCard';
|
||||
import FilePickerModal from '@app/components/shared/FilePickerModal';
|
||||
import { FileId, StirlingFile } from '@app/types/fileContext';
|
||||
import { alert } from '@app/components/toast';
|
||||
import { downloadBlob } from '@app/utils/downloadUtils';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
import { useFileEditorRightRailButtons } from '@app/components/fileEditor/fileEditorRightRailButtons';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
|
||||
@@ -278,13 +278,29 @@ const FileEditor = ({
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, removeFiles, setSelectedFiles, selectedFileIds]);
|
||||
|
||||
const handleDownloadFile = useCallback((fileId: FileId) => {
|
||||
const handleDownloadFile = useCallback(async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
const file = record ? selectors.getFile(record.id) : null;
|
||||
console.log('[FileEditor] handleDownloadFile called:', { fileId, hasRecord: !!record, hasFile: !!file, localFilePath: record?.localFilePath, isDirty: record?.isDirty });
|
||||
if (record && file) {
|
||||
downloadBlob(file, file.name);
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: record.localFilePath
|
||||
});
|
||||
console.log('[FileEditor] Download complete, checking dirty state:', { localFilePath: record.localFilePath, isDirty: record.isDirty, savedPath: result.savedPath });
|
||||
// Mark file as clean after successful save to disk
|
||||
if (result.savedPath) {
|
||||
console.log('[FileEditor] Marking file as clean:', fileId);
|
||||
fileActions.updateStirlingFileStub(fileId, {
|
||||
localFilePath: record.localFilePath ?? result.savedPath,
|
||||
isDirty: false
|
||||
});
|
||||
} else {
|
||||
console.log('[FileEditor] Skipping clean mark:', { savedPath: result.savedPath, isDirty: record.isDirty });
|
||||
}
|
||||
}
|
||||
}, [activeStirlingFileStubs, selectors, _setStatus]);
|
||||
}, [activeStirlingFileStubs, selectors, fileActions]);
|
||||
|
||||
const handleUnzipFile = useCallback(async (fileId: FileId) => {
|
||||
const record = activeStirlingFileStubs.find(r => r.id === fileId);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { StirlingFileStub } from '@app/types/fileContext';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
|
||||
interface FileEditorFileNameProps {
|
||||
file: StirlingFileStub;
|
||||
}
|
||||
|
||||
const FileEditorFileName = ({ file }: FileEditorFileNameProps) => (
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
);
|
||||
|
||||
export default FileEditorFileName;
|
||||
@@ -23,6 +23,8 @@ import { FileId } from '@app/types/file';
|
||||
import { formatFileSize } from '@app/utils/fileUtils';
|
||||
import ToolChain from '@app/components/shared/ToolChain';
|
||||
import HoverActionMenu, { HoverAction } from '@app/components/shared/HoverActionMenu';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
import FileEditorFileName from '@app/components/fileEditor/FileEditorFileName';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
|
||||
|
||||
@@ -69,7 +71,7 @@ const FileEditorThumbnail = ({
|
||||
actions: fileActions,
|
||||
openEncryptedUnlockPrompt,
|
||||
} = useFileContext();
|
||||
const { state } = useFileState();
|
||||
const { state, selectors } = useFileState();
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
|
||||
// ---- Drag state ----
|
||||
@@ -191,6 +193,37 @@ const FileEditorThumbnail = ({
|
||||
setShowCloseModal(false);
|
||||
}, [file.id, file.name, onCloseFile]);
|
||||
|
||||
const handleSaveAndClose = useCallback(async () => {
|
||||
const fileToSave = selectors.getFile(file.id);
|
||||
if (fileToSave) {
|
||||
try {
|
||||
const result = await downloadFile({
|
||||
data: fileToSave,
|
||||
filename: file.name,
|
||||
localPath: file.localFilePath
|
||||
});
|
||||
if (!result.cancelled && result.savedPath) {
|
||||
fileActions.updateStirlingFileStub(file.id, {
|
||||
localFilePath: file.localFilePath ?? result.savedPath,
|
||||
isDirty: false
|
||||
});
|
||||
} else if (result.cancelled) {
|
||||
setShowCloseModal(false);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to save ${file.name}:`, error);
|
||||
alert({ alertType: 'error', title: 'Save failed', body: `Could not save ${file.name}`, expandable: true });
|
||||
setShowCloseModal(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Then close
|
||||
onCloseFile(file.id);
|
||||
alert({ alertType: 'success', title: `Saved and closed ${file.name}`, expandable: false, durationMs: 3500 });
|
||||
setShowCloseModal(false);
|
||||
}, [file.id, file.name, file.localFilePath, onCloseFile, selectors, fileActions]);
|
||||
|
||||
const handleCancelClose = useCallback(() => {
|
||||
setShowCloseModal(false);
|
||||
}, []);
|
||||
@@ -213,7 +246,6 @@ const FileEditorThumbnail = ({
|
||||
onClick: (e) => {
|
||||
e.stopPropagation();
|
||||
onDownloadFile(file.id);
|
||||
alert({ alertType: 'success', title: `Downloading ${file.name}`, expandable: false, durationMs: 2500 });
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -365,8 +397,8 @@ const FileEditorThumbnail = ({
|
||||
marginTop: '0.5rem',
|
||||
marginBottom: '0.5rem',
|
||||
}}>
|
||||
<Text size="lg" fw={700} className={styles.title} lineClamp={2}>
|
||||
<PrivateContent>{file.name}</PrivateContent>
|
||||
<Text size="lg" fw={700} className={styles.title} lineClamp={2} style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '0.25rem' }}>
|
||||
<FileEditorFileName file={file} />
|
||||
</Text>
|
||||
<Text
|
||||
size="sm"
|
||||
@@ -469,18 +501,40 @@ const FileEditorThumbnail = ({
|
||||
size="auto"
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
{t('confirmCloseCancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||
{t('confirmCloseConfirm', 'Close File')}
|
||||
</Button>
|
||||
</Group>
|
||||
{file.isDirty && file.localFilePath ? (
|
||||
<>
|
||||
<Text size="md">{t('confirmCloseUnsaved', 'This file has unsaved changes.')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
{t('confirmCloseCancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||
{t('confirmCloseDiscard', 'Discard changes and close')}
|
||||
</Button>
|
||||
<Button variant="filled" onClick={handleSaveAndClose}>
|
||||
{t('confirmCloseSave', 'Save and close')}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text size="md">{t('confirmCloseMessage', 'Are you sure you want to close this file?')}</Text>
|
||||
<Text size="sm" c="dimmed" fw={500}>
|
||||
{file.name}
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<Button variant="light" onClick={handleCancelClose}>
|
||||
{t('confirmCloseCancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button variant="filled" color="red" onClick={handleConfirmClose}>
|
||||
{t('confirmCloseConfirm', 'Close File')}
|
||||
</Button>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
@@ -12,8 +12,14 @@ const FileActions: React.FC = () => {
|
||||
const terminology = useFileActionTerminology();
|
||||
const icons = useFileActionIcons();
|
||||
const DownloadIcon = icons.download;
|
||||
const { recentFiles, selectedFileIds, filteredFiles, onSelectAll, onDeleteSelected, onDownloadSelected } =
|
||||
useFileManagerContext();
|
||||
const {
|
||||
recentFiles,
|
||||
selectedFileIds,
|
||||
filteredFiles,
|
||||
onSelectAll,
|
||||
onDeleteSelected,
|
||||
onDownloadSelected
|
||||
} = useFileManagerContext();
|
||||
|
||||
const handleSelectAll = () => {
|
||||
onSelectAll();
|
||||
@@ -31,6 +37,7 @@ const FileActions: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Only show actions if there are files
|
||||
if (recentFiles.length === 0) {
|
||||
return null;
|
||||
|
||||
@@ -46,7 +46,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const {expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
|
||||
const { expandedFileIds, onToggleExpansion, onUnzipFile } = useFileManagerContext();
|
||||
const { removeFiles } = useFileManagement();
|
||||
|
||||
// Check if this is a ZIP file
|
||||
@@ -269,6 +269,7 @@ const FileListItem: React.FC<FileListItemProps> = ({
|
||||
>
|
||||
{t('fileManager.delete', 'Delete')}
|
||||
</Menu.Item>
|
||||
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
|
||||
@@ -3,11 +3,12 @@ import { Box } from '@mantine/core';
|
||||
import { useRainbowThemeContext } from '@app/components/shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useFileHandler } from '@app/hooks/useFileHandler';
|
||||
import { useFileState } from '@app/contexts/FileContext';
|
||||
import { useFileState, useFileActions } from '@app/contexts/FileContext';
|
||||
import { useNavigationState, useNavigationActions, useNavigationGuard } from '@app/contexts/NavigationContext';
|
||||
import { isBaseWorkbench } from '@app/types/workbench';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { FileId } from '@app/types/file';
|
||||
import styles from '@app/components/layout/Workbench.module.css';
|
||||
|
||||
import TopControls from '@app/components/shared/TopControls';
|
||||
@@ -26,6 +27,7 @@ export default function Workbench() {
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { selectors } = useFileState();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
@@ -61,13 +63,17 @@ export default function Workbench() {
|
||||
const handleFileSelect = useCallback((index: number) => {
|
||||
// Don't do anything if selecting the same file
|
||||
if (index === activeFileIndex) return;
|
||||
|
||||
|
||||
// requestNavigation handles the unsaved changes check internally
|
||||
requestNavigation(() => {
|
||||
setActiveFileIndex(index);
|
||||
});
|
||||
}, [activeFileIndex, requestNavigation, setActiveFileIndex]);
|
||||
|
||||
const handleFileRemove = useCallback(async (fileId: FileId) => {
|
||||
await fileActions.removeFiles([fileId], false); // false = don't delete from IndexedDB, just remove from context
|
||||
}, [fileActions]);
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
setPreviewFile(null);
|
||||
const previousMode = sessionStorage.getItem('previousMode');
|
||||
@@ -201,6 +207,7 @@ export default function Workbench() {
|
||||
})}
|
||||
currentFileIndex={activeFileIndex}
|
||||
onFileSelect={handleFileSelect}
|
||||
onFileRemove={handleFileRemove}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import { FileId } from '@app/types/file';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
|
||||
interface FileItem {
|
||||
id: FileId;
|
||||
@@ -79,13 +80,7 @@ const FileThumbnail = ({
|
||||
// Fallback: attempt to download using the File object if provided
|
||||
const maybeFile = (file as unknown as { file?: File }).file;
|
||||
if (maybeFile instanceof File) {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(maybeFile);
|
||||
link.download = maybeFile.name || file.name || 'download';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
void downloadFile({ data: maybeFile, filename: maybeFile.name || file.name || 'download' });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +295,21 @@ export const usePageEditorExport = ({
|
||||
actions.setSelectedFiles(newStirlingFiles.map((file) => file.fileId));
|
||||
}
|
||||
|
||||
if (sourceFileIds.length === 1 && newStirlingFiles.length === 1) {
|
||||
const sourceStub = selectors.getStirlingFileStub(sourceFileIds[0]);
|
||||
if (sourceStub?.localFilePath) {
|
||||
actions.updateStirlingFileStub(newStirlingFiles[0].fileId, {
|
||||
localFilePath: sourceStub.localFilePath,
|
||||
isDirty: true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Remove source files from context
|
||||
if (sourceFileIds.length > 0) {
|
||||
await actions.removeFiles(sourceFileIds, true);
|
||||
}
|
||||
|
||||
setHasUnsavedChanges(false);
|
||||
setSplitPositions(new Set());
|
||||
setExportLoading(false);
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
import React from 'react';
|
||||
import { Menu, Loader, Group, Text } from '@mantine/core';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import { Menu, Loader, Group, Text, ActionIcon, Tooltip } from '@mantine/core';
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import FitText from '@app/components/shared/FitText';
|
||||
import { PrivateContent } from '@app/components/shared/PrivateContent';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
// Truncate text from the center: "very-long-filename.pdf" -> "very-lo...ame.pdf"
|
||||
function truncateCenter(text: string, maxLength: number = 25): string {
|
||||
if (text.length <= maxLength) return text;
|
||||
const ellipsis = '...';
|
||||
const charsToShow = maxLength - ellipsis.length;
|
||||
const frontChars = Math.ceil(charsToShow / 2);
|
||||
const backChars = Math.floor(charsToShow / 2);
|
||||
return text.substring(0, frontChars) + ellipsis + text.substring(text.length - backChars);
|
||||
}
|
||||
|
||||
interface FileDropdownMenuProps {
|
||||
displayName: string;
|
||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>;
|
||||
currentFileIndex: number;
|
||||
onFileSelect?: (index: number) => void;
|
||||
onFileRemove?: (fileId: FileId) => void;
|
||||
switchingTo?: string | null;
|
||||
viewOptionStyle: React.CSSProperties;
|
||||
pillRef?: React.RefObject<HTMLDivElement>;
|
||||
@@ -20,22 +33,27 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
||||
activeFiles,
|
||||
currentFileIndex,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
switchingTo,
|
||||
viewOptionStyle,
|
||||
}) => {
|
||||
return (
|
||||
<Menu trigger="click" position="bottom" width="30rem">
|
||||
<Menu.Target>
|
||||
<div style={{...viewOptionStyle, cursor: 'pointer'}}>
|
||||
<div style={{...viewOptionStyle, cursor: 'pointer', maxWidth: '100%'}}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
<InsertDriveFileIcon fontSize="small" style={{ flexShrink: 0 }} />
|
||||
)}
|
||||
<PrivateContent>
|
||||
<FitText text={displayName} fontSize={14} minimumFontScale={0.6} />
|
||||
<FitText
|
||||
text={truncateCenter(displayName, 30)}
|
||||
minimumFontScale={0.6}
|
||||
style={{ maxWidth: '12rem', display: 'inline-block' }}
|
||||
/>
|
||||
</PrivateContent>
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
<KeyboardArrowDownIcon fontSize="small" style={{ flexShrink: 0 }} />
|
||||
</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{
|
||||
@@ -65,14 +83,36 @@ export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
||||
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
|
||||
<PrivateContent>
|
||||
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} />
|
||||
<FitText
|
||||
text={truncateCenter(itemName, 50)}
|
||||
minimumFontScale={0.7}
|
||||
style={{ display: 'block', width: '100%' }}
|
||||
/>
|
||||
</PrivateContent>
|
||||
</div>
|
||||
{file.versionNumber && file.versionNumber > 1 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
v{file.versionNumber}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="xs" style={{ flexShrink: 0 }}>
|
||||
{file.versionNumber && file.versionNumber > 1 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
v{file.versionNumber}
|
||||
</Text>
|
||||
)}
|
||||
{onFileRemove && (
|
||||
<Tooltip label="Close file" withArrow>
|
||||
<ActionIcon
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFileRemove(file.fileId as FileId);
|
||||
}}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<CloseIcon style={{ fontSize: 14 }} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
);
|
||||
|
||||
@@ -14,6 +14,7 @@ import { useFileActionIcons } from '@app/hooks/useFileActionIcons';
|
||||
import { useAppConfig } from '@app/contexts/AppConfigContext';
|
||||
import { useIsMobile } from '@app/hooks/useIsMobile';
|
||||
import MobileUploadModal from '@app/components/shared/MobileUploadModal';
|
||||
import { openFilesFromDisk } from '@app/services/openFilesFromDisk';
|
||||
|
||||
const LandingPage = () => {
|
||||
const { addFiles } = useFileHandler();
|
||||
@@ -41,8 +42,14 @@ const LandingPage = () => {
|
||||
openFilesModal();
|
||||
};
|
||||
|
||||
const handleNativeUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
const handleNativeUploadClick = async () => {
|
||||
const files = await openFilesFromDisk({
|
||||
multiple: true,
|
||||
onFallbackOpen: () => fileInputRef.current?.click()
|
||||
});
|
||||
if (files.length > 0) {
|
||||
await addFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileSelect = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
|
||||
@@ -52,12 +52,15 @@ const LanguageItem: React.FC<LanguageItemProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const labelText = option.label;
|
||||
const comingSoonText = t('comingSoon', 'Coming soon');
|
||||
|
||||
const label = disabled ? (
|
||||
<Tooltip content={t('comingSoon', 'Coming soon')} position="left" arrow>
|
||||
<p>{option.label}</p>
|
||||
<Tooltip content={comingSoonText} position="left" arrow>
|
||||
<p>{labelText}</p>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<p>{option.label}</p>
|
||||
<p>{labelText}</p>
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -157,12 +160,27 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
compact = false,
|
||||
tooltip
|
||||
}) => {
|
||||
const { i18n } = useTranslation();
|
||||
const { i18n, ready } = 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[] || [])
|
||||
@@ -176,12 +194,6 @@ 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
|
||||
@@ -225,16 +237,14 @@ const LanguageSelector: React.FC<LanguageSelectorProps> = ({
|
||||
};
|
||||
|
||||
const currentLanguage = supportedLanguages[i18n.language as keyof typeof supportedLanguages] ||
|
||||
supportedLanguages['en-GB'];
|
||||
supportedLanguages['en-GB'] ||
|
||||
'English'; // Fallback if supportedLanguages lookup fails
|
||||
|
||||
// Trigger animation when dropdown opens
|
||||
useEffect(() => {
|
||||
if (opened) {
|
||||
setAnimationTriggered(false);
|
||||
// Small delay to ensure DOM is ready
|
||||
setTimeout(() => setAnimationTriggered(true), 20);
|
||||
}
|
||||
}, [opened]);
|
||||
// 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;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -127,7 +127,7 @@ const FileMenuItem: React.FC<FileMenuItemProps> = ({
|
||||
/>
|
||||
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
|
||||
<PrivateContent>
|
||||
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} />
|
||||
<FitText text={itemName} minimumFontScale={0.7} />
|
||||
</PrivateContent>
|
||||
</div>
|
||||
{file.versionNumber && file.versionNumber > 1 && (
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo } from "react";
|
||||
import { ActionIcon } from "@mantine/core";
|
||||
import LocalIcon from "@app/components/shared/LocalIcon";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
import { PluginInfo } from "@app/contexts/PluginRegistryContext";
|
||||
|
||||
interface PluginViewerOverlayProps {
|
||||
plugin: PluginInfo;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function PluginViewerOverlay({
|
||||
plugin,
|
||||
onClose,
|
||||
}: PluginViewerOverlayProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const iframeSrc = useMemo(() => plugin.frontendUrl ?? "", [plugin.frontendUrl]);
|
||||
|
||||
if (!iframeSrc) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
inset: 0,
|
||||
background: "rgba(6, 8, 12, 0.92)",
|
||||
padding: "1.5rem",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
zIndex: 5000,
|
||||
};
|
||||
|
||||
const headerStyle: React.CSSProperties = {
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
marginBottom: "1rem",
|
||||
color: "var(--text-on-dark, white)",
|
||||
};
|
||||
|
||||
const titleStyle: React.CSSProperties = {
|
||||
fontWeight: 600,
|
||||
fontSize: "1.25rem",
|
||||
};
|
||||
|
||||
const subtitleStyle: React.CSSProperties = {
|
||||
opacity: 0.85,
|
||||
fontSize: "0.9rem",
|
||||
};
|
||||
|
||||
const iframeStyle: React.CSSProperties = {
|
||||
flex: 1,
|
||||
width: "100%",
|
||||
border: "none",
|
||||
borderRadius: "0.75rem",
|
||||
background: "#05070a",
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div style={overlayStyle}>
|
||||
<div style={headerStyle}>
|
||||
<div>
|
||||
<div style={titleStyle}>{plugin.name}</div>
|
||||
<div style={subtitleStyle}>
|
||||
{plugin.description || t("plugins.noDescription", "No description provided.")}
|
||||
</div>
|
||||
</div>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
size="lg"
|
||||
onClick={onClose}
|
||||
aria-label={t("plugins.closeViewer", "Close plugin viewer")}
|
||||
>
|
||||
<LocalIcon icon="close-rounded" width="1.1rem" height="1.1rem" />
|
||||
</ActionIcon>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
src={iframeSrc}
|
||||
title={plugin.name}
|
||||
style={iframeStyle}
|
||||
allowFullScreen
|
||||
sandbox="allow-scripts allow-forms allow-same-origin allow-popups"
|
||||
/>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState, useRef, forwardRef, useEffect } from "react";
|
||||
import React, { useState, useRef, forwardRef, useEffect, useMemo } from "react";
|
||||
import { Stack, Divider, Menu, Indicator } from "@mantine/core";
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
@@ -34,7 +34,7 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
const location = useLocation();
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const { openFilesModal, isFilesModalOpen } = useFilesModalContext();
|
||||
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool } = useToolWorkflow();
|
||||
const { handleReaderToggle, handleToolSelect, selectedToolKey, leftPanelView, toolRegistry, readerMode, resetTool, toolAvailability } = useToolWorkflow();
|
||||
const { hasUnsavedChanges } = useNavigationState();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const { getToolNavigation } = useSidebarNavigation();
|
||||
@@ -119,14 +119,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
);
|
||||
};
|
||||
|
||||
const mainButtons: ButtonConfig[] = [
|
||||
const mainButtons: ButtonConfig[] = useMemo(() => [
|
||||
{
|
||||
id: 'read',
|
||||
name: t("quickAccess.reader", "Reader"),
|
||||
icon: <LocalIcon icon="menu-book-rounded" width="1.25rem" height="1.25rem" />,
|
||||
size: 'md',
|
||||
size: 'md' as const,
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
type: 'navigation' as const,
|
||||
onClick: () => {
|
||||
setActiveButton('read');
|
||||
handleReaderToggle();
|
||||
@@ -136,9 +136,9 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
id: 'automate',
|
||||
name: t("quickAccess.automate", "Automate"),
|
||||
icon: <LocalIcon icon="automation-outline" width="1.25rem" height="1.25rem" />,
|
||||
size: 'md',
|
||||
size: 'md' as const,
|
||||
isRound: false,
|
||||
type: 'navigation',
|
||||
type: 'navigation' as const,
|
||||
onClick: () => {
|
||||
setActiveButton('automate');
|
||||
// If already on automate tool, reset it directly
|
||||
@@ -149,7 +149,14 @@ const QuickAccessBar = forwardRef<HTMLDivElement>((_, ref) => {
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
].filter(button => {
|
||||
// Filter out buttons for disabled tools
|
||||
// 'read' is always available (viewer mode)
|
||||
if (button.id === 'read') return true;
|
||||
// Check if tool is actually available (not just present in registry)
|
||||
const availability = toolAvailability[button.id as keyof typeof toolAvailability];
|
||||
return availability?.available !== false;
|
||||
}), [t, setActiveButton, handleReaderToggle, selectedToolKey, resetTool, handleToolSelect, toolAvailability]);
|
||||
|
||||
const middleButtons: ButtonConfig[] = [
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ import { ActionIcon, Divider } from '@mantine/core';
|
||||
import '@app/components/shared/rightRail/RightRail.css';
|
||||
import { useToolWorkflow } from '@app/contexts/ToolWorkflowContext';
|
||||
import { useRightRail } from '@app/contexts/RightRailContext';
|
||||
import { useFileState, useFileSelection } from '@app/contexts/FileContext';
|
||||
import { useFileState, useFileSelection, useFileActions } from '@app/contexts/FileContext';
|
||||
import { useNavigationState } from '@app/contexts/NavigationContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
@@ -22,6 +22,7 @@ import LightModeIcon from '@mui/icons-material/LightMode';
|
||||
import { useSidebarContext } from '@app/contexts/SidebarContext';
|
||||
import { RightRailButtonConfig, RightRailRenderContext, RightRailSection } from '@app/types/rightRail';
|
||||
import { useRightRailTooltipSide } from '@app/hooks/useRightRailTooltipSide';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
|
||||
const SECTION_ORDER: RightRailSection[] = ['top', 'middle', 'bottom'];
|
||||
|
||||
@@ -59,6 +60,7 @@ export default function RightRail() {
|
||||
|
||||
const { selectors } = useFileState();
|
||||
const { selectedFiles, selectedFileIds } = useFileSelection();
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { signaturesApplied } = useSignature();
|
||||
|
||||
const activeFiles = selectors.getFiles();
|
||||
@@ -141,7 +143,7 @@ export default function RightRail() {
|
||||
alert('You have unapplied signatures. Please use "Apply Signatures" first before exporting.');
|
||||
return;
|
||||
}
|
||||
viewerContext?.exportActions?.download();
|
||||
viewerContext?.exportActions?.download?.();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -150,34 +152,57 @@ export default function RightRail() {
|
||||
return;
|
||||
}
|
||||
|
||||
const filesToDownload = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
filesToDownload.forEach(file => {
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(file);
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(link.href);
|
||||
});
|
||||
const filesToExport = selectedFiles.length > 0 ? selectedFiles : activeFiles;
|
||||
const stubsToExport = selectedFiles.length > 0
|
||||
? selectors.getSelectedStirlingFileStubs()
|
||||
: selectors.getStirlingFileStubs();
|
||||
|
||||
if (filesToExport.length > 0) {
|
||||
for (let i = 0; i < filesToExport.length; i++) {
|
||||
const file = filesToExport[i];
|
||||
const stub = stubsToExport[i];
|
||||
console.log('[RightRail] Exporting file:', { fileName: file.name, stubId: stub?.id, localFilePath: stub?.localFilePath, isDirty: stub?.isDirty });
|
||||
const result = await downloadFile({
|
||||
data: file,
|
||||
filename: file.name,
|
||||
localPath: stub?.localFilePath
|
||||
});
|
||||
console.log('[RightRail] Export complete, checking dirty state:', { localFilePath: stub?.localFilePath, isDirty: stub?.isDirty, savedPath: result.savedPath });
|
||||
// Mark file as clean after successful save to disk
|
||||
if (stub && result.savedPath) {
|
||||
console.log('[RightRail] Marking file as clean:', stub.id);
|
||||
fileActions.updateStirlingFileStub(stub.id, {
|
||||
localFilePath: stub.localFilePath ?? result.savedPath,
|
||||
isDirty: false
|
||||
});
|
||||
} else {
|
||||
console.log('[RightRail] Skipping clean mark:', { savedPath: result.savedPath, isDirty: stub?.isDirty });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentView,
|
||||
selectedFiles,
|
||||
activeFiles,
|
||||
pageEditorFunctions,
|
||||
viewerContext,
|
||||
signaturesApplied
|
||||
signaturesApplied,
|
||||
selectors,
|
||||
fileActions,
|
||||
]);
|
||||
|
||||
const downloadTooltip = useMemo(() => {
|
||||
if (currentView === 'pageEditor') {
|
||||
return t('rightRail.exportAll', 'Export PDF');
|
||||
}
|
||||
if (currentView === 'viewer') {
|
||||
return terminology.download;
|
||||
}
|
||||
if (selectedCount > 0) {
|
||||
return terminology.downloadSelected;
|
||||
}
|
||||
return terminology.downloadAll;
|
||||
}, [currentView, selectedCount, t]);
|
||||
}, [currentView, selectedCount, t, terminology]);
|
||||
|
||||
return (
|
||||
<div ref={sidebarRefs.rightRailRef} className="right-rail" data-sidebar="right-rail">
|
||||
|
||||
@@ -24,9 +24,8 @@ const ToolChain: React.FC<ToolChainProps> = ({
|
||||
size = 'xs',
|
||||
color = 'var(--mantine-color-blue-7)'
|
||||
}) => {
|
||||
if (!toolChain || toolChain.length === 0) return null;
|
||||
|
||||
const { t } = useTranslation();
|
||||
if (!toolChain || toolChain.length === 0) return null;
|
||||
|
||||
const toolIds = toolChain.map(tool => tool.toolId);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { PageEditorFileDropdown } from '@app/components/shared/PageEditorFileDro
|
||||
import type { CustomWorkbenchViewInstance } from '@app/contexts/ToolWorkflowContext';
|
||||
import { FileDropdownMenu } from '@app/components/shared/FileDropdownMenu';
|
||||
import { usePageEditorDropdownState, PageEditorDropdownState } from '@app/components/pageEditor/hooks/usePageEditorDropdownState';
|
||||
import { FileId } from '@app/types/file';
|
||||
|
||||
|
||||
const viewOptionStyle: React.CSSProperties = {
|
||||
@@ -29,6 +30,7 @@ const createViewOptions = (
|
||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
|
||||
currentFileIndex: number,
|
||||
onFileSelect?: (index: number) => void,
|
||||
onFileRemove?: (fileId: FileId) => void,
|
||||
pageEditorState?: PageEditorDropdownState,
|
||||
customViews?: CustomWorkbenchViewInstance[]
|
||||
) => {
|
||||
@@ -37,8 +39,7 @@ const createViewOptions = (
|
||||
const isInViewer = currentView === 'viewer';
|
||||
const fileName = currentFile?.name || '';
|
||||
const viewerDisplayName = isInViewer && fileName ? fileName : 'Viewer';
|
||||
const hasMultipleFiles = activeFiles.length > 1;
|
||||
const showViewerDropdown = isInViewer && hasMultipleFiles;
|
||||
const showViewerDropdown = isInViewer;
|
||||
|
||||
const viewerOption = {
|
||||
label: showViewerDropdown ? (
|
||||
@@ -47,6 +48,7 @@ const createViewOptions = (
|
||||
activeFiles={activeFiles}
|
||||
currentFileIndex={currentFileIndex}
|
||||
onFileSelect={onFileSelect}
|
||||
onFileRemove={onFileRemove}
|
||||
switchingTo={switchingTo}
|
||||
viewOptionStyle={viewOptionStyle}
|
||||
/>
|
||||
@@ -132,6 +134,7 @@ interface TopControlsProps {
|
||||
activeFiles?: Array<{ fileId: string; name: string; versionNumber?: number }>;
|
||||
currentFileIndex?: number;
|
||||
onFileSelect?: (index: number) => void;
|
||||
onFileRemove?: (fileId: FileId) => void;
|
||||
}
|
||||
|
||||
const TopControls = ({
|
||||
@@ -141,6 +144,7 @@ const TopControls = ({
|
||||
activeFiles = [],
|
||||
currentFileIndex = 0,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
}: TopControlsProps) => {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
|
||||
@@ -176,9 +180,10 @@ const TopControls = ({
|
||||
activeFiles,
|
||||
currentFileIndex,
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
pageEditorState,
|
||||
customViews
|
||||
), [currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, pageEditorState, customViews]);
|
||||
), [currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect, onFileRemove, pageEditorState, customViews]);
|
||||
|
||||
return (
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { NavKey } from '@app/components/shared/config/types';
|
||||
import HotkeysSection from '@app/components/shared/config/configSections/HotkeysSection';
|
||||
import GeneralSection from '@app/components/shared/config/configSections/GeneralSection';
|
||||
import PluginSection from '@app/components/shared/config/configSections/PluginSection';
|
||||
|
||||
export interface ConfigNavItem {
|
||||
key: NavKey;
|
||||
@@ -53,6 +54,17 @@ export const useConfigNavSections = (
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: t('settings.plugins.sectionTitle', 'Extensions'),
|
||||
items: [
|
||||
{
|
||||
key: 'plugins',
|
||||
label: t('settings.plugins.label', 'Plugins'),
|
||||
icon: 'extension-rounded',
|
||||
component: <PluginSection />
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return sections;
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Paper, Text, Group, Stack, Badge, Divider, Avatar, Tooltip } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { usePluginRegistry } from "@app/contexts/PluginRegistryContext";
|
||||
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||
import { LocalIcon } from "@app/components/shared/LocalIcon";
|
||||
import apiClient from "@app/services/apiClient";
|
||||
|
||||
const PluginSection: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { plugins, loading, error } = usePluginRegistry();
|
||||
const { config } = useAppConfig();
|
||||
const pluginPath =
|
||||
config?.pluginsPath ?? (config?.basePath ? `${config.basePath}/customFiles/plugins/` : "customFiles/plugins/");
|
||||
const [iconStatus, setIconStatus] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
const toCheck = plugins.filter((plugin) => plugin.iconUrl && iconStatus[plugin.id] === undefined);
|
||||
|
||||
toCheck.forEach((plugin) => {
|
||||
const iconUrl = plugin.iconUrl!;
|
||||
apiClient
|
||||
.get(iconUrl, { responseType: "blob", suppressErrorToast: true })
|
||||
.then(() => {
|
||||
if (!active) return;
|
||||
setIconStatus((prev) => ({ ...prev, [plugin.id]: true }));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return;
|
||||
setIconStatus((prev) => ({ ...prev, [plugin.id]: false }));
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [plugins, iconStatus]);
|
||||
|
||||
const renderIcon = (plugin: ReturnType<typeof usePluginRegistry> extends { plugins: (infer T)[] } ? T : never) => {
|
||||
const isValid = iconStatus[plugin.id];
|
||||
if (plugin.iconUrl && isValid) {
|
||||
return <Avatar radius="md" w="36px" h="36px" src={plugin.iconUrl} alt="Plugin Icon" />;
|
||||
}
|
||||
return <LocalIcon icon="extension-outline" width="1.5rem" height="1.5rem" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Paper radius="md" p="md" withBorder style={{ background: "var(--modal-content-bg)" }}>
|
||||
<Group justify="space-between">
|
||||
<Text size="lg" fw={600}>
|
||||
{t("settings.plugins.title", "Plugins")}
|
||||
</Text>
|
||||
<Badge variant="outline" color="gray">
|
||||
{t("settings.plugins.count", "Installed plugins {{count}}", { count: plugins.length })}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed" mt="xs">
|
||||
{t("settings.plugins.description", "Browse, install, and configure extensions.")}
|
||||
</Text>
|
||||
</Paper>
|
||||
|
||||
{loading && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("settings.plugins.loading", "Loading plugins...")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{error && plugins.length > 0 && (
|
||||
<Text size="sm" c="red">
|
||||
{t("settings.plugins.error", "Failed to load plugins")}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{!loading && plugins.length === 0 && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("settings.plugins.empty", "No plugins found. Drop a plugin JAR in {{path}}.", { path: pluginPath })}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{plugins.map((plugin) => (
|
||||
<Paper key={plugin.id} radius="md" p="md" withBorder style={{ background: "var(--modal-content-bg)" }}>
|
||||
<Stack gap="sm">
|
||||
<Group align="center" gap="sm">
|
||||
{renderIcon(plugin)}
|
||||
<Stack gap="0">
|
||||
<Group gap="xs">
|
||||
{plugin.frontendLabel && (
|
||||
<Badge color="teal" variant="light">
|
||||
{plugin.frontendLabel}
|
||||
</Badge>
|
||||
)}
|
||||
{plugin.minHostVersion && (
|
||||
<Badge color="blue" variant="light">
|
||||
{t("settings.plugins.minHost", { defaultValue: "min. v{{version}}", version: plugin.minHostVersion })}
|
||||
</Badge>
|
||||
)}
|
||||
{plugin.version && (
|
||||
<Badge variant="outline" color="gray">
|
||||
{t("plugins.version", { defaultValue: "v{{version}}", version: plugin.version })}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text fw={600} c={plugin.hasFrontend ? "var(--mantine-color-blue-3)" : undefined}>
|
||||
{plugin.name}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Group>
|
||||
<Text size="sm" c="dimmed">
|
||||
{plugin.description || t("settings.plugins.noDescription", "No description")}
|
||||
</Text>
|
||||
{plugin.backendEndpoints.length > 0 && (
|
||||
<Group gap="xs">
|
||||
{plugin.backendEndpoints.map((endpoint) => (
|
||||
<Tooltip key={endpoint} label={endpoint} position="bottom" withArrow>
|
||||
<Badge variant="outline" color="cyan" style={{ fontSize: "0.7rem", letterSpacing: 0.4 }}>
|
||||
{endpoint.replace(/^\//, "").toUpperCase()}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Group>
|
||||
)}
|
||||
<Divider />
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("settings.plugins.author", "Author: {{author}}", {
|
||||
author: plugin.author || t("settings.plugins.unknownAuthor", "unknown"),
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
{plugin.jarCreatedAt && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("settings.plugins.createdAt", "Created on {{date}}", {
|
||||
date: new Date(plugin.jarCreatedAt).toLocaleString(),
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default PluginSection;
|
||||
@@ -29,6 +29,7 @@ export const VALID_NAV_KEYS = [
|
||||
'adminAudit',
|
||||
'adminUsage',
|
||||
'adminEndpoints',
|
||||
'plugins',
|
||||
] as const;
|
||||
|
||||
// Derive the type from the array
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useMemo, useRef } from "react";
|
||||
import { Box, Stack } from "@mantine/core";
|
||||
import React, { useEffect, useMemo, useRef } from "react";
|
||||
import { Box, Button, Stack } from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ToolRegistryEntry } from "@app/data/toolsTaxonomy";
|
||||
import "@app/components/tools/toolPicker/ToolPicker.css";
|
||||
@@ -12,6 +12,11 @@ import ToolButton from "@app/components/tools/toolPicker/ToolButton";
|
||||
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
|
||||
import { ToolId } from "@app/types/toolId";
|
||||
import { getSubcategoryLabel } from "@app/data/toolsTaxonomy";
|
||||
import { usePluginRegistry } from "@app/contexts/PluginRegistryContext";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import FitText from "@app/components/shared/FitText";
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
|
||||
interface ToolPickerProps {
|
||||
selectedToolKey: string | null;
|
||||
@@ -26,7 +31,10 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
const scrollableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { sections: visibleSections } = useToolSections(filteredTools);
|
||||
const { favoriteTools, toolRegistry } = useToolWorkflow();
|
||||
const {
|
||||
favoriteTools,
|
||||
toolRegistry,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const favoriteToolItems = useFavoriteToolItems(favoriteTools, toolRegistry);
|
||||
|
||||
@@ -47,6 +55,19 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
[visibleSections]
|
||||
);
|
||||
|
||||
const { plugins } = usePluginRegistry();
|
||||
const navigate = useNavigate();
|
||||
const pluginItems = useMemo(
|
||||
() => plugins.filter((plugin) => plugin.hasFrontend && plugin.frontendUrl),
|
||||
[plugins],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
pluginItems.forEach((plugin) => {
|
||||
console.debug(`[ToolPicker] Rendering icon for plugin ${plugin.id}:`, plugin.icon);
|
||||
});
|
||||
}, [pluginItems]);
|
||||
|
||||
// Build flat list by subcategory for search mode
|
||||
const emptyFilteredTools: ToolPickerProps['filteredTools'] = [];
|
||||
const effectiveFilteredForSearch: ToolPickerProps['filteredTools'] = isSearching ? filteredTools : emptyFilteredTools;
|
||||
@@ -133,6 +154,68 @@ const ToolPicker = ({ selectedToolKey, onSelect, filteredTools, isSearching = fa
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{pluginItems.length > 0 && (
|
||||
<Box w="100%">
|
||||
<div style={headerTextStyle}>{t("plugins.shortTitle", "Plugins")}</div>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: "0.375rem" }}>
|
||||
{pluginItems.map((plugin) => (
|
||||
<div key={`plugin-${plugin.id}`} className="tool-button-container">
|
||||
<Tooltip content={plugin.description} position="right" arrow={true} delay={500}>
|
||||
<Button
|
||||
component="a"
|
||||
key={`plugin-${plugin.id}`}
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
radius="md"
|
||||
fullWidth
|
||||
className="tool-button"
|
||||
justify="flex-start"
|
||||
onClick={() => {
|
||||
console.debug(`[ToolPicker] Navigating to plugin ${plugin.id}`);
|
||||
navigate(`/plugins/${plugin.id}`, { state: { plugin } });
|
||||
}}
|
||||
data-tour={`plugin-button-${plugin.id}`}
|
||||
styles={{
|
||||
root: {
|
||||
borderRadius: 0,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
overflow: 'visible'
|
||||
},
|
||||
label: { overflow: 'visible' }
|
||||
}}
|
||||
>
|
||||
<>
|
||||
<div
|
||||
className="tool-button-icon"
|
||||
style={{
|
||||
transform: "scale(0.8)",
|
||||
transformOrigin: "center",
|
||||
opacity: 1,
|
||||
color: "var(--tools-text-and-icon-color)",
|
||||
marginRight: "0.5rem"
|
||||
}}
|
||||
>
|
||||
<LocalIcon icon={typeof plugin.icon === 'string' ? plugin.icon : 'extension'} width="24" height="24" />
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', flex: 1, overflow: 'visible' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', width: '100%' }}>
|
||||
<FitText
|
||||
text={plugin.name}
|
||||
lines={1}
|
||||
minimumFontScale={0.8}
|
||||
as="span"
|
||||
style={{ display: 'inline-block', maxWidth: '100%', opacity: 1 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
{allSection && allSection.subcategories.map((sc: SubcategoryGroup) => (
|
||||
<Box key={sc.subcategoryId} w="100%">
|
||||
<div style={headerTextStyle}>
|
||||
|
||||
@@ -74,6 +74,11 @@ const CompareDocumentPane = ({
|
||||
}
|
||||
}, [zoom]);
|
||||
|
||||
const renderedPageNumbers = useMemo(
|
||||
() => new Set(pages.map((p) => p.pageNumber)),
|
||||
[pages]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="compare-pane">
|
||||
<div className="compare-header">
|
||||
@@ -88,7 +93,7 @@ const CompareDocumentPane = ({
|
||||
placeholder={dropdownPlaceholder ?? null}
|
||||
className={pane === 'comparison' ? 'compare-changes-select--comparison' : undefined}
|
||||
onNavigate={onNavigateChange}
|
||||
renderedPageNumbers={useMemo(() => new Set(pages.map(p => p.pageNumber)), [pages])}
|
||||
renderedPageNumbers={renderedPageNumbers}
|
||||
/>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
+12
-14
@@ -15,6 +15,7 @@ import ErrorNotification from '@app/components/tools/shared/ErrorNotification';
|
||||
import ResultsPreview from '@app/components/tools/shared/ResultsPreview';
|
||||
import BookmarkEditor from '@app/components/tools/editTableOfContents/BookmarkEditor';
|
||||
import { useFileActionTerminology } from '@app/hooks/useFileActionTerminology';
|
||||
import { downloadFromUrl } from '@app/services/downloadService';
|
||||
|
||||
export interface EditTableOfContentsWorkbenchViewData {
|
||||
bookmarks: BookmarkNode[];
|
||||
@@ -42,6 +43,16 @@ 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 (
|
||||
@@ -62,8 +73,6 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
|
||||
bookmarks,
|
||||
selectedFileName,
|
||||
disabled,
|
||||
files,
|
||||
thumbnails,
|
||||
downloadUrl,
|
||||
downloadFilename,
|
||||
errorMessage,
|
||||
@@ -77,15 +86,6 @@ 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
|
||||
);
|
||||
@@ -177,10 +177,8 @@ const EditTableOfContentsWorkbenchView = ({ data }: EditTableOfContentsWorkbench
|
||||
<Group justify="flex-end" gap="sm">
|
||||
{downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={downloadUrl}
|
||||
download={downloadFilename ?? undefined}
|
||||
leftSection={<LocalIcon icon='download-rounded' />}
|
||||
onClick={() => downloadFromUrl(downloadUrl, downloadFilename ?? "download")}
|
||||
>
|
||||
{terminology.download}
|
||||
</Button>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useMemo } from 'react';
|
||||
import { Alert, Button, Group, Loader, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { GetPdfInfoOperationHook } from '@app/hooks/tools/getPdfInfo/useGetPdfInfoOperation';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
|
||||
interface GetPdfInfoResultsProps {
|
||||
operation: GetPdfInfoOperationHook;
|
||||
@@ -21,14 +22,7 @@ const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoRes
|
||||
const selectedDownloadLabel = useMemo(() => t('getPdfInfo.downloadJson', 'Download JSON'), [t]);
|
||||
|
||||
const handleDownload = useCallback((file: File) => {
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
void downloadFile({ data: file, filename: file.name });
|
||||
}, []);
|
||||
|
||||
if (isLoading && operation.results.length === 0) {
|
||||
@@ -76,4 +70,3 @@ const GetPdfInfoResults = ({ operation, isLoading, errorMessage }: GetPdfInfoRes
|
||||
|
||||
export default GetPdfInfoResults;
|
||||
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEffect, useRef, useCallback } from 'react';
|
||||
import { Button, Stack, Text, Group, Divider } from '@mantine/core';
|
||||
import HighlightAltIcon from '@mui/icons-material/HighlightAlt';
|
||||
import CropFreeIcon from '@mui/icons-material/CropFree';
|
||||
import { Button, Stack, Text, Divider, ColorInput } from '@mantine/core';
|
||||
import AutoFixHighIcon from '@mui/icons-material/AutoFixHigh';
|
||||
import { useRedaction, useRedactionMode } from '@app/contexts/RedactionContext';
|
||||
import { useViewer } from '@app/contexts/ViewerContext';
|
||||
import { useSignature } from '@app/contexts/SignatureContext';
|
||||
@@ -19,8 +18,8 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Use our RedactionContext which bridges to EmbedPDF
|
||||
const { activateTextSelection, activateMarquee, redactionsApplied, setActiveType } = useRedaction();
|
||||
const { pendingCount, activeType, isBridgeReady } = useRedactionMode();
|
||||
const { activateManualRedact, redactionsApplied, setActiveType, setManualRedactColor } = useRedaction();
|
||||
const { pendingCount, activeType, isBridgeReady, isRedacting, manualRedactColor } = useRedactionMode();
|
||||
|
||||
// Get viewer context to manage annotation mode and save changes
|
||||
const { isAnnotationMode, setAnnotationMode, applyChanges, activeFileIndex } = useViewer();
|
||||
@@ -28,9 +27,8 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
|
||||
// Get signature context to deactivate annotation tools when switching to redaction
|
||||
const { signatureApiRef } = useSignature();
|
||||
|
||||
// Check which tool is active based on activeType
|
||||
const isSelectionActive = activeType === 'redactSelection';
|
||||
const isMarqueeActive = activeType === 'marqueeRedact';
|
||||
// Check if redaction mode is active
|
||||
const isRedactActive = isRedacting;
|
||||
|
||||
// Track if we've auto-activated for the current bridge session
|
||||
const hasAutoActivated = useRef(false);
|
||||
@@ -47,12 +45,12 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
|
||||
const timer = setTimeout(() => {
|
||||
// Deactivate annotation mode to show redaction layer
|
||||
setAnnotationMode(false);
|
||||
// Pre-select the Mark Text tool
|
||||
activateTextSelection();
|
||||
// Pre-select the Redaction tool
|
||||
activateManualRedact();
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isBridgeReady, disabled, activateTextSelection, setAnnotationMode]);
|
||||
}, [isBridgeReady, disabled, activateManualRedact, setAnnotationMode]);
|
||||
|
||||
// Reset auto-activation flag when disabled changes or bridge becomes not ready
|
||||
useEffect(() => {
|
||||
@@ -68,18 +66,16 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
|
||||
prevFileIndexRef.current = activeFileIndex;
|
||||
|
||||
// Reset active type to null when switching files
|
||||
// This makes both buttons appear unselected, requiring the user to re-click
|
||||
// which ensures proper activation on the new PDF
|
||||
if (isSelectionActive || isMarqueeActive) {
|
||||
if (activeType) {
|
||||
setActiveType(null);
|
||||
}
|
||||
|
||||
// Reset auto-activation flag so new file can auto-activate
|
||||
hasAutoActivated.current = false;
|
||||
}
|
||||
}, [activeFileIndex, isSelectionActive, isMarqueeActive, setActiveType]);
|
||||
}, [activeFileIndex, activeType, setActiveType]);
|
||||
|
||||
const handleSelectionClick = () => {
|
||||
const handleRedactClick = () => {
|
||||
// Deactivate annotation mode and tools to switch to redaction layer
|
||||
if (isAnnotationMode) {
|
||||
setAnnotationMode(false);
|
||||
@@ -93,34 +89,7 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
|
||||
}
|
||||
}
|
||||
|
||||
if (isSelectionActive && !isAnnotationMode) {
|
||||
// If already active and not coming from annotation mode, switch to marquee
|
||||
activateMarquee();
|
||||
} else {
|
||||
activateTextSelection();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMarqueeClick = () => {
|
||||
// Deactivate annotation mode and tools to switch to redaction layer
|
||||
if (isAnnotationMode) {
|
||||
setAnnotationMode(false);
|
||||
// Deactivate any active annotation tools (like draw)
|
||||
if (signatureApiRef?.current) {
|
||||
try {
|
||||
signatureApiRef.current.deactivateTools();
|
||||
} catch (error) {
|
||||
console.log('Unable to deactivate annotation tools:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isMarqueeActive && !isAnnotationMode) {
|
||||
// If already active and not coming from annotation mode, switch to selection
|
||||
activateTextSelection();
|
||||
} else {
|
||||
activateMarquee();
|
||||
}
|
||||
activateManualRedact();
|
||||
};
|
||||
|
||||
// Handle saving changes - this will apply pending redactions and save to file
|
||||
@@ -149,43 +118,27 @@ export default function ManualRedactionControls({ disabled = false }: ManualReda
|
||||
{t('redact.manual.instructions', 'Select text or draw areas on the PDF to mark content for redaction.')}
|
||||
</Text>
|
||||
|
||||
<Group gap="sm" grow wrap="nowrap">
|
||||
{/* Mark Text Selection Tool */}
|
||||
<Button
|
||||
variant={isSelectionActive && !isAnnotationMode ? 'filled' : 'outline'}
|
||||
color={isSelectionActive && !isAnnotationMode ? 'blue' : 'gray'}
|
||||
leftSection={<HighlightAltIcon style={{ fontSize: 18, flexShrink: 0 }} />}
|
||||
onClick={handleSelectionClick}
|
||||
disabled={disabled || !isApiReady}
|
||||
size="sm"
|
||||
styles={{
|
||||
root: {
|
||||
minWidth: 0,
|
||||
},
|
||||
label: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||||
}}
|
||||
>
|
||||
{t('redact.manual.markText', 'Mark Text')}
|
||||
</Button>
|
||||
<ColorInput
|
||||
label={t('redact.manual.colorLabel', 'Redaction Colour')}
|
||||
value={manualRedactColor}
|
||||
onChange={setManualRedactColor}
|
||||
disabled={disabled || !isApiReady}
|
||||
size="sm"
|
||||
format="hex"
|
||||
popoverProps={{ withinPortal: true }}
|
||||
/>
|
||||
|
||||
{/* Mark Area (Marquee) Tool */}
|
||||
<Button
|
||||
variant={isMarqueeActive && !isAnnotationMode ? 'filled' : 'outline'}
|
||||
color={isMarqueeActive && !isAnnotationMode ? 'blue' : 'gray'}
|
||||
leftSection={<CropFreeIcon style={{ fontSize: 18, flexShrink: 0 }} />}
|
||||
onClick={handleMarqueeClick}
|
||||
disabled={disabled || !isApiReady}
|
||||
size="sm"
|
||||
styles={{
|
||||
root: {
|
||||
minWidth: 0,
|
||||
},
|
||||
label: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' },
|
||||
}}
|
||||
>
|
||||
{t('redact.manual.markArea', 'Mark Area')}
|
||||
</Button>
|
||||
</Group>
|
||||
<Button
|
||||
variant={isRedactActive && !isAnnotationMode ? 'filled' : 'outline'}
|
||||
color={isRedactActive && !isAnnotationMode ? 'blue' : 'gray'}
|
||||
leftSection={<AutoFixHighIcon style={{ fontSize: 18, flexShrink: 0 }} />}
|
||||
onClick={handleRedactClick}
|
||||
disabled={disabled || !isApiReady}
|
||||
fullWidth
|
||||
size="sm"
|
||||
>
|
||||
{isRedactActive && !isAnnotationMode ? t('redact.manual.active', 'Redaction Mode Active') : t('redact.manual.activate', 'Activate Redaction Tool')}
|
||||
</Button>
|
||||
|
||||
{/* Save Changes Button - applies pending redactions and saves to file */}
|
||||
<Button
|
||||
|
||||
@@ -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,9 +14,7 @@ export function createFilesToolStep(
|
||||
createStep: (title: string, props: any, children?: React.ReactNode) => React.ReactElement,
|
||||
props: FilesToolStepProps
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return createStep(t("files.title", "Files"), {
|
||||
return createStep(i18n.t("files.title", "Files"), {
|
||||
isVisible: true,
|
||||
isCollapsed: props.isCollapsed,
|
||||
onCollapsedClick: props.onCollapsedClick
|
||||
|
||||
@@ -9,6 +9,10 @@ import { ToolOperationHook } from "@app/hooks/tools/shared/useToolOperation";
|
||||
import { Tooltip } from "@app/components/shared/Tooltip";
|
||||
import { useFileActionTerminology } from "@app/hooks/useFileActionTerminology";
|
||||
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;
|
||||
@@ -34,6 +38,8 @@ function ReviewStepContent<TParams = unknown>({
|
||||
const icons = useFileActionIcons();
|
||||
const DownloadIcon = icons.download;
|
||||
const stepRef = useRef<HTMLDivElement>(null);
|
||||
const { actions: fileActions } = useFileActions();
|
||||
const { selectors } = useFileState();
|
||||
|
||||
const handleUndo = async () => {
|
||||
try {
|
||||
@@ -50,6 +56,31 @@ function ReviewStepContent<TParams = unknown>({
|
||||
thumbnail: operation.thumbnails[index],
|
||||
})) || [];
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!operation.downloadUrl) return;
|
||||
try {
|
||||
await saveOperationResults({
|
||||
downloadUrl: operation.downloadUrl,
|
||||
downloadFilename: operation.downloadFilename || "download",
|
||||
downloadLocalPath: operation.downloadLocalPath,
|
||||
outputFileIds: operation.outputFileIds,
|
||||
getFile: (fileId) => selectors.getFile(fileId as FileId),
|
||||
getStub: (fileId) => selectors.getStirlingFileStub(fileId as FileId),
|
||||
markSaved: (fileId, savedPath) => {
|
||||
const stub = selectors.getStirlingFileStub(fileId as FileId);
|
||||
fileActions.updateStirlingFileStub(fileId as FileId, {
|
||||
localFilePath: stub?.localFilePath ?? savedPath,
|
||||
isDirty: false
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.error("[ReviewToolStep] Failed to download file:", message);
|
||||
alert(`Failed to download file: ${message}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Auto-scroll to bottom when content appears
|
||||
useEffect(() => {
|
||||
if (stepRef.current && (previewFiles.length > 0 || operation.downloadUrl || operation.errorMessage)) {
|
||||
@@ -92,13 +123,11 @@ function ReviewStepContent<TParams = unknown>({
|
||||
)}
|
||||
{operation.downloadUrl && (
|
||||
<Button
|
||||
component="a"
|
||||
href={operation.downloadUrl}
|
||||
download={operation.downloadFilename}
|
||||
leftSection={<DownloadIcon />}
|
||||
color="blue"
|
||||
fullWidth
|
||||
mb="md"
|
||||
onClick={handleDownload}
|
||||
>
|
||||
{terminology.download}
|
||||
</Button>
|
||||
@@ -123,10 +152,8 @@ export function createReviewToolStep<TParams = unknown>(
|
||||
) => React.ReactElement,
|
||||
props: ReviewToolStepProps<TParams>
|
||||
): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return createStep(
|
||||
t("review", "Review"),
|
||||
i18n.t("review", "Review"),
|
||||
{
|
||||
isVisible: props.isVisible,
|
||||
isCollapsed: props.isCollapsed,
|
||||
|
||||
@@ -80,8 +80,6 @@ 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
|
||||
@@ -91,6 +89,8 @@ const ToolStep = ({
|
||||
return parent ? parent.visibleStepCount >= 3 : false; // Auto-detect
|
||||
}, [showNumber, parent]);
|
||||
|
||||
if (!isVisible) return null;
|
||||
|
||||
const stepNumber = _stepNumber;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { downloadFromUrl } from "@app/services/downloadService";
|
||||
|
||||
export type ShowJsTokenType = "kw" | "str" | "num" | "com" | "plain";
|
||||
export type ShowJsToken = { type: ShowJsTokenType; text: string };
|
||||
|
||||
@@ -372,11 +374,6 @@ export async function copyTextToClipboard(text: string, fallbackElement?: HTMLEl
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerDownload(url: string, filename: string): void {
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
export async function triggerDownload(url: string, filename: string): Promise<void> {
|
||||
await downloadFromUrl(url, filename);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { ValidateSignatureOperationHook } from '@app/hooks/tools/validateSi
|
||||
import '@app/components/tools/validateSignature/reportView/styles.css';
|
||||
import FitText from '@app/components/shared/FitText';
|
||||
import { SuggestedToolsSection } from '@app/components/tools/shared/SuggestedToolsSection';
|
||||
import { downloadFile } from '@app/services/downloadService';
|
||||
|
||||
interface ValidateSignatureResultsProps {
|
||||
operation: ValidateSignatureOperationHook;
|
||||
@@ -80,14 +81,7 @@ const ValidateSignatureResults = ({
|
||||
];
|
||||
|
||||
const handleDownload = useCallback((file: File) => {
|
||||
const blobUrl = URL.createObjectURL(file);
|
||||
const link = document.createElement('a');
|
||||
link.href = blobUrl;
|
||||
link.download = file.name;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
void downloadFile({ data: file, filename: file.name });
|
||||
}, []);
|
||||
|
||||
// Show the big loader only while we're still waiting for the first results.
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
AnnotationEvent,
|
||||
AnnotationPatch,
|
||||
} from '@app/components/viewer/viewerTypes';
|
||||
import { useDocumentReady } from '@app/components/viewer/hooks/useDocumentReady';
|
||||
|
||||
type NoteIcon = NonNullable<AnnotationToolOptions['icon']>;
|
||||
|
||||
@@ -290,6 +291,7 @@ 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) =>
|
||||
@@ -323,6 +325,7 @@ 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;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user