mirror of
https://github.com/Stirling-Tools/Stirling-PDF.git
synced 2026-09-03 13:20:08 +03:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7eafe68e58 |
@@ -1,104 +0,0 @@
|
||||
# Frontend TODO: Revocation Status Migration
|
||||
|
||||
## Background
|
||||
The backend has removed the deprecated `notRevoked` boolean field in favor of `revocationStatus` string field.
|
||||
|
||||
**revocationStatus values**:
|
||||
- `"not-checked"` - revocation checking was disabled
|
||||
- `"good"` - certificate was checked and is not revoked
|
||||
- `"revoked"` - certificate is revoked
|
||||
- `"soft-fail"` - revocation status couldn't be determined (network error, etc.)
|
||||
- `"unknown"` - other failure scenarios
|
||||
|
||||
## Files That Need Changes
|
||||
|
||||
### 1. `/frontend/src/hooks/tools/validateSignature/utils/signatureUtils.ts`
|
||||
|
||||
Add mappings for new backend fields in `normalizeBackendResult()`:
|
||||
```typescript
|
||||
export const normalizeBackendResult = (
|
||||
item: SignatureValidationBackendResult,
|
||||
stirlingFile: StirlingFile,
|
||||
index: number
|
||||
): SignatureValidationSignature => ({
|
||||
id: `${stirlingFile.fileId}-${index}`,
|
||||
valid: Boolean(item.valid),
|
||||
chainValid: Boolean(item.chainValid),
|
||||
trustValid: Boolean(item.trustValid),
|
||||
chainValidationError: item.chainValidationError ?? null, // ADD THIS
|
||||
certPathLength: item.certPathLength ?? null, // ADD THIS
|
||||
notExpired: Boolean(item.notExpired),
|
||||
revocationChecked: item.revocationChecked ?? null, // ADD THIS
|
||||
revocationStatus: item.revocationStatus ?? null, // ADD THIS
|
||||
validationTimeSource: item.validationTimeSource ?? null, // ADD THIS
|
||||
signerName: coerceString(item.signerName),
|
||||
// ... rest of fields
|
||||
})
|
||||
```
|
||||
|
||||
### 2. `/frontend/src/hooks/tools/validateSignature/utils/signatureStatus.ts`
|
||||
|
||||
**Current code** (lines 42-43):
|
||||
```typescript
|
||||
// Use new revocationStatus field if available, fallback to notRevoked for backward compatibility
|
||||
const revStatus = signature.revocationStatus || (signature.notRevoked ? 'good' : 'unknown');
|
||||
```
|
||||
|
||||
**Change to**:
|
||||
```typescript
|
||||
const revStatus = signature.revocationStatus || 'unknown';
|
||||
```
|
||||
|
||||
### 3. `/frontend/src/hooks/tools/validateSignature/utils/signatureCsv.ts`
|
||||
|
||||
**Current code** (lines 12, 42):
|
||||
```typescript
|
||||
'notRevoked', // line 12 in CSV header
|
||||
booleanToString(signature.notRevoked), // line 42 in data row
|
||||
```
|
||||
|
||||
**Recommended change** - replace with detailed status:
|
||||
```typescript
|
||||
// Header:
|
||||
'revocationStatus',
|
||||
|
||||
// Data:
|
||||
signature.revocationStatus || 'unknown',
|
||||
```
|
||||
|
||||
### 4. `/frontend/src/hooks/tools/validateSignature/utils/reportStatus.ts`
|
||||
|
||||
**Current code** (line 24):
|
||||
```typescript
|
||||
(sig) => sig.valid && sig.chainValid && sig.trustValid && sig.notExpired && sig.notRevoked
|
||||
```
|
||||
|
||||
**Change to**:
|
||||
```typescript
|
||||
(sig) => sig.valid && sig.chainValid && sig.trustValid && sig.notExpired && sig.revocationStatus === 'good'
|
||||
```
|
||||
|
||||
### 5. `/frontend/src/components/tools/validateSignature/ValidateSignatureResults.tsx`
|
||||
|
||||
**Current code** (line 33):
|
||||
```typescript
|
||||
signature.notRevoked;
|
||||
```
|
||||
|
||||
**Change to**:
|
||||
```typescript
|
||||
signature.revocationStatus === 'good'
|
||||
```
|
||||
|
||||
## Migration Pattern
|
||||
|
||||
**For boolean contexts** (if statements, filters):
|
||||
```typescript
|
||||
// Old: signature.notRevoked
|
||||
// New: signature.revocationStatus === 'good'
|
||||
```
|
||||
|
||||
**For display/logging**:
|
||||
```typescript
|
||||
signature.revocationStatus // "good" | "revoked" | "soft-fail" | "not-checked" | "unknown"
|
||||
```
|
||||
@@ -87,7 +87,8 @@ public class AutoJobAspect {
|
||||
},
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
resourceWeight,
|
||||
trackProgress);
|
||||
} else {
|
||||
// Use retry logic
|
||||
return executeWithRetries(
|
||||
@@ -220,7 +221,8 @@ public class AutoJobAspect {
|
||||
},
|
||||
timeout,
|
||||
queueable,
|
||||
resourceWeight);
|
||||
resourceWeight,
|
||||
trackProgress);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package stirling.software.common.context;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
/**
|
||||
* Holds contextual information for the currently executing job. Backed by a {@link ThreadLocal} so
|
||||
* worker threads can retrieve the job ID and progress tracking preference while processing
|
||||
* asynchronous work dispatched by {@link stirling.software.common.service.JobExecutorService
|
||||
* JobExecutorService}.
|
||||
*/
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public final class JobContextHolder {
|
||||
|
||||
private static final ThreadLocal<String> JOB_ID = new ThreadLocal<>();
|
||||
private static final ThreadLocal<Boolean> PROGRESS_ENABLED = new ThreadLocal<>();
|
||||
|
||||
/** Store context for the current thread. */
|
||||
public static void setContext(String jobId, boolean progressEnabled) {
|
||||
if (jobId == null) {
|
||||
clear();
|
||||
return;
|
||||
}
|
||||
JOB_ID.set(jobId);
|
||||
PROGRESS_ENABLED.set(progressEnabled);
|
||||
}
|
||||
|
||||
/** Get the job ID bound to the current thread, or {@code null} if none. */
|
||||
public static String getJobId() {
|
||||
return JOB_ID.get();
|
||||
}
|
||||
|
||||
/** Whether progress tracking is enabled for the current job (defaults to {@code false}). */
|
||||
public static boolean isProgressEnabled() {
|
||||
Boolean enabled = PROGRESS_ENABLED.get();
|
||||
return enabled != null && enabled;
|
||||
}
|
||||
|
||||
/** Remove all context associated with the current thread. */
|
||||
public static void clear() {
|
||||
JOB_ID.remove();
|
||||
PROGRESS_ENABLED.remove();
|
||||
}
|
||||
}
|
||||
@@ -120,7 +120,6 @@ public class ApplicationProperties {
|
||||
private String loginMethod = "all";
|
||||
private String customGlobalAPIKey;
|
||||
private Jwt jwt = new Jwt();
|
||||
private Validation validation = new Validation();
|
||||
|
||||
public Boolean isAltLogin() {
|
||||
return saml2.getEnabled() || oauth2.getEnabled();
|
||||
@@ -309,41 +308,6 @@ public class ApplicationProperties {
|
||||
private int keyRetentionDays = 7;
|
||||
private boolean secureCookie;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Validation {
|
||||
private Trust trust = new Trust();
|
||||
private boolean allowAIA = false;
|
||||
private Aatl aatl = new Aatl();
|
||||
private Eutl eutl = new Eutl();
|
||||
private Revocation revocation = new Revocation();
|
||||
|
||||
@Data
|
||||
public static class Trust {
|
||||
private boolean serverAsAnchor = true;
|
||||
private boolean useSystemTrust = false;
|
||||
private boolean useMozillaBundle = false;
|
||||
private boolean useAATL = false;
|
||||
private boolean useEUTL = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Aatl {
|
||||
private String url = "https://trustlist.adobe.com/tl.pdf";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Eutl {
|
||||
private String lotlUrl = "https://ec.europa.eu/tools/lotl/eu-lotl.xml";
|
||||
private boolean acceptTransitional = false;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class Revocation {
|
||||
private String mode = "none";
|
||||
private boolean hardFail = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Data
|
||||
|
||||
@@ -41,6 +41,18 @@ public class JobResult {
|
||||
/** The actual result object, if not a file */
|
||||
private Object result;
|
||||
|
||||
/** Whether detailed progress tracking is enabled for this job. */
|
||||
@Builder.Default private boolean trackProgress = true;
|
||||
|
||||
/** Most recent percentage update (0-100) if progress tracking is enabled. */
|
||||
private Integer progressPercent;
|
||||
|
||||
/** Human readable progress message (e.g. current stage) when progress is enabled. */
|
||||
private String progressMessage;
|
||||
|
||||
/** Timestamp of the last progress update. */
|
||||
private LocalDateTime progressUpdatedAt;
|
||||
|
||||
/**
|
||||
* Notes attached to this job for tracking purposes. Uses CopyOnWriteArrayList for thread safety
|
||||
* when notes are added concurrently.
|
||||
@@ -54,11 +66,28 @@ public class JobResult {
|
||||
* @return A new JobResult
|
||||
*/
|
||||
public static JobResult createNew(String jobId) {
|
||||
return JobResult.builder()
|
||||
.jobId(jobId)
|
||||
.complete(false)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.build();
|
||||
return createNew(jobId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new JobResult with the given job ID and progress tracking preference.
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @param trackProgress Whether detailed progress should be tracked
|
||||
* @return A new JobResult
|
||||
*/
|
||||
public static JobResult createNew(String jobId, boolean trackProgress) {
|
||||
JobResult result =
|
||||
JobResult.builder()
|
||||
.jobId(jobId)
|
||||
.complete(false)
|
||||
.createdAt(LocalDateTime.now())
|
||||
.trackProgress(trackProgress)
|
||||
.build();
|
||||
if (trackProgress) {
|
||||
result.updateProgressInternal(0, "Pending");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,6 +99,9 @@ public class JobResult {
|
||||
this.complete = true;
|
||||
this.result = result;
|
||||
this.completedAt = LocalDateTime.now();
|
||||
if (trackProgress) {
|
||||
updateProgressInternal(100, "Completed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -81,6 +113,9 @@ public class JobResult {
|
||||
this.complete = true;
|
||||
this.error = error;
|
||||
this.completedAt = LocalDateTime.now();
|
||||
if (trackProgress) {
|
||||
updateProgressInternal(100, error != null ? error : "Failed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,6 +127,9 @@ public class JobResult {
|
||||
this.complete = true;
|
||||
this.resultFiles = new ArrayList<>(resultFiles);
|
||||
this.completedAt = LocalDateTime.now();
|
||||
if (trackProgress) {
|
||||
updateProgressInternal(100, "Completed");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,4 +199,26 @@ public class JobResult {
|
||||
public List<String> getNotes() {
|
||||
return Collections.unmodifiableList(notes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the progress information if tracking is enabled.
|
||||
*
|
||||
* @param percent The percent complete (0-100)
|
||||
* @param message Optional descriptive message
|
||||
*/
|
||||
public void updateProgress(int percent, String message) {
|
||||
if (!trackProgress) {
|
||||
return;
|
||||
}
|
||||
updateProgressInternal(percent, message);
|
||||
}
|
||||
|
||||
private void updateProgressInternal(int percent, String message) {
|
||||
int clamped = Math.min(100, Math.max(0, percent));
|
||||
this.progressPercent = clamped;
|
||||
if (message != null && !message.isBlank()) {
|
||||
this.progressMessage = message;
|
||||
}
|
||||
this.progressUpdatedAt = LocalDateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
+120
-24
@@ -20,6 +20,7 @@ import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.context.JobContextHolder;
|
||||
import stirling.software.common.model.job.JobResponse;
|
||||
import stirling.software.common.util.ExecutorFactory;
|
||||
|
||||
@@ -65,7 +66,7 @@ public class JobExecutorService {
|
||||
* @return The response
|
||||
*/
|
||||
public ResponseEntity<?> runJobGeneric(boolean async, Supplier<Object> work) {
|
||||
return runJobGeneric(async, work, -1);
|
||||
return runJobGeneric(async, work, -1, false, 50, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +79,7 @@ public class JobExecutorService {
|
||||
*/
|
||||
public ResponseEntity<?> runJobGeneric(
|
||||
boolean async, Supplier<Object> work, long customTimeoutMs) {
|
||||
return runJobGeneric(async, work, customTimeoutMs, false, 50);
|
||||
return runJobGeneric(async, work, customTimeoutMs, false, 50, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -96,7 +97,8 @@ public class JobExecutorService {
|
||||
Supplier<Object> work,
|
||||
long customTimeoutMs,
|
||||
boolean queueable,
|
||||
int resourceWeight) {
|
||||
int resourceWeight,
|
||||
boolean trackProgress) {
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
|
||||
// Store the job ID in the request for potential use by other components
|
||||
@@ -138,6 +140,8 @@ public class JobExecutorService {
|
||||
&& // Only async jobs can be queued
|
||||
resourceMonitor.shouldQueueJob(resourceWeight);
|
||||
|
||||
boolean enableProgress = async && trackProgress;
|
||||
|
||||
if (shouldQueue) {
|
||||
// Queue the job instead of executing immediately
|
||||
log.debug(
|
||||
@@ -145,13 +149,18 @@ public class JobExecutorService {
|
||||
jobId,
|
||||
resourceWeight);
|
||||
|
||||
taskManager.createTask(jobId);
|
||||
taskManager.createTask(jobId, trackProgress);
|
||||
|
||||
Supplier<Object> contextualWork = withJobContext(jobId, enableProgress, work);
|
||||
|
||||
// Create a specialized wrapper that updates the TaskManager
|
||||
Supplier<Object> wrappedWork =
|
||||
() -> {
|
||||
try {
|
||||
Object result = work.get();
|
||||
if (enableProgress) {
|
||||
taskManager.updateProgress(jobId, 5, null);
|
||||
}
|
||||
Object result = contextualWork.get();
|
||||
processJobResult(jobId, result);
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
@@ -169,24 +178,49 @@ public class JobExecutorService {
|
||||
// Return immediately with job ID
|
||||
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
|
||||
} else if (async) {
|
||||
taskManager.createTask(jobId);
|
||||
taskManager.createTask(jobId, trackProgress);
|
||||
executor.execute(
|
||||
() -> {
|
||||
try {
|
||||
log.debug(
|
||||
"Running async job {} with timeout {} ms", jobId, timeoutToUse);
|
||||
() ->
|
||||
runWithJobContext(
|
||||
jobId,
|
||||
enableProgress,
|
||||
() -> {
|
||||
try {
|
||||
log.debug(
|
||||
"Running async job {} with timeout {} ms",
|
||||
jobId,
|
||||
timeoutToUse);
|
||||
|
||||
// Execute with timeout
|
||||
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
|
||||
processJobResult(jobId, result);
|
||||
} catch (TimeoutException te) {
|
||||
log.error("Job {} timed out after {} ms", jobId, timeoutToUse);
|
||||
taskManager.setError(jobId, "Job timed out");
|
||||
} catch (Exception e) {
|
||||
log.error("Error executing job {}: {}", jobId, e.getMessage(), e);
|
||||
taskManager.setError(jobId, e.getMessage());
|
||||
}
|
||||
});
|
||||
Supplier<Object> contextualWork =
|
||||
withJobContext(jobId, enableProgress, work);
|
||||
|
||||
if (enableProgress) {
|
||||
taskManager.updateProgress(jobId, 5, null);
|
||||
}
|
||||
|
||||
// Execute with timeout
|
||||
Object result =
|
||||
executeWithTimeout(
|
||||
contextualWork,
|
||||
timeoutToUse,
|
||||
jobId,
|
||||
enableProgress);
|
||||
processJobResult(jobId, result);
|
||||
} catch (TimeoutException te) {
|
||||
log.error(
|
||||
"Job {} timed out after {} ms",
|
||||
jobId,
|
||||
timeoutToUse);
|
||||
taskManager.setError(jobId, "Job timed out");
|
||||
} catch (Exception e) {
|
||||
log.error(
|
||||
"Error executing job {}: {}",
|
||||
jobId,
|
||||
e.getMessage(),
|
||||
e);
|
||||
taskManager.setError(jobId, e.getMessage());
|
||||
}
|
||||
}));
|
||||
|
||||
return ResponseEntity.ok().body(new JobResponse<>(true, jobId, null));
|
||||
} else {
|
||||
@@ -194,7 +228,9 @@ public class JobExecutorService {
|
||||
log.debug("Running sync job with timeout {} ms", timeoutToUse);
|
||||
|
||||
// Execute with timeout
|
||||
Object result = executeWithTimeout(() -> work.get(), timeoutToUse);
|
||||
Supplier<Object> contextualWork = withJobContext(jobId, enableProgress, work);
|
||||
Object result =
|
||||
executeWithTimeout(contextualWork, timeoutToUse, jobId, enableProgress);
|
||||
|
||||
// If the result is already a ResponseEntity, return it directly
|
||||
if (result instanceof ResponseEntity) {
|
||||
@@ -452,12 +488,14 @@ public class JobExecutorService {
|
||||
* @throws TimeoutException If the execution times out
|
||||
* @throws Exception If the supplier throws an exception
|
||||
*/
|
||||
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs)
|
||||
private <T> T executeWithTimeout(
|
||||
Supplier<T> supplier, long timeoutMs, String jobId, boolean progressEnabled)
|
||||
throws TimeoutException, Exception {
|
||||
// Use the same executor as other async jobs for consistency
|
||||
// This ensures all operations run on the same thread pool
|
||||
java.util.concurrent.CompletableFuture<T> future =
|
||||
java.util.concurrent.CompletableFuture.supplyAsync(supplier, executor);
|
||||
java.util.concurrent.CompletableFuture.supplyAsync(
|
||||
withJobContext(jobId, progressEnabled, supplier), executor);
|
||||
|
||||
try {
|
||||
return future.get(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
@@ -473,4 +511,62 @@ public class JobExecutorService {
|
||||
throw new Exception("Execution was interrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Backwards compatible helper used by tests via reflection. */
|
||||
@SuppressWarnings("unused")
|
||||
private <T> T executeWithTimeout(Supplier<T> supplier, long timeoutMs)
|
||||
throws TimeoutException, Exception {
|
||||
return executeWithTimeout(
|
||||
supplier,
|
||||
timeoutMs,
|
||||
JobContextHolder.getJobId(),
|
||||
JobContextHolder.isProgressEnabled());
|
||||
}
|
||||
|
||||
private <T> Supplier<T> withJobContext(
|
||||
String jobId, boolean progressEnabled, Supplier<T> delegate) {
|
||||
if (jobId == null) {
|
||||
return delegate;
|
||||
}
|
||||
return () -> {
|
||||
String previousJobId = JobContextHolder.getJobId();
|
||||
boolean previousProgress = JobContextHolder.isProgressEnabled();
|
||||
|
||||
JobContextHolder.setContext(jobId, progressEnabled);
|
||||
try {
|
||||
return delegate.get();
|
||||
} finally {
|
||||
if (previousJobId == null) {
|
||||
JobContextHolder.clear();
|
||||
} else {
|
||||
JobContextHolder.setContext(previousJobId, previousProgress);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void runWithJobContext(String jobId, boolean progressEnabled, Runnable runnable) {
|
||||
if (jobId == null) {
|
||||
runnable.run();
|
||||
return;
|
||||
}
|
||||
Runnable contextualRunnable =
|
||||
() -> {
|
||||
String previousJobId = JobContextHolder.getJobId();
|
||||
boolean previousProgress = JobContextHolder.isProgressEnabled();
|
||||
|
||||
JobContextHolder.setContext(jobId, progressEnabled);
|
||||
try {
|
||||
runnable.run();
|
||||
} finally {
|
||||
if (previousJobId == null) {
|
||||
JobContextHolder.clear();
|
||||
} else {
|
||||
JobContextHolder.setContext(previousJobId, previousProgress);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
contextualRunnable.run();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
import stirling.software.common.context.JobContextHolder;
|
||||
|
||||
/**
|
||||
* Convenience service that exposes a simple API for updating progress information from within job
|
||||
* handlers executed through {@link JobExecutorService}. The service automatically ties progress
|
||||
* updates to the current job (if any) and no-ops when progress tracking is disabled.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class JobProgressService {
|
||||
|
||||
private final TaskManager taskManager;
|
||||
|
||||
/** Update the progress percentage for the current job. */
|
||||
public boolean updateProgress(int percent, String message) {
|
||||
String jobId = JobContextHolder.getJobId();
|
||||
if (jobId == null || !JobContextHolder.isProgressEnabled()) {
|
||||
return false;
|
||||
}
|
||||
return taskManager.updateProgress(jobId, percent, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a simple tracker that can be used to report progress across a fixed number of steps.
|
||||
* When progress tracking is disabled for the current job, the returned tracker will be a
|
||||
* lightweight no-op implementation.
|
||||
*/
|
||||
public JobProgressTracker tracker(int totalSteps) {
|
||||
return tracker(totalSteps, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a tracker and optionally publish an initial message. Useful for multi-stage pipelines
|
||||
* where the initial state should be visible to clients.
|
||||
*/
|
||||
public JobProgressTracker tracker(int totalSteps, String initialMessage) {
|
||||
String jobId = JobContextHolder.getJobId();
|
||||
boolean enabled = JobContextHolder.isProgressEnabled();
|
||||
|
||||
if (jobId == null || !enabled || totalSteps <= 0) {
|
||||
return JobProgressTracker.disabled();
|
||||
}
|
||||
|
||||
if (initialMessage != null && !initialMessage.isBlank()) {
|
||||
taskManager.updateProgress(jobId, 0, initialMessage);
|
||||
}
|
||||
|
||||
return new JobProgressTracker(taskManager, jobId, totalSteps, true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import lombok.AccessLevel;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
|
||||
/**
|
||||
* Utility that helps controllers report progress in a structured way by distributing the 0-100%
|
||||
* range across a finite number of logical steps.
|
||||
*/
|
||||
@RequiredArgsConstructor(access = AccessLevel.PACKAGE)
|
||||
public class JobProgressTracker {
|
||||
|
||||
private final TaskManager taskManager;
|
||||
private final String jobId;
|
||||
private final int totalSteps;
|
||||
private final boolean enabled;
|
||||
|
||||
private int completedSteps;
|
||||
|
||||
static JobProgressTracker disabled() {
|
||||
return new JobProgressTracker(null, null, 1, false);
|
||||
}
|
||||
|
||||
/** Whether the tracker will emit updates. */
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/** Advance the tracker by one step. */
|
||||
public void advance() {
|
||||
advanceBy(1, null);
|
||||
}
|
||||
|
||||
/** Advance the tracker by {@code steps} steps. */
|
||||
public void advanceBy(int steps, String message) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
int safeSteps = Math.max(0, steps);
|
||||
completedSteps = Math.min(totalSteps, completedSteps + safeSteps);
|
||||
publish(message);
|
||||
}
|
||||
|
||||
/** Advance the tracker by {@code steps} steps without a message. */
|
||||
public void advanceBy(int steps) {
|
||||
advanceBy(steps, null);
|
||||
}
|
||||
|
||||
/** Explicitly set the completed steps count. */
|
||||
public void setStepsCompleted(int stepsCompleted, String message) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
completedSteps = Math.max(0, Math.min(totalSteps, stepsCompleted));
|
||||
publish(message);
|
||||
}
|
||||
|
||||
/** Explicitly set completed steps without a message. */
|
||||
public void setStepsCompleted(int stepsCompleted) {
|
||||
setStepsCompleted(stepsCompleted, null);
|
||||
}
|
||||
|
||||
/** Mark the tracker as complete and emit a final message. */
|
||||
public void complete(String message) {
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
completedSteps = totalSteps;
|
||||
taskManager.updateProgress(jobId, 100, message);
|
||||
}
|
||||
|
||||
/** Mark the tracker as complete without a message. */
|
||||
public void complete() {
|
||||
complete(null);
|
||||
}
|
||||
|
||||
private void publish(String message) {
|
||||
int percent = (int) Math.floor(((double) completedSteps / (double) totalSteps) * 100);
|
||||
taskManager.updateProgress(jobId, percent, message);
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,17 @@ public class TaskManager {
|
||||
* @param jobId The job ID
|
||||
*/
|
||||
public void createTask(String jobId) {
|
||||
jobResults.put(jobId, JobResult.createNew(jobId));
|
||||
createTask(jobId, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new task with the given job ID and progress tracking preference
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @param trackProgress Whether detailed progress updates should be stored
|
||||
*/
|
||||
public void createTask(String jobId, boolean trackProgress) {
|
||||
jobResults.put(jobId, JobResult.createNew(jobId, trackProgress));
|
||||
log.debug("Created task with job ID: {}", jobId);
|
||||
}
|
||||
|
||||
@@ -165,6 +175,11 @@ public class TaskManager {
|
||||
&& jobResult.getError() == null) {
|
||||
// If no result or error has been set, mark it as complete with an empty result
|
||||
jobResult.completeWithResult("Task completed successfully");
|
||||
} else {
|
||||
// Ensure progress is set to 100% with "Completed" message
|
||||
if (jobResult.isTrackProgress()) {
|
||||
jobResult.updateProgress(100, "Completed");
|
||||
}
|
||||
}
|
||||
log.debug("Marked job ID: {} as complete", jobId);
|
||||
}
|
||||
@@ -209,6 +224,33 @@ public class TaskManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the progress information for a task.
|
||||
*
|
||||
* @param jobId The job ID
|
||||
* @param percent Percentage complete (0-100)
|
||||
* @param message Descriptive message for the current stage
|
||||
* @return true if the progress update was accepted
|
||||
*/
|
||||
public boolean updateProgress(String jobId, int percent, String message) {
|
||||
JobResult jobResult = jobResults.get(jobId);
|
||||
if (jobResult == null) {
|
||||
log.debug("Ignoring progress update for unknown job ID: {}", jobId);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!jobResult.isTrackProgress()) {
|
||||
log.trace("Progress tracking disabled for job ID: {}", jobId);
|
||||
return false;
|
||||
}
|
||||
|
||||
jobResult.updateProgress(percent, message);
|
||||
log.debug(
|
||||
"Updated progress for job {} to {}% with message: {}",
|
||||
jobId, jobResult.getProgressPercent(), message);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get statistics about all jobs in the system
|
||||
*
|
||||
|
||||
+34
-4
@@ -1,6 +1,7 @@
|
||||
package stirling.software.common.annotations;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
@@ -72,6 +73,8 @@ class AutoJobPostMappingIntegrationTest {
|
||||
|
||||
@Captor private ArgumentCaptor<Integer> resourceWeightCaptor;
|
||||
|
||||
@Captor private ArgumentCaptor<Boolean> trackProgressCaptor;
|
||||
|
||||
@Test
|
||||
void shouldExecuteWithCustomParameters() throws Throwable {
|
||||
// Given
|
||||
@@ -91,7 +94,12 @@ class AutoJobPostMappingIntegrationTest {
|
||||
when(fileStorage.retrieveFile("test-file-id")).thenReturn(mockFile);
|
||||
|
||||
when(jobExecutorService.runJobGeneric(
|
||||
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
|
||||
anyBoolean(),
|
||||
any(Supplier.class),
|
||||
anyLong(),
|
||||
anyBoolean(),
|
||||
anyInt(),
|
||||
anyBoolean()))
|
||||
.thenReturn(ResponseEntity.ok("success"));
|
||||
|
||||
// When
|
||||
@@ -106,12 +114,14 @@ class AutoJobPostMappingIntegrationTest {
|
||||
workCaptor.capture(),
|
||||
timeoutCaptor.capture(),
|
||||
queueableCaptor.capture(),
|
||||
resourceWeightCaptor.capture());
|
||||
resourceWeightCaptor.capture(),
|
||||
trackProgressCaptor.capture());
|
||||
|
||||
assertTrue(asyncCaptor.getValue(), "Async should be true");
|
||||
assertEquals(60000L, timeoutCaptor.getValue(), "Timeout should be 60000ms");
|
||||
assertTrue(queueableCaptor.getValue(), "Queueable should be true");
|
||||
assertEquals(75, resourceWeightCaptor.getValue(), "Resource weight should be 75");
|
||||
assertTrue(trackProgressCaptor.getValue(), "Track progress should propagate");
|
||||
|
||||
// Test that file was resolved
|
||||
assertNotNull(pdfFile.getFileInput(), "File input should be set");
|
||||
@@ -135,7 +145,12 @@ class AutoJobPostMappingIntegrationTest {
|
||||
|
||||
// Mock jobExecutorService to execute the work immediately
|
||||
when(jobExecutorService.runJobGeneric(
|
||||
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
|
||||
anyBoolean(),
|
||||
any(Supplier.class),
|
||||
anyLong(),
|
||||
anyBoolean(),
|
||||
anyInt(),
|
||||
anyBoolean()))
|
||||
.thenAnswer(
|
||||
invocation -> {
|
||||
Supplier<Object> work = invocation.getArgument(1);
|
||||
@@ -150,6 +165,16 @@ class AutoJobPostMappingIntegrationTest {
|
||||
|
||||
// Verify that proceed was called twice (initial attempt + 1 retry)
|
||||
verify(joinPoint, times(2)).proceed(any());
|
||||
|
||||
verify(jobExecutorService)
|
||||
.runJobGeneric(
|
||||
asyncCaptor.capture(),
|
||||
workCaptor.capture(),
|
||||
timeoutCaptor.capture(),
|
||||
queueableCaptor.capture(),
|
||||
resourceWeightCaptor.capture(),
|
||||
trackProgressCaptor.capture());
|
||||
assertFalse(trackProgressCaptor.getValue(), "Track progress should be false when disabled");
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -168,7 +193,12 @@ class AutoJobPostMappingIntegrationTest {
|
||||
|
||||
// Mock job executor to return a successful response
|
||||
when(jobExecutorService.runJobGeneric(
|
||||
anyBoolean(), any(Supplier.class), anyLong(), anyBoolean(), anyInt()))
|
||||
anyBoolean(),
|
||||
any(Supplier.class),
|
||||
anyLong(),
|
||||
anyBoolean(),
|
||||
anyInt(),
|
||||
anyBoolean()))
|
||||
.thenReturn(ResponseEntity.ok("success"));
|
||||
|
||||
// When
|
||||
|
||||
+4
-3
@@ -94,7 +94,7 @@ class JobExecutorServiceTest {
|
||||
assertNotNull(jobResponse.getJobId());
|
||||
|
||||
// Verify task manager was called
|
||||
verify(taskManager).createTask(jobIdCaptor.capture());
|
||||
verify(taskManager).createTask(jobIdCaptor.capture(), eq(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -129,7 +129,8 @@ class JobExecutorServiceTest {
|
||||
when(jobQueue.queueJob(anyString(), eq(80), any(), anyLong())).thenReturn(future);
|
||||
|
||||
// When
|
||||
ResponseEntity<?> response = jobExecutorService.runJobGeneric(true, work, 5000, true, 80);
|
||||
ResponseEntity<?> response =
|
||||
jobExecutorService.runJobGeneric(true, work, 5000, true, 80, true);
|
||||
|
||||
// Then
|
||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||
@@ -137,7 +138,7 @@ class JobExecutorServiceTest {
|
||||
|
||||
// Verify job was queued
|
||||
verify(jobQueue).queueJob(anyString(), eq(80), any(), eq(5000L));
|
||||
verify(taskManager).createTask(anyString());
|
||||
verify(taskManager).createTask(anyString(), eq(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package stirling.software.common.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import stirling.software.common.context.JobContextHolder;
|
||||
|
||||
class JobProgressServiceTest {
|
||||
|
||||
@Mock private TaskManager taskManager;
|
||||
|
||||
private JobProgressService jobProgressService;
|
||||
|
||||
private AutoCloseable mocks;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
mocks = MockitoAnnotations.openMocks(this);
|
||||
jobProgressService = new JobProgressService(taskManager);
|
||||
JobContextHolder.clear();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
JobContextHolder.clear();
|
||||
mocks.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProgressReturnsFalseWhenNoContext() {
|
||||
boolean updated = jobProgressService.updateProgress(10, "Stage");
|
||||
assertFalse(updated);
|
||||
verify(taskManager, never()).updateProgress(anyString(), anyInt(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updateProgressDelegatesToTaskManager() {
|
||||
JobContextHolder.setContext("job-123", true);
|
||||
when(taskManager.updateProgress("job-123", 20, "Processing")).thenReturn(true);
|
||||
|
||||
boolean updated = jobProgressService.updateProgress(20, "Processing");
|
||||
|
||||
assertTrue(updated);
|
||||
verify(taskManager).updateProgress("job-123", 20, "Processing");
|
||||
}
|
||||
|
||||
@Test
|
||||
void trackerNoOpsWhenDisabled() {
|
||||
JobContextHolder.setContext("job-123", false);
|
||||
JobProgressTracker tracker = jobProgressService.tracker(5, "Start");
|
||||
|
||||
assertFalse(tracker.isEnabled());
|
||||
tracker.advanceBy(1, "Step");
|
||||
tracker.complete("Done");
|
||||
|
||||
verify(taskManager, never()).updateProgress(anyString(), anyInt(), anyString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void trackerPublishesProgress() {
|
||||
JobContextHolder.setContext("job-123", true);
|
||||
JobProgressTracker tracker = jobProgressService.tracker(4, "Starting");
|
||||
|
||||
assertTrue(tracker.isEnabled());
|
||||
verify(taskManager).updateProgress("job-123", 0, "Starting");
|
||||
|
||||
tracker.advanceBy(1, "25 percent");
|
||||
tracker.advanceBy(1, "50 percent");
|
||||
tracker.setStepsCompleted(3, "75 percent");
|
||||
tracker.complete("Done");
|
||||
|
||||
verify(taskManager).updateProgress("job-123", 25, "25 percent");
|
||||
verify(taskManager).updateProgress("job-123", 50, "50 percent");
|
||||
verify(taskManager).updateProgress("job-123", 75, "75 percent");
|
||||
verify(taskManager).updateProgress("job-123", 100, "Done");
|
||||
}
|
||||
}
|
||||
@@ -50,6 +50,9 @@ class TaskManagerTest {
|
||||
assertEquals(jobId, result.getJobId());
|
||||
assertFalse(result.isComplete());
|
||||
assertNotNull(result.getCreatedAt());
|
||||
assertTrue(result.isTrackProgress());
|
||||
assertEquals(0, result.getProgressPercent());
|
||||
assertEquals("Pending", result.getProgressMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -68,6 +71,8 @@ class TaskManagerTest {
|
||||
assertTrue(result.isComplete());
|
||||
assertEquals(resultObject, result.getResult());
|
||||
assertNotNull(result.getCompletedAt());
|
||||
assertEquals(100, result.getProgressPercent());
|
||||
assertEquals("Completed", result.getProgressMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -120,6 +125,8 @@ class TaskManagerTest {
|
||||
assertTrue(result.isComplete());
|
||||
assertEquals(errorMessage, result.getError());
|
||||
assertNotNull(result.getCompletedAt());
|
||||
assertEquals(100, result.getProgressPercent());
|
||||
assertEquals(errorMessage, result.getProgressMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -138,6 +145,7 @@ class TaskManagerTest {
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isComplete());
|
||||
assertEquals(resultObject, result.getResult());
|
||||
assertEquals(100, result.getProgressPercent());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -154,6 +162,8 @@ class TaskManagerTest {
|
||||
assertNotNull(result);
|
||||
assertTrue(result.isComplete());
|
||||
assertEquals("Task completed successfully", result.getResult());
|
||||
assertEquals(100, result.getProgressPercent());
|
||||
assertEquals("Completed", result.getProgressMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -305,4 +315,45 @@ class TaskManagerTest {
|
||||
// Assert
|
||||
assertFalse(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testCreateTaskWithoutProgressTracking() {
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
taskManager.createTask(jobId, false);
|
||||
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertNotNull(result);
|
||||
assertFalse(result.isTrackProgress());
|
||||
assertNull(result.getProgressPercent());
|
||||
assertNull(result.getProgressMessage());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateProgress() {
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
taskManager.createTask(jobId);
|
||||
|
||||
boolean updated = taskManager.updateProgress(jobId, 50, "Halfway there");
|
||||
|
||||
assertTrue(updated);
|
||||
JobResult result = taskManager.getJobResult(jobId);
|
||||
assertEquals(50, result.getProgressPercent());
|
||||
assertEquals("Halfway there", result.getProgressMessage());
|
||||
assertNotNull(result.getProgressUpdatedAt());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateProgressReturnsFalseWhenJobMissing() {
|
||||
boolean updated = taskManager.updateProgress("missing", 10, "Stage");
|
||||
assertFalse(updated);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUpdateProgressIgnoredWhenTrackingDisabled() {
|
||||
String jobId = UUID.randomUUID().toString();
|
||||
taskManager.createTask(jobId, false);
|
||||
|
||||
boolean updated = taskManager.updateProgress(jobId, 75, "Stage");
|
||||
assertFalse(updated);
|
||||
}
|
||||
}
|
||||
|
||||
+38
-2
@@ -22,12 +22,15 @@ import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.AnalysisApi;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
|
||||
@AnalysisApi
|
||||
@RequiredArgsConstructor
|
||||
public class AnalysisController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@AutoJobPostMapping(value = "/page-count", consumes = "multipart/form-data")
|
||||
@JsonDataResponse
|
||||
@@ -89,12 +92,22 @@ public class AnalysisController {
|
||||
try (PDDocument document = pdfDocumentFactory.load(file.getFileInput())) {
|
||||
List<Map<String, Float>> dimensions = new ArrayList<>();
|
||||
PDPageTree pages = document.getPages();
|
||||
JobProgressTracker progressTracker =
|
||||
jobProgressService.tracker(Math.max(1, pages.getCount()));
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
for (PDPage page : pages) {
|
||||
Map<String, Float> pageDim = new HashMap<>();
|
||||
pageDim.put("width", page.getBBox().getWidth());
|
||||
pageDim.put("height", page.getBBox().getHeight());
|
||||
dimensions.add(pageDim);
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
return dimensions;
|
||||
}
|
||||
@@ -134,13 +147,24 @@ public class AnalysisController {
|
||||
Map<String, Object> annotInfo = new HashMap<>();
|
||||
int totalAnnotations = 0;
|
||||
Map<String, Integer> annotationTypes = new HashMap<>();
|
||||
PDPageTree pages = document.getPages();
|
||||
JobProgressTracker progressTracker =
|
||||
jobProgressService.tracker(Math.max(1, pages.getCount()));
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
for (PDPage page : document.getPages()) {
|
||||
for (PDPage page : pages) {
|
||||
for (PDAnnotation annot : page.getAnnotations()) {
|
||||
totalAnnotations++;
|
||||
String subType = annot.getSubtype();
|
||||
annotationTypes.merge(subType, 1, Integer::sum);
|
||||
}
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
|
||||
annotInfo.put("totalCount", totalAnnotations);
|
||||
@@ -160,10 +184,22 @@ public class AnalysisController {
|
||||
Map<String, Object> fontInfo = new HashMap<>();
|
||||
Set<String> fontNames = new HashSet<>();
|
||||
|
||||
for (PDPage page : document.getPages()) {
|
||||
PDPageTree pages = document.getPages();
|
||||
JobProgressTracker progressTracker =
|
||||
jobProgressService.tracker(Math.max(1, pages.getCount()));
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
for (PDPage page : pages) {
|
||||
for (COSName font : page.getResources().getFontNames()) {
|
||||
fontNames.add(font.getName());
|
||||
}
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
|
||||
fontInfo.put("fontCount", fontNames.size());
|
||||
|
||||
+23
-6
@@ -28,6 +28,8 @@ import lombok.RequiredArgsConstructor;
|
||||
import stirling.software.SPDF.model.api.general.BookletImpositionRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@RestController
|
||||
@@ -37,6 +39,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class BookletImpositionController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@AutoJobPostMapping(value = "/booklet-imposition", consumes = "multipart/form-data")
|
||||
@Operation(
|
||||
@@ -68,21 +71,31 @@ public class BookletImpositionController {
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(file);
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
|
||||
List<Side> sides = saddleStitchSides(totalPages, doubleSided, duplexPass, flipOnShortEdge);
|
||||
JobProgressTracker progressTracker = jobProgressService.tracker(Math.max(1, sides.size()));
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
// Create proper booklet with signature-based page ordering
|
||||
PDDocument newDocument =
|
||||
createSaddleBooklet(
|
||||
sourceDocument,
|
||||
totalPages,
|
||||
addBorder,
|
||||
spineLocation,
|
||||
addGutter,
|
||||
gutterSize,
|
||||
doubleSided,
|
||||
duplexPass,
|
||||
flipOnShortEdge);
|
||||
flipOnShortEdge,
|
||||
sides,
|
||||
progressTracker,
|
||||
trackProgress);
|
||||
|
||||
sourceDocument.close();
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
@@ -154,14 +167,16 @@ public class BookletImpositionController {
|
||||
|
||||
private PDDocument createSaddleBooklet(
|
||||
PDDocument src,
|
||||
int totalPages,
|
||||
boolean addBorder,
|
||||
String spineLocation,
|
||||
boolean addGutter,
|
||||
float gutterSize,
|
||||
boolean doubleSided,
|
||||
String duplexPass,
|
||||
boolean flipOnShortEdge)
|
||||
boolean flipOnShortEdge,
|
||||
List<Side> sides,
|
||||
JobProgressTracker progressTracker,
|
||||
boolean trackProgress)
|
||||
throws IOException {
|
||||
|
||||
PDDocument dst = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(src);
|
||||
@@ -176,8 +191,6 @@ public class BookletImpositionController {
|
||||
if (gutterSize < 0) gutterSize = 0;
|
||||
if (gutterSize >= pageSize.getWidth() / 2f) gutterSize = pageSize.getWidth() / 2f - 1f;
|
||||
|
||||
List<Side> sides = saddleStitchSides(totalPages, doubleSided, duplexPass, flipOnShortEdge);
|
||||
|
||||
for (Side side : sides) {
|
||||
PDPage out = new PDPage(pageSize);
|
||||
dst.addPage(out);
|
||||
@@ -234,6 +247,10 @@ public class BookletImpositionController {
|
||||
cellH,
|
||||
addBorder);
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
return dst;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import stirling.software.SPDF.model.api.general.CropPdfForm;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@GeneralApi
|
||||
@@ -29,6 +31,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class CropController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@AutoJobPostMapping(value = "/crop", consumes = "multipart/form-data")
|
||||
@StandardPdfResponse
|
||||
@@ -44,6 +47,8 @@ public class CropController {
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDocument);
|
||||
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
JobProgressTracker progressTracker = jobProgressService.tracker(Math.max(1, totalPages));
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
LayerUtility layerUtility = new LayerUtility(newDocument);
|
||||
|
||||
@@ -80,6 +85,10 @@ public class CropController {
|
||||
request.getY(),
|
||||
request.getWidth(),
|
||||
request.getHeight()));
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
@@ -87,6 +96,10 @@ public class CropController {
|
||||
newDocument.close();
|
||||
sourceDocument.close();
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
|
||||
byte[] pdfContent = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
pdfContent,
|
||||
|
||||
@@ -37,6 +37,8 @@ import stirling.software.SPDF.model.api.general.MergePdfsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.PdfErrorUtils;
|
||||
@@ -48,6 +50,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class MergeController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
// Merges a list of PDDocument objects into a single PDDocument
|
||||
public PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
|
||||
@@ -204,6 +207,10 @@ public class MergeController {
|
||||
getSortComparator(
|
||||
request.getSortType())); // Sort files based on the given sort type
|
||||
|
||||
JobProgressTracker progressTracker =
|
||||
jobProgressService.tracker(Math.max(1, files.length) + 4);
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
PDFMergerUtility mergerUtility = new PDFMergerUtility();
|
||||
long totalSize = 0;
|
||||
List<Integer> invalidIndexes = new ArrayList<>();
|
||||
@@ -224,6 +231,10 @@ public class MergeController {
|
||||
invalidIndexes.add(index);
|
||||
}
|
||||
mergerUtility.addSource(tempFile); // Add source file to the merger utility
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
if (!invalidIndexes.isEmpty()) {
|
||||
@@ -243,6 +254,8 @@ public class MergeController {
|
||||
"{\"errorFileIds\":%s,\"message\":\"Some of the selected files can't be merged\"}",
|
||||
errorFileIds.toString());
|
||||
|
||||
jobProgressService.updateProgress(100, null);
|
||||
|
||||
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY)
|
||||
.header("Content-Type", MediaType.APPLICATION_JSON_VALUE)
|
||||
.body(payload.getBytes(StandardCharsets.UTF_8));
|
||||
@@ -251,6 +264,10 @@ public class MergeController {
|
||||
mergedTempFile = Files.createTempFile("merged-", ".pdf").toFile();
|
||||
mergerUtility.setDestinationFileName(mergedTempFile.getAbsolutePath());
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
|
||||
try {
|
||||
mergerUtility.mergeDocuments(
|
||||
pdfDocumentFactory.getStreamCacheFunction(
|
||||
@@ -289,10 +306,18 @@ public class MergeController {
|
||||
addTableOfContents(mergedDocument, files);
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
|
||||
// Save the modified document to a new ByteArrayOutputStream
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
mergedDocument.save(baos);
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
|
||||
String mergedFileName =
|
||||
files[0].getOriginalFilename().replaceFirst("[.][^.]+$", "")
|
||||
+ "_merged_unsigned.pdf";
|
||||
|
||||
+13
@@ -25,6 +25,8 @@ import stirling.software.SPDF.model.api.general.MergeMultiplePagesRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@GeneralApi
|
||||
@@ -32,6 +34,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class MultiPageLayoutController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@AutoJobPostMapping(value = "/multi-page-layout", consumes = "multipart/form-data")
|
||||
@StandardPdfResponse
|
||||
@@ -66,6 +69,8 @@ public class MultiPageLayoutController {
|
||||
newDocument.addPage(newPage);
|
||||
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
JobProgressTracker progressTracker = jobProgressService.tracker(Math.max(1, totalPages));
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
float cellWidth = newPage.getMediaBox().getWidth() / cols;
|
||||
float cellHeight = newPage.getMediaBox().getHeight() / rows;
|
||||
|
||||
@@ -126,6 +131,10 @@ public class MultiPageLayoutController {
|
||||
contentStream.addRect(borderX, borderY, cellWidth, cellHeight);
|
||||
contentStream.stroke();
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
contentStream.close(); // Close the final content stream
|
||||
@@ -135,6 +144,10 @@ public class MultiPageLayoutController {
|
||||
newDocument.save(baos);
|
||||
newDocument.close();
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
|
||||
+14
-1
@@ -17,6 +17,8 @@ import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
/**
|
||||
@@ -32,6 +34,8 @@ public class PdfImageRemovalController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
/**
|
||||
* Endpoint to remove images from a PDF file.
|
||||
*
|
||||
@@ -54,8 +58,13 @@ public class PdfImageRemovalController {
|
||||
// Load the PDF document
|
||||
PDDocument document = pdfDocumentFactory.load(file);
|
||||
|
||||
int pageCount = Math.max(1, document.getNumberOfPages());
|
||||
JobProgressTracker progressTracker = jobProgressService.tracker(pageCount + 1);
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
// Remove images from the PDF document using the service
|
||||
PDDocument modifiedDocument = pdfImageRemovalService.removeImagesFromPdf(document);
|
||||
PDDocument modifiedDocument =
|
||||
pdfImageRemovalService.removeImagesFromPdf(document, progressTracker);
|
||||
|
||||
// Create a ByteArrayOutputStream to hold the modified PDF data
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
@@ -69,6 +78,10 @@ public class PdfImageRemovalController {
|
||||
file.getFileInput().getOriginalFilename().replaceFirst("[.][^.]+$", "")
|
||||
+ "_removed_images.pdf";
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
|
||||
// Convert the byte array to a web response and return it
|
||||
return WebResponseUtils.bytesToWebResponse(outputStream.toByteArray(), mergedFileName);
|
||||
}
|
||||
|
||||
+51
-8
@@ -27,6 +27,8 @@ import stirling.software.SPDF.model.api.general.OverlayPdfsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -35,6 +37,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class PdfOverlayController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@AutoJobPostMapping(value = "/overlay-pdfs", consumes = "multipart/form-data")
|
||||
@StandardPdfResponse
|
||||
@@ -63,13 +66,17 @@ public class PdfOverlayController {
|
||||
|
||||
try (PDDocument basePdf = pdfDocumentFactory.load(baseFile);
|
||||
Overlay overlay = new Overlay()) {
|
||||
JobProgressTracker progressTracker =
|
||||
jobProgressService.tracker(basePdf.getNumberOfPages() + 1);
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
Map<Integer, String> overlayGuide =
|
||||
prepareOverlayGuide(
|
||||
basePdf.getNumberOfPages(),
|
||||
overlayPdfFiles,
|
||||
mode,
|
||||
counts,
|
||||
tempFiles);
|
||||
tempFiles,
|
||||
progressTracker);
|
||||
|
||||
overlay.setInputPDF(basePdf);
|
||||
if (overlayPos == 0) {
|
||||
@@ -80,6 +87,13 @@ public class PdfOverlayController {
|
||||
|
||||
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||
overlay.overlay(overlayGuide).save(outputStream);
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
progressTracker.complete();
|
||||
} else {
|
||||
jobProgressService.updateProgress(100, null);
|
||||
}
|
||||
byte[] data = outputStream.toByteArray();
|
||||
String outputFilename =
|
||||
Filenames.toSimpleFileName(baseFile.getOriginalFilename())
|
||||
@@ -104,18 +118,25 @@ public class PdfOverlayController {
|
||||
}
|
||||
|
||||
private Map<Integer, String> prepareOverlayGuide(
|
||||
int basePageCount, File[] overlayFiles, String mode, int[] counts, List<File> tempFiles)
|
||||
int basePageCount,
|
||||
File[] overlayFiles,
|
||||
String mode,
|
||||
int[] counts,
|
||||
List<File> tempFiles,
|
||||
JobProgressTracker progressTracker)
|
||||
throws IOException {
|
||||
Map<Integer, String> overlayGuide = new HashMap<>();
|
||||
switch (mode) {
|
||||
case "SequentialOverlay":
|
||||
sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles);
|
||||
sequentialOverlay(
|
||||
overlayGuide, overlayFiles, basePageCount, tempFiles, progressTracker);
|
||||
break;
|
||||
case "InterleavedOverlay":
|
||||
interleavedOverlay(overlayGuide, overlayFiles, basePageCount);
|
||||
interleavedOverlay(overlayGuide, overlayFiles, basePageCount, progressTracker);
|
||||
break;
|
||||
case "FixedRepeatOverlay":
|
||||
fixedRepeatOverlay(overlayGuide, overlayFiles, counts, basePageCount);
|
||||
fixedRepeatOverlay(
|
||||
overlayGuide, overlayFiles, counts, basePageCount, progressTracker);
|
||||
break;
|
||||
default:
|
||||
throw new IllegalArgumentException("Invalid overlay mode");
|
||||
@@ -127,10 +148,12 @@ public class PdfOverlayController {
|
||||
Map<Integer, String> overlayGuide,
|
||||
File[] overlayFiles,
|
||||
int basePageCount,
|
||||
List<File> tempFiles)
|
||||
List<File> tempFiles,
|
||||
JobProgressTracker progressTracker)
|
||||
throws IOException {
|
||||
int overlayFileIndex = 0;
|
||||
int pageCountInCurrentOverlay = 0;
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
for (int basePageIndex = 1; basePageIndex <= basePageCount; basePageIndex++) {
|
||||
if (pageCountInCurrentOverlay == 0
|
||||
@@ -152,6 +175,10 @@ public class PdfOverlayController {
|
||||
}
|
||||
|
||||
pageCountInCurrentOverlay++;
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,8 +189,12 @@ public class PdfOverlayController {
|
||||
}
|
||||
|
||||
private void interleavedOverlay(
|
||||
Map<Integer, String> overlayGuide, File[] overlayFiles, int basePageCount)
|
||||
Map<Integer, String> overlayGuide,
|
||||
File[] overlayFiles,
|
||||
int basePageCount,
|
||||
JobProgressTracker progressTracker)
|
||||
throws IOException {
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
for (int basePageIndex = 1; basePageIndex <= basePageCount; basePageIndex++) {
|
||||
File overlayFile = overlayFiles[(basePageIndex - 1) % overlayFiles.length];
|
||||
|
||||
@@ -174,17 +205,26 @@ public class PdfOverlayController {
|
||||
overlayGuide.put(basePageIndex, overlayFile.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void fixedRepeatOverlay(
|
||||
Map<Integer, String> overlayGuide, File[] overlayFiles, int[] counts, int basePageCount)
|
||||
Map<Integer, String> overlayGuide,
|
||||
File[] overlayFiles,
|
||||
int[] counts,
|
||||
int basePageCount,
|
||||
JobProgressTracker progressTracker)
|
||||
throws IOException {
|
||||
if (overlayFiles.length != counts.length) {
|
||||
throw new IllegalArgumentException(
|
||||
"Counts array length must match the number of overlay files");
|
||||
}
|
||||
int currentPage = 1;
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
for (int i = 0; i < overlayFiles.length; i++) {
|
||||
File overlayFile = overlayFiles[i];
|
||||
int repeatCount = counts[i];
|
||||
@@ -196,6 +236,9 @@ public class PdfOverlayController {
|
||||
for (int page = 0; page < overlayPageCount; page++) {
|
||||
if (currentPage > basePageCount) break;
|
||||
overlayGuide.put(currentPage++, overlayFile.getAbsolutePath());
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+30
@@ -24,6 +24,8 @@ import stirling.software.SPDF.model.api.general.RearrangePagesRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
@@ -34,6 +36,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class RearrangePagesPDFController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/remove-pages")
|
||||
@StandardPdfResponse
|
||||
@@ -59,9 +62,20 @@ public class RearrangePagesPDFController {
|
||||
|
||||
Collections.sort(pagesToRemove);
|
||||
|
||||
JobProgressTracker progressTracker =
|
||||
jobProgressService.tracker(Math.max(1, pagesToRemove.size()));
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
for (int i = pagesToRemove.size() - 1; i >= 0; i--) {
|
||||
int pageIndex = pagesToRemove.get(i);
|
||||
document.removePage(pageIndex);
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
@@ -272,14 +286,30 @@ public class RearrangePagesPDFController {
|
||||
newPages.add(document.getPage(newPageOrder.get(i)));
|
||||
}
|
||||
|
||||
int removalSteps = document.getNumberOfPages();
|
||||
int additionSteps = newPages.size();
|
||||
JobProgressTracker progressTracker =
|
||||
jobProgressService.tracker(Math.max(1, removalSteps + additionSteps));
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
// Remove all the pages from the original document
|
||||
for (int i = document.getNumberOfPages() - 1; i >= 0; i--) {
|
||||
document.removePage(i);
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
// Add the pages in the new order
|
||||
for (PDPage page : newPages) {
|
||||
document.addPage(page);
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
|
||||
@@ -19,6 +19,8 @@ import stirling.software.SPDF.model.api.general.RotatePDFRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -27,6 +29,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class RotationController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/rotate-pdf")
|
||||
@StandardPdfResponse
|
||||
@@ -51,9 +54,19 @@ public class RotationController {
|
||||
|
||||
// Get the list of pages in the document
|
||||
PDPageTree pages = document.getPages();
|
||||
int totalPages = Math.max(1, pages.getCount());
|
||||
JobProgressTracker progressTracker = jobProgressService.tracker(totalPages);
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
for (PDPage page : pages) {
|
||||
page.setRotation(page.getRotation() + angle);
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
|
||||
+13
@@ -26,6 +26,8 @@ import stirling.software.SPDF.model.api.general.ScalePagesRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -34,6 +36,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class ScalePagesController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@AutoJobPostMapping(value = "/scale-pages", consumes = "multipart/form-data")
|
||||
@StandardPdfResponse
|
||||
@@ -55,6 +58,8 @@ public class ScalePagesController {
|
||||
PDRectangle targetSize = getTargetSize(targetPDRectangle, sourceDocument);
|
||||
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
JobProgressTracker progressTracker = jobProgressService.tracker(Math.max(1, totalPages));
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
for (int i = 0; i < totalPages; i++) {
|
||||
PDPage sourcePage = sourceDocument.getPage(i);
|
||||
PDRectangle sourceSize = sourcePage.getMediaBox();
|
||||
@@ -87,6 +92,10 @@ public class ScalePagesController {
|
||||
|
||||
contentStream.restoreGraphicsState();
|
||||
contentStream.close();
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
@@ -94,6 +103,10 @@ public class ScalePagesController {
|
||||
outputDocument.close();
|
||||
sourceDocument.close();
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
baos.toByteArray(),
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename()).replaceFirst("[.][^.]+$", "")
|
||||
|
||||
@@ -28,6 +28,8 @@ import stirling.software.SPDF.model.api.PDFWithPageNums;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -37,6 +39,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class SplitPDFController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/split-pages")
|
||||
@MultiFileResponse
|
||||
@@ -77,6 +80,8 @@ public class SplitPDFController {
|
||||
// split the document
|
||||
splitDocumentsBoas = new ArrayList<>();
|
||||
int previousPageNumber = 0;
|
||||
JobProgressTracker progressTracker = jobProgressService.tracker(pageNumbers.size() + 2);
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
for (int splitPoint : pageNumbers) {
|
||||
try (PDDocument splitDocument =
|
||||
pdfDocumentFactory.createNewDocumentBasedOnOldDocument(document)) {
|
||||
@@ -94,6 +99,10 @@ public class SplitPDFController {
|
||||
splitDocument.save(baos);
|
||||
|
||||
splitDocumentsBoas.add(baos);
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("document splitting and saving", e);
|
||||
throw e;
|
||||
@@ -128,11 +137,18 @@ public class SplitPDFController {
|
||||
throw e;
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
|
||||
log.debug("Successfully created zip file with split documents: {}", zipFile.toString());
|
||||
byte[] data = Files.readAllBytes(zipFile);
|
||||
Files.deleteIfExists(zipFile);
|
||||
|
||||
// return the Resource in the response
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
|
||||
|
||||
+24
-2
@@ -33,6 +33,8 @@ import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.model.PdfMetadata;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.service.PdfMetadataService;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
@@ -45,6 +47,7 @@ public class SplitPdfByChaptersController {
|
||||
private final PdfMetadataService pdfMetadataService;
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
private static List<Bookmark> extractOutlineItems(
|
||||
PDDocument sourceDocument,
|
||||
@@ -178,8 +181,12 @@ public class SplitPdfByChaptersController {
|
||||
bookmark.getStartPage(),
|
||||
bookmark.getEndPage());
|
||||
}
|
||||
JobProgressTracker progressTracker = jobProgressService.tracker(bookmarks.size() + 1);
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
List<ByteArrayOutputStream> splitDocumentsBoas =
|
||||
getSplitDocumentsBoas(sourceDocument, bookmarks, includeMetadata);
|
||||
getSplitDocumentsBoas(
|
||||
sourceDocument, bookmarks, includeMetadata, progressTracker);
|
||||
|
||||
zipFile = createZipFile(bookmarks, splitDocumentsBoas);
|
||||
|
||||
@@ -190,6 +197,14 @@ public class SplitPdfByChaptersController {
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "");
|
||||
sourceDocument.close();
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
progressTracker.complete();
|
||||
} else {
|
||||
jobProgressService.updateProgress(100, null);
|
||||
}
|
||||
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
data, filename + ".zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
} finally {
|
||||
@@ -265,13 +280,17 @@ public class SplitPdfByChaptersController {
|
||||
}
|
||||
|
||||
public List<ByteArrayOutputStream> getSplitDocumentsBoas(
|
||||
PDDocument sourceDocument, List<Bookmark> bookmarks, boolean includeMetadata)
|
||||
PDDocument sourceDocument,
|
||||
List<Bookmark> bookmarks,
|
||||
boolean includeMetadata,
|
||||
JobProgressTracker progressTracker)
|
||||
throws Exception {
|
||||
List<ByteArrayOutputStream> splitDocumentsBoas = new ArrayList<>();
|
||||
PdfMetadata metadata = null;
|
||||
if (includeMetadata) {
|
||||
metadata = pdfMetadataService.extractMetadataFromPdf(sourceDocument);
|
||||
}
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
for (Bookmark bookmark : bookmarks) {
|
||||
try (PDDocument splitDocument = new PDDocument()) {
|
||||
boolean isSinglePage = (bookmark.getStartPage() == bookmark.getEndPage());
|
||||
@@ -291,6 +310,9 @@ public class SplitPdfByChaptersController {
|
||||
splitDocument.save(baos);
|
||||
|
||||
splitDocumentsBoas.add(baos);
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("document splitting and saving", e);
|
||||
throw e;
|
||||
|
||||
+44
-4
@@ -32,6 +32,8 @@ import stirling.software.SPDF.model.api.SplitPdfBySectionsRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@GeneralApi
|
||||
@@ -39,6 +41,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class SplitPdfBySectionsController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@AutoJobPostMapping(value = "/split-pdf-by-sections", consumes = "multipart/form-data")
|
||||
@MultiFileResponse
|
||||
@@ -59,15 +62,27 @@ public class SplitPdfBySectionsController {
|
||||
int horiz = request.getHorizontalDivisions() + 1;
|
||||
int verti = request.getVerticalDivisions() + 1;
|
||||
boolean merge = Boolean.TRUE.equals(request.getMerge());
|
||||
List<PDDocument> splitDocuments = splitPdfPages(sourceDocument, verti, horiz);
|
||||
int totalSections = sourceDocument.getNumberOfPages() * horiz * verti;
|
||||
JobProgressTracker progressTracker = jobProgressService.tracker(totalSections + 1);
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
List<PDDocument> splitDocuments =
|
||||
splitPdfPages(sourceDocument, verti, horiz, progressTracker);
|
||||
|
||||
String filename =
|
||||
Filenames.toSimpleFileName(file.getOriginalFilename())
|
||||
.replaceFirst("[.][^.]+$", "");
|
||||
if (merge) {
|
||||
MergeController mergeController = new MergeController(pdfDocumentFactory);
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
mergeController.mergeDocuments(splitDocuments).save(baos);
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
mergeDocuments(splitDocuments).save(baos);
|
||||
for (PDDocument doc : splitDocuments) {
|
||||
doc.close();
|
||||
}
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
return WebResponseUtils.bytesToWebResponse(baos.toByteArray(), filename + "_split.pdf");
|
||||
}
|
||||
for (PDDocument doc : splitDocuments) {
|
||||
@@ -99,6 +114,10 @@ public class SplitPdfBySectionsController {
|
||||
|
||||
zipOut.finish();
|
||||
data = Files.readAllBytes(zipFile);
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
progressTracker.complete();
|
||||
}
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
data, filename + "_split.zip", MediaType.APPLICATION_OCTET_STREAM);
|
||||
|
||||
@@ -107,11 +126,28 @@ public class SplitPdfBySectionsController {
|
||||
}
|
||||
}
|
||||
|
||||
private PDDocument mergeDocuments(List<PDDocument> documents) throws IOException {
|
||||
PDDocument merged = pdfDocumentFactory.createNewDocument();
|
||||
for (PDDocument doc : documents) {
|
||||
for (PDPage page : doc.getPages()) {
|
||||
merged.addPage(page);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
public List<PDDocument> splitPdfPages(
|
||||
PDDocument document, int horizontalDivisions, int verticalDivisions)
|
||||
PDDocument document,
|
||||
int horizontalDivisions,
|
||||
int verticalDivisions,
|
||||
JobProgressTracker progressTracker)
|
||||
throws IOException {
|
||||
List<PDDocument> splitDocuments = new ArrayList<>();
|
||||
|
||||
int totalSections = document.getNumberOfPages() * horizontalDivisions * verticalDivisions;
|
||||
int sectionCounter = 0;
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
for (PDPage originalPage : document.getPages()) {
|
||||
PDRectangle originalMediaBox = originalPage.getMediaBox();
|
||||
float width = originalMediaBox.getWidth();
|
||||
@@ -151,6 +187,10 @@ public class SplitPdfBySectionsController {
|
||||
}
|
||||
|
||||
splitDocuments.add(subDoc);
|
||||
sectionCounter++;
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+51
-6
@@ -25,6 +25,8 @@ import stirling.software.SPDF.model.api.general.SplitPdfBySizeOrCountRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
import stirling.software.common.util.GeneralUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
@@ -35,6 +37,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class SplitPdfBySizeController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@AutoJobPostMapping(value = "/split-by-size-or-count", consumes = "multipart/form-data")
|
||||
@MultiFileResponse
|
||||
@@ -78,21 +81,28 @@ public class SplitPdfBySizeController {
|
||||
String value = request.getSplitValue();
|
||||
log.debug("Split type: {}, Split value: {}", type, value);
|
||||
|
||||
JobProgressTracker progressTracker =
|
||||
jobProgressService.tracker(sourceDocument.getNumberOfPages() + 2);
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
if (type == 0) {
|
||||
log.debug("Processing split by size");
|
||||
long maxBytes = GeneralUtils.convertSizeToBytes(value);
|
||||
log.debug("Max bytes per document: {}", maxBytes);
|
||||
handleSplitBySize(sourceDocument, maxBytes, zipOut, filename);
|
||||
handleSplitBySize(
|
||||
sourceDocument, maxBytes, zipOut, filename, progressTracker);
|
||||
} else if (type == 1) {
|
||||
log.debug("Processing split by page count");
|
||||
int pageCount = Integer.parseInt(value);
|
||||
log.debug("Pages per document: {}", pageCount);
|
||||
handleSplitByPageCount(sourceDocument, pageCount, zipOut, filename);
|
||||
handleSplitByPageCount(
|
||||
sourceDocument, pageCount, zipOut, filename, progressTracker);
|
||||
} else if (type == 2) {
|
||||
log.debug("Processing split by document count");
|
||||
int documentCount = Integer.parseInt(value);
|
||||
log.debug("Total number of documents: {}", documentCount);
|
||||
handleSplitByDocCount(sourceDocument, documentCount, zipOut, filename);
|
||||
handleSplitByDocCount(
|
||||
sourceDocument, documentCount, zipOut, filename, progressTracker);
|
||||
} else {
|
||||
log.error("Invalid split type: {}", type);
|
||||
throw ExceptionUtils.createIllegalArgumentException(
|
||||
@@ -102,6 +112,13 @@ public class SplitPdfBySizeController {
|
||||
}
|
||||
|
||||
log.debug("PDF splitting completed successfully");
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
progressTracker.complete();
|
||||
} else {
|
||||
jobProgressService.updateProgress(100, null);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
ExceptionUtils.logException("PDF document loading or processing", e);
|
||||
throw e;
|
||||
@@ -138,7 +155,11 @@ public class SplitPdfBySizeController {
|
||||
}
|
||||
|
||||
private void handleSplitBySize(
|
||||
PDDocument sourceDocument, long maxBytes, ZipOutputStream zipOut, String baseFilename)
|
||||
PDDocument sourceDocument,
|
||||
long maxBytes,
|
||||
ZipOutputStream zipOut,
|
||||
String baseFilename,
|
||||
JobProgressTracker progressTracker)
|
||||
throws IOException {
|
||||
log.debug("Starting handleSplitBySize with maxBytes={}", maxBytes);
|
||||
|
||||
@@ -147,6 +168,7 @@ public class SplitPdfBySizeController {
|
||||
int fileIndex = 1;
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
int pageAdded = 0;
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
// Smart size check frequency - check more often with larger documents
|
||||
int baseCheckFrequency = 5;
|
||||
@@ -251,6 +273,9 @@ public class SplitPdfBySizeController {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (trackProgress) {
|
||||
progressTracker.setStepsCompleted(Math.min(totalPages, pageIndex + 1));
|
||||
}
|
||||
}
|
||||
|
||||
// Save final document if it has any pages
|
||||
@@ -260,13 +285,20 @@ public class SplitPdfBySizeController {
|
||||
currentDoc.getNumberOfPages(),
|
||||
fileIndex);
|
||||
saveDocumentToZip(currentDoc, zipOut, baseFilename, fileIndex++);
|
||||
if (trackProgress) {
|
||||
progressTracker.setStepsCompleted(totalPages);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("Completed handleSplitBySize with {} document parts created", fileIndex - 1);
|
||||
}
|
||||
|
||||
private void handleSplitByPageCount(
|
||||
PDDocument sourceDocument, int pageCount, ZipOutputStream zipOut, String baseFilename)
|
||||
PDDocument sourceDocument,
|
||||
int pageCount,
|
||||
ZipOutputStream zipOut,
|
||||
String baseFilename,
|
||||
JobProgressTracker progressTracker)
|
||||
throws IOException {
|
||||
log.debug("Starting handleSplitByPageCount with pageCount={}", pageCount);
|
||||
int currentPageCount = 0;
|
||||
@@ -284,12 +316,17 @@ public class SplitPdfBySizeController {
|
||||
int pageIndex = 0;
|
||||
int totalPages = sourceDocument.getNumberOfPages();
|
||||
log.debug("Processing {} pages", totalPages);
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
try {
|
||||
for (PDPage page : sourceDocument.getPages()) {
|
||||
pageIndex++;
|
||||
log.debug("Processing page {} of {}", pageIndex, totalPages);
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.setStepsCompleted(pageIndex);
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("Adding page {} to current document", pageIndex);
|
||||
currentDoc.addPage(page);
|
||||
@@ -347,6 +384,9 @@ public class SplitPdfBySizeController {
|
||||
log.error("Error saving final document part {}", fileIndex - 1, e);
|
||||
throw e;
|
||||
}
|
||||
if (trackProgress) {
|
||||
progressTracker.setStepsCompleted(totalPages);
|
||||
}
|
||||
} else {
|
||||
log.debug("Final document has no pages, skipping");
|
||||
}
|
||||
@@ -370,7 +410,8 @@ public class SplitPdfBySizeController {
|
||||
PDDocument sourceDocument,
|
||||
int documentCount,
|
||||
ZipOutputStream zipOut,
|
||||
String baseFilename)
|
||||
String baseFilename,
|
||||
JobProgressTracker progressTracker)
|
||||
throws IOException {
|
||||
log.debug("Starting handleSplitByDocCount with documentCount={}", documentCount);
|
||||
int totalPageCount = sourceDocument.getNumberOfPages();
|
||||
@@ -382,6 +423,7 @@ public class SplitPdfBySizeController {
|
||||
|
||||
int currentPageIndex = 0;
|
||||
int fileIndex = 1;
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
for (int i = 0; i < documentCount; i++) {
|
||||
log.debug("Creating document {} of {}", i + 1, documentCount);
|
||||
@@ -407,6 +449,9 @@ public class SplitPdfBySizeController {
|
||||
currentDoc.addPage(sourceDocument.getPage(currentPageIndex));
|
||||
log.debug("Successfully added page {} to document {}", j + 1, i + 1);
|
||||
currentPageIndex++;
|
||||
if (trackProgress) {
|
||||
progressTracker.setStepsCompleted(currentPageIndex);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("Error adding page {} to document {}", j + 1, i + 1, e);
|
||||
throw ExceptionUtils.createFileProcessingException("split", e);
|
||||
|
||||
+14
@@ -22,6 +22,8 @@ import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.GeneralApi;
|
||||
import stirling.software.common.model.api.PDFFile;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@GeneralApi
|
||||
@@ -29,6 +31,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class ToSinglePageController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/pdf-to-single-page")
|
||||
@StandardPdfResponse
|
||||
@@ -45,6 +48,10 @@ public class ToSinglePageController {
|
||||
// Load the source document
|
||||
PDDocument sourceDocument = pdfDocumentFactory.load(request);
|
||||
|
||||
int totalPages = Math.max(1, sourceDocument.getNumberOfPages());
|
||||
JobProgressTracker progressTracker = jobProgressService.tracker(totalPages + 1);
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
// Calculate total height and max width
|
||||
float totalHeight = 0;
|
||||
float maxWidth = 0;
|
||||
@@ -79,6 +86,9 @@ public class ToSinglePageController {
|
||||
layerUtility.appendFormAsLayer(newPage, form, af, defaultLayerName);
|
||||
yOffset -= page.getMediaBox().getHeight();
|
||||
pageIndex++;
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
@@ -86,6 +96,10 @@ public class ToSinglePageController {
|
||||
newDocument.close();
|
||||
sourceDocument.close();
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
|
||||
byte[] result = baos.toByteArray();
|
||||
return WebResponseUtils.bytesToWebResponse(
|
||||
result,
|
||||
|
||||
+56
-8
@@ -35,6 +35,8 @@ import stirling.software.SPDF.model.api.security.SanitizePdfRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.SecurityApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@SecurityApi
|
||||
@@ -42,6 +44,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class SanitizeController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@AutoJobPostMapping(consumes = "multipart/form-data", value = "/sanitize-pdf")
|
||||
@StandardPdfResponse
|
||||
@@ -61,28 +64,54 @@ public class SanitizeController {
|
||||
boolean removeFonts = Boolean.TRUE.equals(request.getRemoveFonts());
|
||||
|
||||
PDDocument document = pdfDocumentFactory.load(inputFile, true);
|
||||
|
||||
int pageCount = Math.max(1, document.getNumberOfPages());
|
||||
int pageSteps = 0;
|
||||
if (removeJavaScript) pageSteps += pageCount;
|
||||
if (removeEmbeddedFiles) pageSteps += pageCount;
|
||||
if (removeLinks) pageSteps += pageCount;
|
||||
if (removeFonts) pageSteps += pageCount;
|
||||
|
||||
int metadataSteps = 0;
|
||||
if (removeXMPMetadata) metadataSteps++;
|
||||
if (removeMetadata) metadataSteps++;
|
||||
|
||||
int totalSteps = Math.max(1, pageSteps + metadataSteps);
|
||||
JobProgressTracker progressTracker = jobProgressService.tracker(totalSteps);
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
if (removeJavaScript) {
|
||||
sanitizeJavaScript(document);
|
||||
sanitizeJavaScript(document, progressTracker, trackProgress);
|
||||
}
|
||||
|
||||
if (removeEmbeddedFiles) {
|
||||
sanitizeEmbeddedFiles(document);
|
||||
sanitizeEmbeddedFiles(document, progressTracker, trackProgress);
|
||||
}
|
||||
|
||||
if (removeXMPMetadata) {
|
||||
sanitizeXMPMetadata(document);
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
if (removeMetadata) {
|
||||
sanitizeDocumentInfoMetadata(document);
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
if (removeLinks) {
|
||||
sanitizeLinks(document);
|
||||
sanitizeLinks(document, progressTracker, trackProgress);
|
||||
}
|
||||
|
||||
if (removeFonts) {
|
||||
sanitizeFonts(document);
|
||||
sanitizeFonts(document, progressTracker, trackProgress);
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
@@ -92,7 +121,9 @@ public class SanitizeController {
|
||||
+ "_sanitized.pdf");
|
||||
}
|
||||
|
||||
private void sanitizeJavaScript(PDDocument document) throws IOException {
|
||||
private void sanitizeJavaScript(
|
||||
PDDocument document, JobProgressTracker progressTracker, boolean trackProgress)
|
||||
throws IOException {
|
||||
// Get the root dictionary (catalog) of the PDF
|
||||
PDDocumentCatalog catalog = document.getDocumentCatalog();
|
||||
|
||||
@@ -140,10 +171,15 @@ public class SanitizeController {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sanitizeEmbeddedFiles(PDDocument document) {
|
||||
private void sanitizeEmbeddedFiles(
|
||||
PDDocument document, JobProgressTracker progressTracker, boolean trackProgress) {
|
||||
PDPageTree allPages = document.getPages();
|
||||
|
||||
for (PDPage page : allPages) {
|
||||
@@ -151,6 +187,9 @@ public class SanitizeController {
|
||||
if (res != null && res.getCOSObject() != null) {
|
||||
res.getCOSObject().removeItem(COSName.getPDFName("EmbeddedFiles"));
|
||||
}
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -171,7 +210,9 @@ public class SanitizeController {
|
||||
}
|
||||
}
|
||||
|
||||
private void sanitizeLinks(PDDocument document) throws IOException {
|
||||
private void sanitizeLinks(
|
||||
PDDocument document, JobProgressTracker progressTracker, boolean trackProgress)
|
||||
throws IOException {
|
||||
for (PDPage page : document.getPages()) {
|
||||
for (PDAnnotation annotation : page.getAnnotations()) {
|
||||
if (annotation != null && annotation instanceof PDAnnotationLink linkAnnotation) {
|
||||
@@ -183,16 +224,23 @@ public class SanitizeController {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void sanitizeFonts(PDDocument document) {
|
||||
private void sanitizeFonts(
|
||||
PDDocument document, JobProgressTracker progressTracker, boolean trackProgress) {
|
||||
for (PDPage page : document.getPages()) {
|
||||
if (page != null
|
||||
&& page.getResources() != null
|
||||
&& page.getResources().getCOSObject() != null) {
|
||||
page.getResources().getCOSObject().removeItem(COSName.getPDFName("Font"));
|
||||
}
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+43
-129
@@ -5,11 +5,9 @@ import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.security.cert.CertificateException;
|
||||
import java.security.cert.CertificateFactory;
|
||||
import java.security.cert.PKIXCertPathBuilderResult;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.security.interfaces.RSAPublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
@@ -34,7 +32,6 @@ import org.springframework.web.multipart.MultipartFile;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.SPDF.config.swagger.JsonDataResponse;
|
||||
import stirling.software.SPDF.model.api.security.SignatureValidationRequest;
|
||||
@@ -45,7 +42,6 @@ import stirling.software.common.annotations.api.SecurityApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.util.ExceptionUtils;
|
||||
|
||||
@Slf4j
|
||||
@SecurityApi
|
||||
@RequiredArgsConstructor
|
||||
public class ValidateSignatureController {
|
||||
@@ -69,9 +65,8 @@ public class ValidateSignatureController {
|
||||
@Operation(
|
||||
summary = "Validate PDF Digital Signature",
|
||||
description =
|
||||
"Validates the digital signatures in a PDF file using PKIX path building"
|
||||
+ " and time-of-signing semantics. Supports custom trust anchors."
|
||||
+ " Input:PDF Output:JSON Type:SISO")
|
||||
"Validates the digital signatures in a PDF file against default or custom"
|
||||
+ " certificates. Input:PDF Output:JSON Type:SISO")
|
||||
@AutoJobPostMapping(
|
||||
value = "/validate-signature",
|
||||
consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||
@@ -79,12 +74,12 @@ public class ValidateSignatureController {
|
||||
@ModelAttribute SignatureValidationRequest request) throws IOException {
|
||||
List<SignatureValidationResult> results = new ArrayList<>();
|
||||
MultipartFile file = request.getFileInput();
|
||||
MultipartFile certFile = request.getCertFile();
|
||||
|
||||
// Load custom certificate if provided
|
||||
X509Certificate customCert = null;
|
||||
if (request.getCertFile() != null && !request.getCertFile().isEmpty()) {
|
||||
try (ByteArrayInputStream certStream =
|
||||
new ByteArrayInputStream(request.getCertFile().getBytes())) {
|
||||
if (certFile != null && !certFile.isEmpty()) {
|
||||
try (ByteArrayInputStream certStream = new ByteArrayInputStream(certFile.getBytes())) {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
customCert = (X509Certificate) cf.generateCertificate(certStream);
|
||||
} catch (CertificateException e) {
|
||||
@@ -113,150 +108,67 @@ public class ValidateSignatureController {
|
||||
Store<X509CertificateHolder> certStore = signedData.getCertificates();
|
||||
SignerInformationStore signerStore = signedData.getSignerInfos();
|
||||
|
||||
for (SignerInformation signerInfo : signerStore.getSigners()) {
|
||||
for (SignerInformation signer : signerStore.getSigners()) {
|
||||
X509CertificateHolder certHolder =
|
||||
(X509CertificateHolder)
|
||||
certStore.getMatches(signerInfo.getSID()).iterator().next();
|
||||
X509Certificate signerCert =
|
||||
certStore.getMatches(signer.getSID()).iterator().next();
|
||||
X509Certificate cert =
|
||||
new JcaX509CertificateConverter().getCertificate(certHolder);
|
||||
|
||||
// Extract intermediate certificates from CMS
|
||||
Collection<X509Certificate> intermediates =
|
||||
certValidationService.extractIntermediateCertificates(
|
||||
certStore, signerCert);
|
||||
boolean isValid =
|
||||
signer.verify(new JcaSimpleSignerInfoVerifierBuilder().build(cert));
|
||||
result.setValid(isValid);
|
||||
|
||||
// Log what we found
|
||||
log.debug(
|
||||
"Found {} intermediate certificates in CMS signature",
|
||||
intermediates.size());
|
||||
for (X509Certificate inter : intermediates) {
|
||||
log.debug(
|
||||
" → Intermediate: {}",
|
||||
inter.getSubjectX500Principal().getName());
|
||||
log.debug(
|
||||
" Issuer DN: {}", inter.getIssuerX500Principal().getName());
|
||||
}
|
||||
// Additional validations
|
||||
result.setChainValid(
|
||||
customCert != null
|
||||
? certValidationService
|
||||
.validateCertificateChainWithCustomCert(
|
||||
cert, customCert)
|
||||
: certValidationService.validateCertificateChain(cert));
|
||||
|
||||
// Determine validation time (TSA timestamp or signingTime, or current)
|
||||
CertificateValidationService.ValidationTime validationTimeResult =
|
||||
certValidationService.extractValidationTime(signerInfo);
|
||||
Date validationTime;
|
||||
if (validationTimeResult == null) {
|
||||
validationTime = new Date();
|
||||
result.setValidationTimeSource("current");
|
||||
} else {
|
||||
validationTime = validationTimeResult.date;
|
||||
result.setValidationTimeSource(validationTimeResult.source);
|
||||
}
|
||||
result.setTrustValid(
|
||||
customCert != null
|
||||
? certValidationService.validateTrustWithCustomCert(
|
||||
cert, customCert)
|
||||
: certValidationService.validateTrustStore(cert));
|
||||
|
||||
// Verify cryptographic signature
|
||||
boolean cmsValid =
|
||||
signerInfo.verify(
|
||||
new JcaSimpleSignerInfoVerifierBuilder().build(signerCert));
|
||||
result.setValid(cmsValid);
|
||||
|
||||
// Build and validate certificate path
|
||||
boolean chainValid = false;
|
||||
boolean trustValid = false;
|
||||
try {
|
||||
PKIXCertPathBuilderResult pathResult =
|
||||
certValidationService.buildAndValidatePath(
|
||||
signerCert, intermediates, customCert, validationTime);
|
||||
chainValid = true;
|
||||
trustValid = true; // Path ends at trust anchor
|
||||
result.setCertPathLength(
|
||||
pathResult.getCertPath().getCertificates().size());
|
||||
} catch (Exception e) {
|
||||
String errorMsg = e.getMessage();
|
||||
result.setChainValidationError(errorMsg);
|
||||
chainValid = false;
|
||||
trustValid = false;
|
||||
// Log the full error for debugging
|
||||
log.warn(
|
||||
"Certificate path validation failed for {}: {}",
|
||||
signerCert.getSubjectX500Principal().getName(),
|
||||
errorMsg);
|
||||
log.debug("Full stack trace:", e);
|
||||
}
|
||||
result.setChainValid(chainValid);
|
||||
result.setTrustValid(trustValid);
|
||||
|
||||
// Check validity at validation time
|
||||
boolean outside =
|
||||
certValidationService.isOutsideValidityPeriod(
|
||||
signerCert, validationTime);
|
||||
result.setNotExpired(!outside);
|
||||
|
||||
// Revocation status determination
|
||||
boolean revocationEnabled = certValidationService.isRevocationEnabled();
|
||||
result.setRevocationChecked(revocationEnabled);
|
||||
|
||||
if (!revocationEnabled) {
|
||||
result.setRevocationStatus("not-checked");
|
||||
} else if (chainValid && trustValid) {
|
||||
// Path building succeeded with revocation enabled = no revocation found
|
||||
result.setRevocationStatus("good");
|
||||
} else if (result.getChainValidationError() != null
|
||||
&& result.getChainValidationError()
|
||||
.toLowerCase()
|
||||
.contains("revocation")) {
|
||||
// Check if failure was revocation-related
|
||||
if (result.getChainValidationError()
|
||||
.toLowerCase()
|
||||
.contains("unable to check")) {
|
||||
result.setRevocationStatus("soft-fail");
|
||||
} else {
|
||||
result.setRevocationStatus("revoked");
|
||||
}
|
||||
} else {
|
||||
result.setRevocationStatus("unknown");
|
||||
}
|
||||
result.setNotRevoked(!certValidationService.isRevoked(cert));
|
||||
result.setNotExpired(!cert.getNotAfter().before(new Date()));
|
||||
|
||||
// Set basic signature info
|
||||
result.setSignerName(sig.getName());
|
||||
result.setSignatureDate(
|
||||
sig.getSignDate() != null
|
||||
? sig.getSignDate().getTime().toString()
|
||||
: null);
|
||||
result.setSignatureDate(sig.getSignDate().getTime().toString());
|
||||
result.setReason(sig.getReason());
|
||||
result.setLocation(sig.getLocation());
|
||||
|
||||
// Set certificate details (from signer cert)
|
||||
result.setIssuerDN(signerCert.getIssuerX500Principal().getName());
|
||||
result.setSubjectDN(signerCert.getSubjectX500Principal().getName());
|
||||
result.setSerialNumber(
|
||||
signerCert.getSerialNumber().toString(16)); // Hex format
|
||||
result.setValidFrom(signerCert.getNotBefore().toString());
|
||||
result.setValidUntil(signerCert.getNotAfter().toString());
|
||||
result.setSignatureAlgorithm(signerCert.getSigAlgName());
|
||||
// Set new certificate details
|
||||
result.setIssuerDN(cert.getIssuerX500Principal().getName());
|
||||
result.setSubjectDN(cert.getSubjectX500Principal().getName());
|
||||
result.setSerialNumber(cert.getSerialNumber().toString(16)); // Hex format
|
||||
result.setValidFrom(cert.getNotBefore().toString());
|
||||
result.setValidUntil(cert.getNotAfter().toString());
|
||||
result.setSignatureAlgorithm(cert.getSigAlgName());
|
||||
|
||||
// Get key size (if possible)
|
||||
try {
|
||||
result.setKeySize(
|
||||
((RSAPublicKey) signerCert.getPublicKey())
|
||||
.getModulus()
|
||||
.bitLength());
|
||||
((RSAPublicKey) cert.getPublicKey()).getModulus().bitLength());
|
||||
} catch (Exception e) {
|
||||
// If not RSA or error, set to 0
|
||||
result.setKeySize(0);
|
||||
}
|
||||
|
||||
result.setVersion(String.valueOf(signerCert.getVersion()));
|
||||
result.setVersion(String.valueOf(cert.getVersion()));
|
||||
|
||||
// Set key usage
|
||||
List<String> keyUsages = new ArrayList<>();
|
||||
boolean[] keyUsageFlags = signerCert.getKeyUsage();
|
||||
boolean[] keyUsageFlags = cert.getKeyUsage();
|
||||
if (keyUsageFlags != null) {
|
||||
String[] keyUsageLabels = {
|
||||
"Digital Signature",
|
||||
"Non-Repudiation",
|
||||
"Key Encipherment",
|
||||
"Data Encipherment",
|
||||
"Key Agreement",
|
||||
"Certificate Signing",
|
||||
"CRL Signing",
|
||||
"Encipher Only",
|
||||
"Decipher Only"
|
||||
"Digital Signature", "Non-Repudiation", "Key Encipherment",
|
||||
"Data Encipherment", "Key Agreement", "Certificate Signing",
|
||||
"CRL Signing", "Encipher Only", "Decipher Only"
|
||||
};
|
||||
for (int i = 0; i < keyUsageFlags.length; i++) {
|
||||
if (keyUsageFlags[i]) {
|
||||
@@ -266,8 +178,10 @@ public class ValidateSignatureController {
|
||||
}
|
||||
result.setKeyUsages(keyUsages);
|
||||
|
||||
// Check if self-signed (properly)
|
||||
result.setSelfSigned(certValidationService.isSelfSigned(signerCert));
|
||||
// Check if self-signed
|
||||
result.setSelfSigned(
|
||||
cert.getSubjectX500Principal()
|
||||
.equals(cert.getIssuerX500Principal()));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
result.setValid(false);
|
||||
|
||||
+15
@@ -40,6 +40,8 @@ import stirling.software.SPDF.model.api.security.AddWatermarkRequest;
|
||||
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||
import stirling.software.common.annotations.api.SecurityApi;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
import stirling.software.common.util.PdfUtils;
|
||||
import stirling.software.common.util.WebResponseUtils;
|
||||
|
||||
@@ -48,6 +50,7 @@ import stirling.software.common.util.WebResponseUtils;
|
||||
public class WatermarkController {
|
||||
|
||||
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
private final JobProgressService jobProgressService;
|
||||
|
||||
@InitBinder
|
||||
public void initBinder(WebDataBinder binder) {
|
||||
@@ -100,6 +103,10 @@ public class WatermarkController {
|
||||
PDDocument document = pdfDocumentFactory.load(pdfFile);
|
||||
|
||||
// Create a page in the document
|
||||
int totalPages = Math.max(1, document.getNumberOfPages());
|
||||
JobProgressTracker progressTracker = jobProgressService.tracker(totalPages);
|
||||
boolean trackProgress = progressTracker.isEnabled();
|
||||
|
||||
for (PDPage page : document.getPages()) {
|
||||
|
||||
// Get the page's content stream
|
||||
@@ -138,6 +145,10 @@ public class WatermarkController {
|
||||
|
||||
// Close the content stream
|
||||
contentStream.close();
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
|
||||
if (convertPdfToImage) {
|
||||
@@ -146,6 +157,10 @@ public class WatermarkController {
|
||||
document = convertedPdf;
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.complete();
|
||||
}
|
||||
|
||||
return WebResponseUtils.pdfDocToWebResponse(
|
||||
document,
|
||||
Filenames.toSimpleFileName(pdfFile.getOriginalFilename())
|
||||
|
||||
+4
-19
@@ -6,32 +6,17 @@ import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class SignatureValidationResult {
|
||||
// Cryptographic signature validation
|
||||
private boolean valid;
|
||||
|
||||
// Certificate chain validation
|
||||
private boolean chainValid;
|
||||
private boolean trustValid;
|
||||
private String chainValidationError;
|
||||
private int certPathLength;
|
||||
|
||||
// Time validation
|
||||
private boolean notExpired;
|
||||
|
||||
// Revocation validation
|
||||
private boolean revocationChecked; // true if PKIX revocation was enabled
|
||||
private String revocationStatus; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
|
||||
|
||||
private String validationTimeSource; // "current", "signing-time", or "timestamp"
|
||||
|
||||
// Signature metadata
|
||||
private String signerName;
|
||||
private String signatureDate;
|
||||
private String reason;
|
||||
private String location;
|
||||
private String errorMessage;
|
||||
private boolean chainValid;
|
||||
private boolean trustValid;
|
||||
private boolean notExpired;
|
||||
private boolean notRevoked;
|
||||
|
||||
// Certificate details
|
||||
private String issuerDN; // Certificate issuer's Distinguished Name
|
||||
private String subjectDN; // Certificate subject's Distinguished Name
|
||||
private String serialNumber; // Certificate serial number
|
||||
|
||||
+93
-813
@@ -1,863 +1,143 @@
|
||||
package stirling.software.SPDF.service;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyStore;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.KeyStoreException;
|
||||
import java.security.cert.*;
|
||||
import java.util.*;
|
||||
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.TrustManagerFactory;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.PDDocumentNameDictionary;
|
||||
import org.apache.pdfbox.pdmodel.PDEmbeddedFilesNameTreeNode;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDComplexFileSpecification;
|
||||
import org.apache.pdfbox.pdmodel.common.filespecification.PDEmbeddedFile;
|
||||
import org.bouncycastle.asn1.ASN1Encodable;
|
||||
import org.bouncycastle.asn1.ASN1GeneralizedTime;
|
||||
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
||||
import org.bouncycastle.asn1.ASN1UTCTime;
|
||||
import org.bouncycastle.asn1.cms.CMSAttributes;
|
||||
import org.bouncycastle.cert.X509CertificateHolder;
|
||||
import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter;
|
||||
import org.bouncycastle.cms.CMSSignedData;
|
||||
import org.bouncycastle.cms.SignerInformation;
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.bouncycastle.tsp.TimeStampToken;
|
||||
import org.bouncycastle.util.Store;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import io.github.pixee.security.BoundedLineReader;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import stirling.software.common.service.ServerCertificateServiceInterface;
|
||||
|
||||
@Service
|
||||
@Slf4j
|
||||
public class CertificateValidationService {
|
||||
/**
|
||||
* Result container for validation time extraction Contains both the date and the source of the
|
||||
* time
|
||||
*/
|
||||
public static class ValidationTime {
|
||||
public final Date date;
|
||||
public final String source; // "timestamp" | "signing-time" | "current"
|
||||
|
||||
public ValidationTime(Date date, String source) {
|
||||
this.date = date;
|
||||
this.source = source;
|
||||
}
|
||||
}
|
||||
|
||||
// Separate trust stores: signing vs TLS
|
||||
private KeyStore signingTrustAnchors; // AATL/EUTL + server cert for PDF signing
|
||||
private final ServerCertificateServiceInterface serverCertificateService;
|
||||
private final ApplicationProperties applicationProperties;
|
||||
|
||||
// EUTL (EU Trusted List) constants
|
||||
private static final String NS_TSL = "http://uri.etsi.org/02231/v2#";
|
||||
|
||||
// Qualified CA service types to import as trust anchors (per ETSI TS 119 612)
|
||||
private static final Set<String> EUTL_SERVICE_TYPES =
|
||||
new HashSet<>(
|
||||
Arrays.asList(
|
||||
"http://uri.etsi.org/TrstSvc/Svctype/CA/QC",
|
||||
"http://uri.etsi.org/TrstSvc/Svctype/NationalRootCA-QC"));
|
||||
|
||||
// Active statuses to accept (per ETSI TS 119 612)
|
||||
private static final String STATUS_UNDER_SUPERVISION =
|
||||
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/undersupervision";
|
||||
private static final String STATUS_ACCREDITED =
|
||||
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/accredited";
|
||||
private static final String STATUS_SUPERVISION_IN_CESSATION =
|
||||
"http://uri.etsi.org/TrstSvc/TrustedList/Svcstatus/supervisionincessation";
|
||||
|
||||
static {
|
||||
if (java.security.Security.getProvider("BC") == null) {
|
||||
java.security.Security.addProvider(new BouncyCastleProvider());
|
||||
}
|
||||
}
|
||||
|
||||
public CertificateValidationService(
|
||||
@Autowired(required = false) ServerCertificateServiceInterface serverCertificateService,
|
||||
ApplicationProperties applicationProperties) {
|
||||
this.serverCertificateService = serverCertificateService;
|
||||
this.applicationProperties = applicationProperties;
|
||||
}
|
||||
private KeyStore trustStore;
|
||||
|
||||
@PostConstruct
|
||||
private void initializeTrustStore() throws Exception {
|
||||
signingTrustAnchors = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
signingTrustAnchors.load(null, null);
|
||||
|
||||
ApplicationProperties.Security.Validation validation =
|
||||
applicationProperties.getSecurity().getValidation();
|
||||
|
||||
// Enable JDK fetching of OCSP/CRLDP if allowed
|
||||
if (validation.isAllowAIA()) {
|
||||
java.security.Security.setProperty("ocsp.enable", "true");
|
||||
System.setProperty("com.sun.security.enableCRLDP", "true");
|
||||
System.setProperty("com.sun.security.enableAIAcaIssuers", "true");
|
||||
log.info("Enabled AIA certificate fetching and revocation checking");
|
||||
}
|
||||
|
||||
// Trust only what we explicitly opt into:
|
||||
if (validation.getTrust().isServerAsAnchor()) loadServerCertAsAnchor();
|
||||
if (validation.getTrust().isUseSystemTrust()) loadJavaSystemTrustStore();
|
||||
if (validation.getTrust().isUseMozillaBundle()) loadBundledMozillaCACerts();
|
||||
if (validation.getTrust().isUseAATL()) loadAATLCertificates();
|
||||
if (validation.getTrust().isUseEUTL()) loadEUTLCertificates();
|
||||
trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
|
||||
trustStore.load(null, null);
|
||||
loadMozillaCertificates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core entry-point: build a valid PKIX path from signerCert using provided intermediates
|
||||
*
|
||||
* @param signerCert The signer certificate
|
||||
* @param intermediates Collection of intermediate certificates from CMS
|
||||
* @param customTrustAnchor Optional custom root/intermediate certificate
|
||||
* @param validationTime Time to validate at (signing time or current)
|
||||
* @return PKIXCertPathBuilderResult containing validated path
|
||||
* @throws GeneralSecurityException if path building/validation fails
|
||||
*/
|
||||
public PKIXCertPathBuilderResult buildAndValidatePath(
|
||||
X509Certificate signerCert,
|
||||
Collection<X509Certificate> intermediates,
|
||||
X509Certificate customTrustAnchor,
|
||||
Date validationTime)
|
||||
throws GeneralSecurityException {
|
||||
private void loadMozillaCertificates() throws Exception {
|
||||
try (InputStream is = getClass().getResourceAsStream("/certdata.txt")) {
|
||||
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
|
||||
String line;
|
||||
StringBuilder certData = new StringBuilder();
|
||||
boolean inCert = false;
|
||||
int certCount = 0;
|
||||
|
||||
// Build trust anchors
|
||||
Set<TrustAnchor> anchors = new HashSet<>();
|
||||
if (customTrustAnchor != null) {
|
||||
anchors.add(new TrustAnchor(customTrustAnchor, null));
|
||||
} else {
|
||||
Enumeration<String> aliases = signingTrustAnchors.aliases();
|
||||
while (aliases.hasMoreElements()) {
|
||||
Certificate c = signingTrustAnchors.getCertificate(aliases.nextElement());
|
||||
if (c instanceof X509Certificate x) {
|
||||
anchors.add(new TrustAnchor(x, null));
|
||||
while ((line = BoundedLineReader.readLine(reader, 5_000_000)) != null) {
|
||||
if (line.startsWith("CKA_VALUE MULTILINE_OCTAL")) {
|
||||
inCert = true;
|
||||
certData = new StringBuilder();
|
||||
continue;
|
||||
}
|
||||
if (inCert) {
|
||||
if ("END".equals(line)) {
|
||||
inCert = false;
|
||||
byte[] certBytes = parseOctalData(certData.toString());
|
||||
if (certBytes != null) {
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
X509Certificate cert =
|
||||
(X509Certificate)
|
||||
cf.generateCertificate(
|
||||
new ByteArrayInputStream(certBytes));
|
||||
trustStore.setCertificateEntry("mozilla-cert-" + certCount++, cert);
|
||||
}
|
||||
} else {
|
||||
certData.append(line).append("\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (anchors.isEmpty()) {
|
||||
throw new CertPathBuilderException("No trust anchors available");
|
||||
}
|
||||
|
||||
// Target certificate selector
|
||||
X509CertSelector target = new X509CertSelector();
|
||||
target.setCertificate(signerCert);
|
||||
|
||||
// Intermediate certificate store
|
||||
List<Certificate> allCerts = new ArrayList<>(intermediates);
|
||||
CertStore intermediateStore =
|
||||
CertStore.getInstance("Collection", new CollectionCertStoreParameters(allCerts));
|
||||
|
||||
// PKIX parameters
|
||||
PKIXBuilderParameters params = new PKIXBuilderParameters(anchors, target);
|
||||
params.addCertStore(intermediateStore);
|
||||
String revocationMode =
|
||||
applicationProperties.getSecurity().getValidation().getRevocation().getMode();
|
||||
params.setRevocationEnabled(!"none".equalsIgnoreCase(revocationMode));
|
||||
if (validationTime != null) {
|
||||
params.setDate(validationTime);
|
||||
}
|
||||
|
||||
// Revocation checking
|
||||
if (!"none".equalsIgnoreCase(revocationMode)) {
|
||||
try {
|
||||
PKIXRevocationChecker rc =
|
||||
(PKIXRevocationChecker)
|
||||
CertPathValidator.getInstance("PKIX").getRevocationChecker();
|
||||
|
||||
Set<PKIXRevocationChecker.Option> options =
|
||||
EnumSet.noneOf(PKIXRevocationChecker.Option.class);
|
||||
|
||||
// Soft-fail: allow validation to succeed if revocation status unavailable
|
||||
boolean revocationHardFail =
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getValidation()
|
||||
.getRevocation()
|
||||
.isHardFail();
|
||||
if (!revocationHardFail) {
|
||||
options.add(PKIXRevocationChecker.Option.SOFT_FAIL);
|
||||
}
|
||||
|
||||
// Revocation mode configuration
|
||||
if ("ocsp".equalsIgnoreCase(revocationMode)) {
|
||||
// OCSP-only: prefer OCSP (default), disable fallback to CRL
|
||||
options.add(PKIXRevocationChecker.Option.NO_FALLBACK);
|
||||
} else if ("crl".equalsIgnoreCase(revocationMode)) {
|
||||
// CRL-only: prefer CRLs, disable fallback to OCSP
|
||||
options.add(PKIXRevocationChecker.Option.PREFER_CRLS);
|
||||
options.add(PKIXRevocationChecker.Option.NO_FALLBACK);
|
||||
}
|
||||
// "ocsp+crl" or other: use defaults (try OCSP first, fallback to CRL)
|
||||
|
||||
rc.setOptions(options);
|
||||
params.addCertPathChecker(rc);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to configure revocation checker: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Build path
|
||||
CertPathBuilder builder = CertPathBuilder.getInstance("PKIX");
|
||||
return (PKIXCertPathBuilderResult) builder.build(params);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract validation time from signature (TSA timestamp or signingTime)
|
||||
*
|
||||
* @param signerInfo The CMS signer information
|
||||
* @return ValidationTime containing date and source, or null if not found
|
||||
*/
|
||||
public ValidationTime extractValidationTime(SignerInformation signerInfo) {
|
||||
private byte[] parseOctalData(String data) {
|
||||
try {
|
||||
// 1) Check for timestamp token (RFC 3161) - highest priority
|
||||
var unsignedAttrs = signerInfo.getUnsignedAttributes();
|
||||
if (unsignedAttrs != null) {
|
||||
var attr =
|
||||
unsignedAttrs.get(new ASN1ObjectIdentifier("1.2.840.113549.1.9.16.2.14"));
|
||||
if (attr != null) {
|
||||
try {
|
||||
TimeStampToken tst =
|
||||
new TimeStampToken(
|
||||
new CMSSignedData(
|
||||
attr.getAttributeValues()[0]
|
||||
.toASN1Primitive()
|
||||
.getEncoded()));
|
||||
Date tstTime = tst.getTimeStampInfo().getGenTime();
|
||||
log.debug("Using timestamp token time: {}", tstTime);
|
||||
return new ValidationTime(tstTime, "timestamp");
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to parse timestamp token: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Check for signingTime attribute - fallback
|
||||
var signedAttrs = signerInfo.getSignedAttributes();
|
||||
if (signedAttrs != null) {
|
||||
var st = signedAttrs.get(CMSAttributes.signingTime);
|
||||
if (st != null) {
|
||||
ASN1Encodable val = st.getAttributeValues()[0];
|
||||
Date signingTime = null;
|
||||
if (val instanceof ASN1UTCTime ut) {
|
||||
signingTime = ut.getDate();
|
||||
} else if (val instanceof ASN1GeneralizedTime gt) {
|
||||
signingTime = gt.getDate();
|
||||
}
|
||||
if (signingTime != null) {
|
||||
log.debug("Using signingTime attribute: {}", signingTime);
|
||||
return new ValidationTime(signingTime, "signing-time");
|
||||
}
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
String[] tokens = data.split("\\\\");
|
||||
for (String token : tokens) {
|
||||
token = token.trim();
|
||||
if (!token.isEmpty()) {
|
||||
baos.write(Integer.parseInt(token, 8));
|
||||
}
|
||||
}
|
||||
return baos.toByteArray();
|
||||
} catch (Exception e) {
|
||||
log.debug("Error extracting validation time: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if certificate is outside validity period at given time
|
||||
*
|
||||
* @param cert Certificate to check
|
||||
* @param at Time to check validity
|
||||
* @return true if certificate is expired or not yet valid
|
||||
*/
|
||||
public boolean isOutsideValidityPeriod(X509Certificate cert, Date at) {
|
||||
public boolean validateCertificateChain(X509Certificate cert) {
|
||||
try {
|
||||
cert.checkValidity(at);
|
||||
CertPathValidator validator = CertPathValidator.getInstance("PKIX");
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
List<X509Certificate> certList = Arrays.asList(cert);
|
||||
CertPath certPath = cf.generateCertPath(certList);
|
||||
|
||||
Set<TrustAnchor> anchors = new HashSet<>();
|
||||
Enumeration<String> aliases = trustStore.aliases();
|
||||
while (aliases.hasMoreElements()) {
|
||||
Object trustCert = trustStore.getCertificate(aliases.nextElement());
|
||||
if (trustCert instanceof X509Certificate x509Cert) {
|
||||
anchors.add(new TrustAnchor(x509Cert, null));
|
||||
}
|
||||
}
|
||||
|
||||
PKIXParameters params = new PKIXParameters(anchors);
|
||||
params.setRevocationEnabled(false);
|
||||
validator.validate(certPath, params);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean validateTrustStore(X509Certificate cert) {
|
||||
try {
|
||||
Enumeration<String> aliases = trustStore.aliases();
|
||||
while (aliases.hasMoreElements()) {
|
||||
Object trustCert = trustStore.getCertificate(aliases.nextElement());
|
||||
if (trustCert instanceof X509Certificate && cert.equals(trustCert)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch (KeyStoreException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isRevoked(X509Certificate cert) {
|
||||
try {
|
||||
cert.checkValidity();
|
||||
return false;
|
||||
} catch (CertificateExpiredException | CertificateNotYetValidException e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if revocation checking is enabled
|
||||
*
|
||||
* @return true if revocation mode is not "none"
|
||||
*/
|
||||
public boolean isRevocationEnabled() {
|
||||
String revocationMode =
|
||||
applicationProperties.getSecurity().getValidation().getRevocation().getMode();
|
||||
return !"none".equalsIgnoreCase(revocationMode);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if certificate is a CA certificate
|
||||
*
|
||||
* @param cert Certificate to check
|
||||
* @return true if certificate has basicConstraints with CA=true
|
||||
*/
|
||||
public boolean isCA(X509Certificate cert) {
|
||||
return cert.getBasicConstraints() >= 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify if certificate is self-signed by checking signature
|
||||
*
|
||||
* @param cert Certificate to check
|
||||
* @return true if certificate is self-signed and signature is valid
|
||||
*/
|
||||
public boolean isSelfSigned(X509Certificate cert) {
|
||||
public boolean validateCertificateChainWithCustomCert(
|
||||
X509Certificate cert, X509Certificate customCert) {
|
||||
try {
|
||||
if (!cert.getSubjectX500Principal().equals(cert.getIssuerX500Principal())) {
|
||||
return false;
|
||||
}
|
||||
cert.verify(cert.getPublicKey());
|
||||
cert.verify(customCert.getPublicKey());
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate SHA-256 fingerprint of certificate
|
||||
*
|
||||
* @param cert Certificate
|
||||
* @return Hex string of SHA-256 hash
|
||||
*/
|
||||
public String sha256Fingerprint(X509Certificate cert) {
|
||||
public boolean validateTrustWithCustomCert(X509Certificate cert, X509Certificate customCert) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = md.digest(cert.getEncoded());
|
||||
return bytesToHex(hash);
|
||||
// Compare the issuer of the signature certificate with the custom certificate
|
||||
return cert.getIssuerX500Principal().equals(customCert.getSubjectX500Principal());
|
||||
} catch (Exception e) {
|
||||
return "";
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private String bytesToHex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) {
|
||||
sb.append(String.format("%02X", b));
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract all certificates from CMS signature store
|
||||
*
|
||||
* @param certStore BouncyCastle certificate store
|
||||
* @param signerCert The signer certificate
|
||||
* @return Collection of all certificates except signer
|
||||
*/
|
||||
public Collection<X509Certificate> extractIntermediateCertificates(
|
||||
Store<X509CertificateHolder> certStore, X509Certificate signerCert) {
|
||||
List<X509Certificate> intermediates = new ArrayList<>();
|
||||
try {
|
||||
JcaX509CertificateConverter converter = new JcaX509CertificateConverter();
|
||||
Collection<X509CertificateHolder> holders = certStore.getMatches(null);
|
||||
|
||||
for (X509CertificateHolder holder : holders) {
|
||||
X509Certificate cert = converter.getCertificate(holder);
|
||||
if (!cert.equals(signerCert)) {
|
||||
intermediates.add(cert);
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Error extracting intermediate certificates: {}", e.getMessage());
|
||||
}
|
||||
return intermediates;
|
||||
}
|
||||
|
||||
// ==================== Trust Store Loading ====================
|
||||
|
||||
/**
|
||||
* Load certificates from Java's system trust store (cacerts). On Windows, this includes
|
||||
* certificates from the Windows trust store. This provides maximum compatibility with what
|
||||
* browsers and OS trust.
|
||||
*/
|
||||
private void loadJavaSystemTrustStore() {
|
||||
try {
|
||||
log.info("Loading certificates from Java system trust store");
|
||||
|
||||
// Get default trust manager factory
|
||||
TrustManagerFactory tmf =
|
||||
TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm());
|
||||
tmf.init((KeyStore) null); // null = use system default
|
||||
|
||||
// Extract certificates from trust managers
|
||||
int loadedCount = 0;
|
||||
for (TrustManager tm : tmf.getTrustManagers()) {
|
||||
if (tm instanceof X509TrustManager x509tm) {
|
||||
for (X509Certificate cert : x509tm.getAcceptedIssuers()) {
|
||||
if (isCA(cert)) {
|
||||
String fingerprint = sha256Fingerprint(cert);
|
||||
String alias = "system-" + fingerprint;
|
||||
signingTrustAnchors.setCertificateEntry(alias, cert);
|
||||
loadedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Loaded {} CA certificates from Java system trust store", loadedCount);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load Java system trust store: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load bundled Mozilla CA certificate bundle from resources. This bundle contains ~140 trusted
|
||||
* root CAs from Mozilla's CA Certificate Program, suitable for validating most commercial PDF
|
||||
* signatures.
|
||||
*/
|
||||
private void loadBundledMozillaCACerts() {
|
||||
try {
|
||||
log.info("Loading bundled Mozilla CA certificates from resources");
|
||||
InputStream certStream =
|
||||
getClass().getClassLoader().getResourceAsStream("certs/cacert.pem");
|
||||
if (certStream == null) {
|
||||
log.warn("Bundled Mozilla CA certificate file not found in resources");
|
||||
return;
|
||||
}
|
||||
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
Collection<? extends Certificate> certs = cf.generateCertificates(certStream);
|
||||
certStream.close();
|
||||
|
||||
int loadedCount = 0;
|
||||
int skippedCount = 0;
|
||||
|
||||
for (Certificate cert : certs) {
|
||||
if (cert instanceof X509Certificate x509) {
|
||||
// Only add CA certificates to trust anchors
|
||||
if (isCA(x509)) {
|
||||
String fingerprint = sha256Fingerprint(x509);
|
||||
String alias = "mozilla-" + fingerprint;
|
||||
signingTrustAnchors.setCertificateEntry(alias, x509);
|
||||
loadedCount++;
|
||||
} else {
|
||||
skippedCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.info(
|
||||
"Loaded {} Mozilla CA certificates as trust anchors (skipped {} non-CA certs)",
|
||||
loadedCount,
|
||||
skippedCount);
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to load bundled Mozilla CA certificates: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void loadServerCertAsAnchor() {
|
||||
try {
|
||||
if (serverCertificateService != null
|
||||
&& serverCertificateService.isEnabled()
|
||||
&& serverCertificateService.hasServerCertificate()) {
|
||||
X509Certificate serverCert = serverCertificateService.getServerCertificate();
|
||||
|
||||
// Self-signed certificates can be trust anchors regardless of CA flag
|
||||
// Non-self-signed certificates should only be trust anchors if they're CAs
|
||||
boolean selfSigned = isSelfSigned(serverCert);
|
||||
boolean ca = isCA(serverCert);
|
||||
|
||||
if (selfSigned || ca) {
|
||||
signingTrustAnchors.setCertificateEntry("server-anchor", serverCert);
|
||||
log.info(
|
||||
"Loaded server certificate as trust anchor (self-signed: {}, CA: {})",
|
||||
selfSigned,
|
||||
ca);
|
||||
} else {
|
||||
log.warn(
|
||||
"Server certificate is neither self-signed nor a CA; not adding as trust anchor");
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed loading server certificate as anchor: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Download and parse Adobe Approved Trust List (AATL) and add CA certs as trust anchors. */
|
||||
private void loadAATLCertificates() {
|
||||
try {
|
||||
String aatlUrl = applicationProperties.getSecurity().getValidation().getAatl().getUrl();
|
||||
log.info("Loading Adobe Approved Trust List (AATL) from: {}", aatlUrl);
|
||||
byte[] pdfBytes = downloadTrustList(aatlUrl);
|
||||
if (pdfBytes == null) {
|
||||
log.warn("AATL download returned no data");
|
||||
return;
|
||||
}
|
||||
int added = parseAATLPdf(pdfBytes);
|
||||
log.info("Loaded {} AATL CA certificates into signing trust", added);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to load AATL: {}", e.getMessage());
|
||||
log.debug("AATL loading error", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Simple HTTP(S) fetch with sane timeouts. */
|
||||
private byte[] downloadTrustList(String urlStr) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(urlStr);
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(10_000);
|
||||
conn.setReadTimeout(30_000);
|
||||
conn.setInstanceFollowRedirects(true);
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
if (code == HttpURLConnection.HTTP_OK) {
|
||||
try (InputStream in = conn.getInputStream();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
byte[] buf = new byte[8192];
|
||||
int r;
|
||||
while ((r = in.read(buf)) != -1) out.write(buf, 0, r);
|
||||
return out.toByteArray();
|
||||
}
|
||||
} else {
|
||||
log.warn("AATL download failed: HTTP {}", code);
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("AATL download error: {}", e.getMessage());
|
||||
return null;
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse AATL PDF, extract the embedded "SecuritySettings.xml", and import CA certs. Returns the
|
||||
* number of newly-added CA certificates.
|
||||
*/
|
||||
private int parseAATLPdf(byte[] pdfBytes) throws Exception {
|
||||
try (PDDocument doc = Loader.loadPDF(pdfBytes)) {
|
||||
PDDocumentNameDictionary names = doc.getDocumentCatalog().getNames();
|
||||
if (names == null) {
|
||||
log.warn("AATL PDF has no name dictionary");
|
||||
return 0;
|
||||
}
|
||||
|
||||
PDEmbeddedFilesNameTreeNode efRoot = names.getEmbeddedFiles();
|
||||
if (efRoot == null) {
|
||||
log.warn("AATL PDF has no embedded files");
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 1) Try names at root level
|
||||
Map<String, PDComplexFileSpecification> top = efRoot.getNames();
|
||||
if (top != null) {
|
||||
Integer count = tryParseSecuritySettingsXML(top);
|
||||
if (count != null) return count;
|
||||
}
|
||||
|
||||
// 2) Traverse kids (name-tree)
|
||||
@SuppressWarnings("unchecked")
|
||||
List<?> kids = efRoot.getKids();
|
||||
if (kids != null) {
|
||||
for (Object kidObj : kids) {
|
||||
if (kidObj instanceof PDEmbeddedFilesNameTreeNode) {
|
||||
PDEmbeddedFilesNameTreeNode kid = (PDEmbeddedFilesNameTreeNode) kidObj;
|
||||
Map<String, PDComplexFileSpecification> map = kid.getNames();
|
||||
if (map != null) {
|
||||
Integer count = tryParseSecuritySettingsXML(map);
|
||||
if (count != null) return count;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("AATL PDF did not contain SecuritySettings.xml");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to locate "SecuritySettings.xml" in the given name map. If found and parsed, returns the
|
||||
* number of certs added; otherwise returns null.
|
||||
*/
|
||||
private Integer tryParseSecuritySettingsXML(Map<String, PDComplexFileSpecification> nameMap) {
|
||||
PDComplexFileSpecification fileSpec = nameMap.get("SecuritySettings.xml");
|
||||
if (fileSpec == null) return null;
|
||||
|
||||
PDEmbeddedFile ef = fileSpec.getEmbeddedFile();
|
||||
if (ef == null) return null;
|
||||
|
||||
try (InputStream xmlStream = ef.createInputStream()) {
|
||||
return parseSecuritySettingsXML(xmlStream);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed parsing SecuritySettings.xml: {}", e.getMessage());
|
||||
log.debug("SecuritySettings.xml parse error", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the SecuritySettings.xml and load only CA certificates (basicConstraints >= 0). Returns
|
||||
* the number of newly-added CA certificates.
|
||||
*/
|
||||
private int parseSecuritySettingsXML(InputStream xmlStream) throws Exception {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
Document doc = builder.parse(xmlStream);
|
||||
|
||||
NodeList certNodes = doc.getElementsByTagName("Certificate");
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
|
||||
int added = 0;
|
||||
for (int i = 0; i < certNodes.getLength(); i++) {
|
||||
String base64 = certNodes.item(i).getTextContent().trim();
|
||||
if (base64.isEmpty()) continue;
|
||||
|
||||
try {
|
||||
byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64);
|
||||
X509Certificate cert =
|
||||
(X509Certificate)
|
||||
cf.generateCertificate(new ByteArrayInputStream(certBytes));
|
||||
|
||||
// Only add CA certs as anchors
|
||||
if (isCA(cert)) {
|
||||
String fingerprint = sha256Fingerprint(cert);
|
||||
String alias = "aatl-" + fingerprint;
|
||||
|
||||
// avoid duplicates
|
||||
if (signingTrustAnchors.getCertificate(alias) == null) {
|
||||
signingTrustAnchors.setCertificateEntry(alias, cert);
|
||||
added++;
|
||||
}
|
||||
} else {
|
||||
log.debug(
|
||||
"Skipping non-CA certificate from AATL: {}",
|
||||
cert.getSubjectX500Principal().getName());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("Failed to parse an AATL certificate node: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download LOTL (List Of Trusted Lists), resolve national TSLs, and import qualified CA
|
||||
* certificates.
|
||||
*/
|
||||
private void loadEUTLCertificates() {
|
||||
try {
|
||||
String lotlUrl =
|
||||
applicationProperties.getSecurity().getValidation().getEutl().getLotlUrl();
|
||||
log.info("Loading EU Trusted List (LOTL) from: {}", lotlUrl);
|
||||
byte[] lotlBytes = downloadXml(lotlUrl);
|
||||
if (lotlBytes == null) {
|
||||
log.warn("LOTL download returned no data");
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> tslUrls = parseLotlForTslLocations(lotlBytes);
|
||||
log.info("Found {} national TSL locations in LOTL", tslUrls.size());
|
||||
|
||||
int totalAdded = 0;
|
||||
for (String tslUrl : tslUrls) {
|
||||
try {
|
||||
byte[] tslBytes = downloadXml(tslUrl);
|
||||
if (tslBytes == null) {
|
||||
log.warn("TSL download failed: {}", tslUrl);
|
||||
continue;
|
||||
}
|
||||
int added = parseTslAndAddCas(tslBytes, tslUrl);
|
||||
totalAdded += added;
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to parse TSL {}: {}", tslUrl, e.getMessage());
|
||||
log.debug("TSL parse error", e);
|
||||
}
|
||||
}
|
||||
|
||||
log.info("Imported {} qualified CA certificates from EUTL", totalAdded);
|
||||
} catch (Exception e) {
|
||||
log.warn("EUTL load failed: {}", e.getMessage());
|
||||
log.debug("EUTL load error", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** HTTP(S) GET for XML with sane timeouts. */
|
||||
private byte[] downloadXml(String urlStr) {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(urlStr);
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setConnectTimeout(10_000);
|
||||
conn.setReadTimeout(30_000);
|
||||
conn.setInstanceFollowRedirects(true);
|
||||
|
||||
int code = conn.getResponseCode();
|
||||
if (code == HttpURLConnection.HTTP_OK) {
|
||||
try (InputStream in = conn.getInputStream();
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
|
||||
byte[] buf = new byte[8192];
|
||||
int r;
|
||||
while ((r = in.read(buf)) != -1) out.write(buf, 0, r);
|
||||
return out.toByteArray();
|
||||
}
|
||||
} else {
|
||||
log.warn("XML download failed: HTTP {} for {}", code, urlStr);
|
||||
return null;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.warn("XML download error for {}: {}", urlStr, e.getMessage());
|
||||
return null;
|
||||
} finally {
|
||||
if (conn != null) conn.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse LOTL and return all TSL URLs from PointersToOtherTSL. */
|
||||
private List<String> parseLotlForTslLocations(byte[] lotlBytes) throws Exception {
|
||||
DocumentBuilderFactory dbf = secureDbfWithNamespaces();
|
||||
DocumentBuilder db = dbf.newDocumentBuilder();
|
||||
Document doc = db.parse(new ByteArrayInputStream(lotlBytes));
|
||||
|
||||
List<String> out = new ArrayList<>();
|
||||
NodeList ptrs = doc.getElementsByTagNameNS(NS_TSL, "PointersToOtherTSL");
|
||||
if (ptrs.getLength() == 0) return out;
|
||||
|
||||
org.w3c.dom.Element ptrRoot = (org.w3c.dom.Element) ptrs.item(0);
|
||||
NodeList locations = ptrRoot.getElementsByTagNameNS(NS_TSL, "TSLLocation");
|
||||
for (int i = 0; i < locations.getLength(); i++) {
|
||||
String url = locations.item(i).getTextContent().trim();
|
||||
if (!url.isEmpty()) out.add(url);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single national TSL, import CA certificates for qualified services in an active
|
||||
* status. Returns count of newly added CA certs.
|
||||
*/
|
||||
private int parseTslAndAddCas(byte[] tslBytes, String sourceUrl) throws Exception {
|
||||
DocumentBuilderFactory dbf = secureDbfWithNamespaces();
|
||||
DocumentBuilder db = dbf.newDocumentBuilder();
|
||||
Document doc = db.parse(new ByteArrayInputStream(tslBytes));
|
||||
|
||||
int added = 0;
|
||||
|
||||
NodeList services = doc.getElementsByTagNameNS(NS_TSL, "TSPService");
|
||||
for (int i = 0; i < services.getLength(); i++) {
|
||||
org.w3c.dom.Element svc = (org.w3c.dom.Element) services.item(i);
|
||||
org.w3c.dom.Element info = firstChildNS(svc, "ServiceInformation");
|
||||
if (info == null) continue;
|
||||
|
||||
String type = textOf(info, "ServiceTypeIdentifier");
|
||||
if (!EUTL_SERVICE_TYPES.contains(type)) continue;
|
||||
|
||||
String status = textOf(info, "ServiceStatus");
|
||||
if (!isActiveStatus(status)) continue;
|
||||
|
||||
org.w3c.dom.Element sdi = firstChildNS(info, "ServiceDigitalIdentity");
|
||||
if (sdi == null) continue;
|
||||
|
||||
NodeList digitalIds = sdi.getElementsByTagNameNS(NS_TSL, "DigitalId");
|
||||
for (int d = 0; d < digitalIds.getLength(); d++) {
|
||||
org.w3c.dom.Element did = (org.w3c.dom.Element) digitalIds.item(d);
|
||||
NodeList certNodes = did.getElementsByTagNameNS(NS_TSL, "X509Certificate");
|
||||
for (int c = 0; c < certNodes.getLength(); c++) {
|
||||
String base64 = certNodes.item(c).getTextContent().trim();
|
||||
if (base64.isEmpty()) continue;
|
||||
|
||||
try {
|
||||
byte[] certBytes = java.util.Base64.getMimeDecoder().decode(base64);
|
||||
CertificateFactory cf = CertificateFactory.getInstance("X.509");
|
||||
X509Certificate cert =
|
||||
(X509Certificate)
|
||||
cf.generateCertificate(new ByteArrayInputStream(certBytes));
|
||||
|
||||
if (!isCA(cert)) {
|
||||
log.debug(
|
||||
"Skipping non-CA in TSL {}: {}",
|
||||
sourceUrl,
|
||||
cert.getSubjectX500Principal().getName());
|
||||
continue;
|
||||
}
|
||||
|
||||
String fp = sha256Fingerprint(cert);
|
||||
String alias = "eutl-" + fp;
|
||||
|
||||
if (signingTrustAnchors.getCertificate(alias) == null) {
|
||||
signingTrustAnchors.setCertificateEntry(alias, cert);
|
||||
added++;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug(
|
||||
"Failed to import a certificate from {}: {}",
|
||||
sourceUrl,
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.debug("TSL {} → imported {} CA certificates", sourceUrl, added);
|
||||
return added;
|
||||
}
|
||||
|
||||
/** Check if service status is active (per ETSI TS 119 612). */
|
||||
private boolean isActiveStatus(String statusUri) {
|
||||
if (STATUS_UNDER_SUPERVISION.equals(statusUri)) return true;
|
||||
if (STATUS_ACCREDITED.equals(statusUri)) return true;
|
||||
boolean acceptTransitional =
|
||||
applicationProperties
|
||||
.getSecurity()
|
||||
.getValidation()
|
||||
.getEutl()
|
||||
.isAcceptTransitional();
|
||||
if (acceptTransitional && STATUS_SUPERVISION_IN_CESSATION.equals(statusUri)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Create secure DocumentBuilderFactory with namespace awareness. */
|
||||
private DocumentBuilderFactory secureDbfWithNamespaces() throws Exception {
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setNamespaceAware(true);
|
||||
// Secure processing hardening
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
|
||||
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
|
||||
factory.setXIncludeAware(false);
|
||||
factory.setExpandEntityReferences(false);
|
||||
return factory;
|
||||
}
|
||||
|
||||
/** Get first child element with given local name in TSL namespace. */
|
||||
private org.w3c.dom.Element firstChildNS(org.w3c.dom.Element parent, String localName) {
|
||||
NodeList nl = parent.getElementsByTagNameNS(NS_TSL, localName);
|
||||
return (nl.getLength() == 0) ? null : (org.w3c.dom.Element) nl.item(0);
|
||||
}
|
||||
|
||||
/** Get text content of first child with given local name. */
|
||||
private String textOf(org.w3c.dom.Element parent, String localName) {
|
||||
org.w3c.dom.Element e = firstChildNS(parent, localName);
|
||||
return (e == null) ? "" : e.getTextContent().trim();
|
||||
}
|
||||
|
||||
/** Get signing trust store */
|
||||
public KeyStore getSigningTrustStore() {
|
||||
return signingTrustAnchors;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ import org.apache.pdfbox.pdmodel.PDResources;
|
||||
import org.apache.pdfbox.pdmodel.graphics.PDXObject;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
|
||||
/** Service class responsible for removing image objects from a PDF document. */
|
||||
@Service
|
||||
public class PdfImageRemovalService {
|
||||
@@ -26,6 +28,13 @@ public class PdfImageRemovalService {
|
||||
* @throws IOException If an error occurs while processing the PDF document.
|
||||
*/
|
||||
public PDDocument removeImagesFromPdf(PDDocument document) throws IOException {
|
||||
return removeImagesFromPdf(document, null);
|
||||
}
|
||||
|
||||
public PDDocument removeImagesFromPdf(PDDocument document, JobProgressTracker progressTracker)
|
||||
throws IOException {
|
||||
boolean trackProgress = progressTracker != null && progressTracker.isEnabled();
|
||||
|
||||
// Iterate over each page in the PDF document
|
||||
for (PDPage page : document.getPages()) {
|
||||
PDResources resources = page.getResources();
|
||||
@@ -45,6 +54,10 @@ public class PdfImageRemovalService {
|
||||
for (COSName name : namesToRemove) {
|
||||
resources.put(name, (PDXObject) null);
|
||||
}
|
||||
|
||||
if (trackProgress) {
|
||||
progressTracker.advance();
|
||||
}
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
@@ -65,22 +65,6 @@ security:
|
||||
enableKeyCleanup: true # Set to 'true' to enable key pair cleanup
|
||||
keyRetentionDays: 7 # Number of days to retain old keys. The default is 7 days.
|
||||
secureCookie: false # Set to 'true' to use secure cookies for JWTs
|
||||
validation: # PDF signature validation settings
|
||||
trust:
|
||||
serverAsAnchor: true # Trust server certificate as anchor for PDF signatures (if configured and self-signed or CA)
|
||||
useSystemTrust: true # Trust Java/OS system trust store for PDF signature validation
|
||||
useMozillaBundle: true # Trust bundled Mozilla CA bundle (~140 CAs) for PDF signature validation
|
||||
useAATL: false # Trust Adobe Approved Trust List (AATL) for PDF signature validation - downloads from Adobe on startup if enabled
|
||||
useEUTL: false # Trust EU Trusted List (EUTL) for eIDAS qualified certificates - downloads LOTL and national TSLs on startup if enabled
|
||||
allowAIA: false # Allow JDK to fetch issuer certificates and revocation information from network (OCSP/CRL/AIA)
|
||||
aatl:
|
||||
url: https://trustlist.adobe.com/tl.pdf # Adobe Approved Trust List download URL
|
||||
eutl:
|
||||
lotlUrl: https://ec.europa.eu/tools/lotl/eu-lotl.xml # EU List Of Trusted Lists (LOTL) URL
|
||||
acceptTransitional: false # Accept certificates with 'supervisionincessation' status (transitional state)
|
||||
revocation:
|
||||
mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback)
|
||||
hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail)
|
||||
|
||||
premium:
|
||||
key: 00000000-0000-0000-0000-000000000000
|
||||
|
||||
+3
-1
@@ -14,17 +14,19 @@ import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
|
||||
class RearrangePagesPDFControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory mockPdfDocumentFactory;
|
||||
@Mock private JobProgressService mockJobProgressService;
|
||||
|
||||
private RearrangePagesPDFController sut;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
MockitoAnnotations.openMocks(this);
|
||||
sut = new RearrangePagesPDFController(mockPdfDocumentFactory);
|
||||
sut = new RearrangePagesPDFController(mockPdfDocumentFactory, mockJobProgressService);
|
||||
}
|
||||
|
||||
/** Tests the behavior of the oddEvenMerge method when there are no pages in the document. */
|
||||
|
||||
+7
@@ -22,11 +22,14 @@ import org.springframework.mock.web.MockMultipartFile;
|
||||
|
||||
import stirling.software.SPDF.model.api.general.RotatePDFRequest;
|
||||
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||
import stirling.software.common.service.JobProgressService;
|
||||
import stirling.software.common.service.JobProgressTracker;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
public class RotationControllerTest {
|
||||
|
||||
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||
@Mock private JobProgressService jobProgressService;
|
||||
|
||||
@InjectMocks private RotationController rotationController;
|
||||
|
||||
@@ -42,12 +45,16 @@ public class RotationControllerTest {
|
||||
PDDocument mockDocument = mock(PDDocument.class);
|
||||
PDPageTree mockPages = mock(PDPageTree.class);
|
||||
PDPage mockPage = mock(PDPage.class);
|
||||
JobProgressTracker mockTracker = mock(JobProgressTracker.class);
|
||||
|
||||
when(pdfDocumentFactory.load(request)).thenReturn(mockDocument);
|
||||
when(mockDocument.getPages()).thenReturn(mockPages);
|
||||
when(mockPages.getCount()).thenReturn(1);
|
||||
when(mockPages.iterator())
|
||||
.thenReturn(java.util.Collections.singletonList(mockPage).iterator());
|
||||
when(mockPage.getRotation()).thenReturn(0);
|
||||
when(jobProgressService.tracker(1)).thenReturn(mockTracker);
|
||||
when(mockTracker.isEnabled()).thenReturn(false);
|
||||
|
||||
// Act
|
||||
ResponseEntity<byte[]> response = rotationController.rotatePDF(request);
|
||||
|
||||
+100
-61
@@ -2,20 +2,20 @@ package stirling.software.SPDF.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.doNothing;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
import java.security.PublicKey;
|
||||
import java.security.cert.CertificateExpiredException;
|
||||
import java.security.cert.X509Certificate;
|
||||
import java.util.Date;
|
||||
|
||||
import javax.security.auth.x500.X500Principal;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import stirling.software.common.model.ApplicationProperties;
|
||||
import org.mockito.Mockito;
|
||||
|
||||
/** Tests for the CertificateValidationService using mocked certificates. */
|
||||
class CertificateValidationServiceTest {
|
||||
@@ -26,82 +26,121 @@ class CertificateValidationServiceTest {
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws Exception {
|
||||
// Create mock ApplicationProperties with default validation settings
|
||||
ApplicationProperties applicationProperties = mock(ApplicationProperties.class);
|
||||
ApplicationProperties.Security security = mock(ApplicationProperties.Security.class);
|
||||
ApplicationProperties.Security.Validation validation =
|
||||
mock(ApplicationProperties.Security.Validation.class);
|
||||
ApplicationProperties.Security.Validation.Trust trust =
|
||||
mock(ApplicationProperties.Security.Validation.Trust.class);
|
||||
ApplicationProperties.Security.Validation.Revocation revocation =
|
||||
mock(ApplicationProperties.Security.Validation.Revocation.class);
|
||||
|
||||
when(applicationProperties.getSecurity()).thenReturn(security);
|
||||
when(security.getValidation()).thenReturn(validation);
|
||||
when(validation.getTrust()).thenReturn(trust);
|
||||
when(validation.getRevocation()).thenReturn(revocation);
|
||||
when(validation.isAllowAIA()).thenReturn(false);
|
||||
when(validation.isEnableEUTL()).thenReturn(false);
|
||||
when(trust.isServerAsAnchor()).thenReturn(false);
|
||||
when(trust.isUseSystemTrust()).thenReturn(false);
|
||||
when(trust.isUseMozillaBundle()).thenReturn(false);
|
||||
when(revocation.getMode()).thenReturn("none");
|
||||
when(revocation.isHardFail()).thenReturn(false);
|
||||
|
||||
validationService = new CertificateValidationService(null, applicationProperties);
|
||||
validationService = new CertificateValidationService();
|
||||
|
||||
// Create mock certificates
|
||||
validCertificate = mock(X509Certificate.class);
|
||||
expiredCertificate = mock(X509Certificate.class);
|
||||
|
||||
// Set up behaviors for valid certificate (both overloads)
|
||||
doNothing().when(validCertificate).checkValidity();
|
||||
doNothing().when(validCertificate).checkValidity(any(Date.class));
|
||||
// Set up behaviors for valid certificate
|
||||
doNothing().when(validCertificate).checkValidity(); // No exception means valid
|
||||
|
||||
// Set up behaviors for expired certificate (both overloads)
|
||||
// Set up behaviors for expired certificate
|
||||
doThrow(new CertificateExpiredException("Certificate expired"))
|
||||
.when(expiredCertificate)
|
||||
.checkValidity();
|
||||
doThrow(new CertificateExpiredException("Certificate expired"))
|
||||
.when(expiredCertificate)
|
||||
.checkValidity(any(Date.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsOutsideValidityPeriod_ValidCertificate() {
|
||||
void testIsRevoked_ValidCertificate() {
|
||||
// When certificate is valid (not expired)
|
||||
boolean result = validationService.isOutsideValidityPeriod(validCertificate, new Date());
|
||||
|
||||
// Then it should not be outside validity period
|
||||
assertFalse(result, "Valid certificate should not be outside validity period");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsOutsideValidityPeriod_ExpiredCertificate() {
|
||||
// When certificate is expired
|
||||
boolean result = validationService.isOutsideValidityPeriod(expiredCertificate, new Date());
|
||||
|
||||
// Then it should be outside validity period
|
||||
assertTrue(result, "Expired certificate should be outside validity period");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void testDeprecatedIsRevoked_ValidCertificate() {
|
||||
// Test deprecated method for backwards compatibility
|
||||
boolean result = validationService.isRevoked(validCertificate);
|
||||
|
||||
// Then it should not be considered revoked
|
||||
assertFalse(result, "Valid certificate should not be considered revoked");
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("deprecation")
|
||||
void testDeprecatedIsRevoked_ExpiredCertificate() {
|
||||
// Test deprecated method for backwards compatibility
|
||||
void testIsRevoked_ExpiredCertificate() {
|
||||
// When certificate is expired
|
||||
boolean result = validationService.isRevoked(expiredCertificate);
|
||||
assertTrue(result, "Expired certificate should be considered revoked (legacy behavior)");
|
||||
|
||||
// Then it should be considered revoked
|
||||
assertTrue(result, "Expired certificate should be considered revoked");
|
||||
}
|
||||
|
||||
// Note: Full integration tests for buildAndValidatePath() would require
|
||||
// real certificate chains and trust anchors. These would be better as
|
||||
// integration tests using actual signed PDFs from the test-signed-pdfs directory.
|
||||
@Test
|
||||
void testValidateTrustWithCustomCert_Match() {
|
||||
// Create certificates with matching issuer and subject
|
||||
X509Certificate issuingCert = mock(X509Certificate.class);
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
|
||||
// Create X500Principal objects for issuer and subject
|
||||
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
|
||||
|
||||
// Mock the issuer of the signed certificate to match the subject of the issuing certificate
|
||||
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
|
||||
when(issuingCert.getSubjectX500Principal()).thenReturn(issuerPrincipal);
|
||||
|
||||
// When validating trust with custom cert
|
||||
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
|
||||
|
||||
// Then validation should succeed
|
||||
assertTrue(result, "Certificate with matching issuer and subject should validate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateTrustWithCustomCert_NoMatch() {
|
||||
// Create certificates with non-matching issuer and subject
|
||||
X509Certificate issuingCert = mock(X509Certificate.class);
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
|
||||
// Create X500Principal objects for issuer and subject
|
||||
X500Principal issuerPrincipal = new X500Principal("CN=Test Issuer");
|
||||
X500Principal differentPrincipal = new X500Principal("CN=Different Name");
|
||||
|
||||
// Mock the issuer of the signed certificate to NOT match the subject of the issuing
|
||||
// certificate
|
||||
when(signedCert.getIssuerX500Principal()).thenReturn(issuerPrincipal);
|
||||
when(issuingCert.getSubjectX500Principal()).thenReturn(differentPrincipal);
|
||||
|
||||
// When validating trust with custom cert
|
||||
boolean result = validationService.validateTrustWithCustomCert(signedCert, issuingCert);
|
||||
|
||||
// Then validation should fail
|
||||
assertFalse(result, "Certificate with non-matching issuer and subject should not validate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateCertificateChainWithCustomCert_Success() throws Exception {
|
||||
// Setup mock certificates
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
X509Certificate signingCert = mock(X509Certificate.class);
|
||||
PublicKey publicKey = mock(PublicKey.class);
|
||||
|
||||
when(signingCert.getPublicKey()).thenReturn(publicKey);
|
||||
|
||||
// When verifying the certificate with the signing cert's public key, don't throw exception
|
||||
doNothing().when(signedCert).verify(Mockito.any());
|
||||
|
||||
// When validating certificate chain with custom cert
|
||||
boolean result =
|
||||
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
|
||||
|
||||
// Then validation should succeed
|
||||
assertTrue(result, "Certificate chain with proper signing should validate");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testValidateCertificateChainWithCustomCert_Failure() throws Exception {
|
||||
// Setup mock certificates
|
||||
X509Certificate signedCert = mock(X509Certificate.class);
|
||||
X509Certificate signingCert = mock(X509Certificate.class);
|
||||
PublicKey publicKey = mock(PublicKey.class);
|
||||
|
||||
when(signingCert.getPublicKey()).thenReturn(publicKey);
|
||||
|
||||
// When verifying the certificate with the signing cert's public key, throw exception
|
||||
// Need to use a specific exception that verify() can throw
|
||||
doThrow(new java.security.SignatureException("Verification failed"))
|
||||
.when(signedCert)
|
||||
.verify(Mockito.any());
|
||||
|
||||
// When validating certificate chain with custom cert
|
||||
boolean result =
|
||||
validationService.validateCertificateChainWithCustomCert(signedCert, signingCert);
|
||||
|
||||
// Then validation should fail
|
||||
assertFalse(result, "Certificate chain with failed signing should not validate");
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+136
-137
@@ -10,24 +10,24 @@
|
||||
"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",
|
||||
"@embedpdf/core": "^1.3.14",
|
||||
"@embedpdf/engines": "^1.3.14",
|
||||
"@embedpdf/plugin-annotation": "^1.3.14",
|
||||
"@embedpdf/plugin-export": "^1.3.14",
|
||||
"@embedpdf/plugin-history": "^1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.14",
|
||||
"@embedpdf/plugin-loader": "^1.3.14",
|
||||
"@embedpdf/plugin-pan": "^1.3.14",
|
||||
"@embedpdf/plugin-render": "^1.3.14",
|
||||
"@embedpdf/plugin-rotate": "^1.3.14",
|
||||
"@embedpdf/plugin-scroll": "^1.3.14",
|
||||
"@embedpdf/plugin-search": "^1.3.14",
|
||||
"@embedpdf/plugin-selection": "^1.3.14",
|
||||
"@embedpdf/plugin-spread": "^1.3.14",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.14",
|
||||
"@embedpdf/plugin-tiling": "^1.3.14",
|
||||
"@embedpdf/plugin-viewport": "^1.3.14",
|
||||
"@embedpdf/plugin-zoom": "^1.3.14",
|
||||
"@embedpdf/core": "^1.3.1",
|
||||
"@embedpdf/engines": "^1.3.1",
|
||||
"@embedpdf/plugin-annotation": "^1.3.1",
|
||||
"@embedpdf/plugin-export": "^1.3.1",
|
||||
"@embedpdf/plugin-history": "^1.3.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.1",
|
||||
"@embedpdf/plugin-loader": "^1.3.1",
|
||||
"@embedpdf/plugin-pan": "^1.3.1",
|
||||
"@embedpdf/plugin-render": "^1.3.1",
|
||||
"@embedpdf/plugin-rotate": "^1.3.1",
|
||||
"@embedpdf/plugin-scroll": "^1.3.1",
|
||||
"@embedpdf/plugin-search": "^1.3.1",
|
||||
"@embedpdf/plugin-selection": "^1.3.1",
|
||||
"@embedpdf/plugin-spread": "^1.3.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.1",
|
||||
"@embedpdf/plugin-tiling": "^1.3.1",
|
||||
"@embedpdf/plugin-viewport": "^1.3.1",
|
||||
"@embedpdf/plugin-zoom": "^1.3.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
@@ -497,13 +497,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/core": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.14.tgz",
|
||||
"integrity": "sha512-lE/vfhA53CxamaCfGWEibrEPr+JeZT42QCF+cOELUwv4+Zt6b+IE6+4wsznx/8wjjJYwllXJ3GJ/un1UzTqARw==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/core/-/core-1.3.1.tgz",
|
||||
"integrity": "sha512-2Az6trhiMMBIv+GFvV8H8UOS1gwQn7NK0KaJMcdsZbUHYLO0P95aVd6Pi/GRzEH4XyF51TDIoTOAUtf07TQ5dQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/engines": "1.3.14",
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/engines": "1.3.1",
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -513,13 +513,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/engines": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.3.14.tgz",
|
||||
"integrity": "sha512-+/FPW2gAzj2lQYvsMH/Oj9+MEXgkyEuyYDC+HFkltTuXvmiP2S/3BD0YslZDX9K4BzcmMxnWB+BiQpNJokbDVg==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/engines/-/engines-1.3.1.tgz",
|
||||
"integrity": "sha512-G3pI+18la7spviUMuA5s9/hV95jlfkA2+CNxqlHBO5ocw3641E3d36Lv+mx+6yU7k0B5vEOQPZDGRMg7KFziBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14",
|
||||
"@embedpdf/pdfium": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1",
|
||||
"@embedpdf/pdfium": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
@@ -529,31 +529,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/models": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.3.14.tgz",
|
||||
"integrity": "sha512-BujY4bmr8b2DQdoZkOge03SzoRVoWxzfIQATLSPPtp4WiFh1U4BPp6cADlGuCwGkp6zBcH/aM4h8PwwA75d/eg==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/models/-/models-1.3.1.tgz",
|
||||
"integrity": "sha512-OzmO1rQAuOP/Y3aYXmW21dPNAx49olhr9ZO2hDdI0fbNBHTVGxnaKqOISxVmUz7TmhTwVBljERACnaA8Ib4b4Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/pdfium": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.3.14.tgz",
|
||||
"integrity": "sha512-TQMZabXzHmzvvfPwopubFcYgQuYV7POvMgjICYu3Pgfn3sgr+UdIUh3aNXR/COcl3q8sXPMFQ2GDuyOHR9QQnA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/pdfium/-/pdfium-1.3.1.tgz",
|
||||
"integrity": "sha512-qYGSS5ntz6DSY9Cxw/aigvHqGB+AKJLEcymNTZOL0GdlBzZpL++dOIYNEYHO2Tm/lOQVpE7I0e+Xh2TvD8O1zQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-annotation": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.3.14.tgz",
|
||||
"integrity": "sha512-JJYqEWwUKCdBZsXCDq/CW96p3pVLn8N+XZ4W3OyL7djI2fvYC9x6ys9m82vwlSathAVOxk1D7xXiY8AzJQVF0Q==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-annotation/-/plugin-annotation-1.3.1.tgz",
|
||||
"integrity": "sha512-mmePRYYBB8v8NIZ95XVfFkpyQ2QiKIGdWyvrPeJXSbL3/K6d6ix+o/jHBVvBWyTsQzdIlzs+FW8+iT0M1zkEow==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14",
|
||||
"@embedpdf/utils": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1",
|
||||
"@embedpdf/utils": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-history": "1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.14",
|
||||
"@embedpdf/plugin-selection": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-history": "1.3.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.1",
|
||||
"@embedpdf/plugin-selection": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -561,15 +561,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-export": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.14.tgz",
|
||||
"integrity": "sha512-fMGp2YxvI4uTRIViUKxfnJts2Jw/vktEM45XUNGNSjT/kAW6znVNgdceYjpK++xU8CGs2grAQ1i5UvMd3aRNDA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-export/-/plugin-export-1.3.1.tgz",
|
||||
"integrity": "sha512-reb03vNPFP5GuIAFExMcuYBVYu/deVO2v8EoCwRZ/lzzYMORIkJjpNWDQPo9VfyGBh1x4/o3CHvxisU1Y1tDLg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -577,15 +577,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-history": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.14.tgz",
|
||||
"integrity": "sha512-77hnNLp0W0FHw8lT7SeqzCgp8bOClfeOAPZdcInu/jPDhVASUGYbtE/0fkLhiaqPH7kyMirNCLif4sF6n4b5vg==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-history/-/plugin-history-1.3.1.tgz",
|
||||
"integrity": "sha512-HrPkWQmAk08mbHiOcIN4htVq5KJMqI9zSjAqaYQEhV/TugeHfWVpK+xMst/PzuFb14HWgk5gWXjtV5E4SDlw9w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -593,15 +593,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-interaction-manager": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.14.tgz",
|
||||
"integrity": "sha512-nR0ZxNoTQtGqOHhweFh6QJ+nUJ4S4Ag1wWur6vAUAi8U95HUOfZhOEa0polZo0zR9WmmblGqRWjFM+mVSOoi1w==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-interaction-manager/-/plugin-interaction-manager-1.3.1.tgz",
|
||||
"integrity": "sha512-8h3y5a9tQ1fZlc4mP1/+XKyuHWwcQEm9AujKxy+6f6omtCBzpnKrH95bURgYOzQEBGY7d5C3HvG6JOlh0o1x3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -609,15 +609,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-loader": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.14.tgz",
|
||||
"integrity": "sha512-KoJX1MacEWE2DrO1OeZeG/Ehz76//u+ida/xb4r9BfwqAp5TfYlksq09cOvcF8LMW5FY4pbAL+AHKI1Hjz+HNA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-loader/-/plugin-loader-1.3.1.tgz",
|
||||
"integrity": "sha512-NjNmA7TOs3E/zwb9I+YohzyGkxq8y5NUGu0MKgh2g41lZoFvyqTAjFPar+RjEiLX8iiJiwNZswyJsNrytmS3Xg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -625,17 +625,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-pan": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.3.14.tgz",
|
||||
"integrity": "sha512-7EG+I5nn8yDCV8pT4x/g5mv7zJli2t3wPrh6Kt8uIpUorPHNb6J0Z67gl0uc/8rEasNzuKOuT0er46Y6/UYLzQ==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-pan/-/plugin-pan-1.3.1.tgz",
|
||||
"integrity": "sha512-lF1gkz/a77G3+Rr8MOefkGnPJ1i5xWnClXm2ZzYAl7PbOScp59/PaP7qeU7eMPC4FHQM81ZhCgVYGXogbaB8ww==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.14",
|
||||
"@embedpdf/plugin-viewport": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.1",
|
||||
"@embedpdf/plugin-viewport": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -643,15 +643,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-render": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.14.tgz",
|
||||
"integrity": "sha512-IPj7GCQXJBsY++JaU+z7y+FwX5NaDBj4YYV6hsHNtSGf42Y1AdlwJzDYetivG2bA84xmk7KgD1X2Y3eIFBhjwA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-render/-/plugin-render-1.3.1.tgz",
|
||||
"integrity": "sha512-c9oH097e1CVUpYF9RgZRfV/7XCJ0pf+svdT1wyM2MbWby06ti20oCwT9wf7BLY0hPQ7+eO3wunr1I1/y3MnVrw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -659,15 +659,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-rotate": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.3.14.tgz",
|
||||
"integrity": "sha512-OroEm11x/fPPXI9C0X+nm9LOjwaI0MvsToZRH+HpV60/FbQeOJvt6D8wThCDVLK95Na6A+JeYIMEu+Hiix7H+A==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-rotate/-/plugin-rotate-1.3.1.tgz",
|
||||
"integrity": "sha512-mRAlIW7IZAnCyDuYqN13yDc6yoNIYLUB4uYTUAR7vTIt021C8H5jDHk9TmLwcH0tQ8/R3yHuDm/XPAe0zfs81g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -675,16 +675,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-scroll": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.14.tgz",
|
||||
"integrity": "sha512-fQbt7OlRMLQJMuZj/Bzh0qpRxMw1ld5Qe/OTw8N54b/plljnFA52joE7cITl3H03huWWyHS3NKOScbw7f34dog==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-scroll/-/plugin-scroll-1.3.1.tgz",
|
||||
"integrity": "sha512-mDvK3DyBZC8/8pOEdJsWtSjCmV2ZuZJJ6xfspJpsaDVywo1Vq6M55BtKThkhqED6mqbFWTN9rP9cbWG8KDBWVA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-viewport": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-viewport": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -692,16 +692,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-search": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.3.14.tgz",
|
||||
"integrity": "sha512-tlZEgR2tG+GSNnh2u1SjCxhUHfTDgcr38sE/xRK1bRLDGPZWlr6Ln7qP7JSWqeYBGni75sGrj0iZqcZbPWyJag==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-search/-/plugin-search-1.3.1.tgz",
|
||||
"integrity": "sha512-SLwYPQg1NJWytq2sd4MnWFmRVGgzwbohBedB2kH0ALsvdnoRYqgjR5HqAsKgoRJO/pphQhHlk3L1gLW62r6hqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-loader": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-loader": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -709,17 +709,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-selection": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.14.tgz",
|
||||
"integrity": "sha512-EXENuaAsse3rT6cjA1nYzyrNvoy62ojJl28wblCng6zcs3HSlGPemIQZAvaYKPUxoY608M+6nKlcMQ5neRnk/A==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-selection/-/plugin-selection-1.3.1.tgz",
|
||||
"integrity": "sha512-yef2XB/zR7zjyeUB3Ul0SbTcXqu5isR0GtINkFwL7bJMok6HpYNDnMXSuo55BaxI0dOCnnCSZfoRkAgosnZ1uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.14",
|
||||
"@embedpdf/plugin-viewport": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.1",
|
||||
"@embedpdf/plugin-viewport": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -727,16 +727,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-spread": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.3.14.tgz",
|
||||
"integrity": "sha512-DVlk6tDgUoDRkp2S4Jc3LrRTuf4DPMlph9vywJw5z6Qpbh0vgcMnObg896/S0Eu5FgACNAj0WGcXpLrcrn5b9Q==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-spread/-/plugin-spread-1.3.1.tgz",
|
||||
"integrity": "sha512-RJ/kgJsFRdtWlPMXTW1feUSb6WHIvxtNRLgqzX8dlFIoyc4oZex2Vw+URo/VZuWSe/NvCIihQ20rkNAQJMnNMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-loader": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-loader": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -744,35 +744,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-thumbnail": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.3.14.tgz",
|
||||
"integrity": "sha512-cnwb5dG8Jph8XSArys1WFCQ6kK2R5FKoO0B5mDrHFv9Fcm2pKszlmZC/NDoskX4pgNUgSnwhI1X3cP37ebF9Ng==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-thumbnail/-/plugin-thumbnail-1.3.1.tgz",
|
||||
"integrity": "sha512-xv96ESa7JgD5z+TzcOK18/u0gq3d9v7QPv2wpr0ZhcnwLwf4sH0eUJZIsv7z7DMOpBNz7o7jJbrtxDUdCEHGhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-render": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-render": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
"vue": ">=3.2.0"
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-tiling": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.3.14.tgz",
|
||||
"integrity": "sha512-SaCTo2LdZwGeE6jCqkwJxvwt8YKbsI3QGxa9S7Ez+5OcBchlhHeTfLQswcErDQ3WH2p8WHtGuucAcOLrVVOm0A==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-tiling/-/plugin-tiling-1.3.1.tgz",
|
||||
"integrity": "sha512-Q8RF80fb6y9GDAKwvgsu0BsWJlQuhNCtSKWwp3YcZJtIBFm94DVcg0zTgvDmE9/WNOmn4Z1Edt86usmYauHolw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-render": "1.3.14",
|
||||
"@embedpdf/plugin-scroll": "1.3.14",
|
||||
"@embedpdf/plugin-viewport": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-render": "1.3.1",
|
||||
"@embedpdf/plugin-scroll": "1.3.1",
|
||||
"@embedpdf/plugin-viewport": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -780,15 +779,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-viewport": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.14.tgz",
|
||||
"integrity": "sha512-mfJ7EbbU68eKk6oFvQ4ozGJNpxUxWbjQ5Gm3uuB+Gj5/tWgBocBOX36k/9LgivEEeX7g2S0tOgyErljApmH8Vg==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-viewport/-/plugin-viewport-1.3.1.tgz",
|
||||
"integrity": "sha512-gzosrWL18ZhN175Kxocf/p7uqYBhNHvEuV1CpJQmN7ys48aew6Qq8z7MjAsCnJBANXk/8syNdo3qWwBriyjQNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14"
|
||||
"@embedpdf/models": "1.3.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -796,19 +795,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/plugin-zoom": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.3.14.tgz",
|
||||
"integrity": "sha512-/N5tyMk+8OzhObrS3O9yPkcmX8EPiuTo+WaT2QCVSmIUqKnOO4AnKpHJ6Vl0uVhcuXHCMwLucZKyhJ7tRqavwg==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/plugin-zoom/-/plugin-zoom-1.3.1.tgz",
|
||||
"integrity": "sha512-3GXpgv6XmZiQnjaPbsxblTqUn84ALFiyONh2gwrEU9apB6STT3TQiY0QRindwrUXdQLpCSjRSB9PpDBCtTww7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@embedpdf/models": "1.3.14",
|
||||
"@embedpdf/models": "1.3.1",
|
||||
"hammerjs": "^2.0.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@embedpdf/core": "1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.14",
|
||||
"@embedpdf/plugin-scroll": "1.3.14",
|
||||
"@embedpdf/plugin-viewport": "1.3.14",
|
||||
"@embedpdf/core": "1.3.1",
|
||||
"@embedpdf/plugin-interaction-manager": "1.3.1",
|
||||
"@embedpdf/plugin-scroll": "1.3.1",
|
||||
"@embedpdf/plugin-viewport": "1.3.1",
|
||||
"preact": "^10.26.4",
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0",
|
||||
@@ -816,9 +815,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@embedpdf/utils": {
|
||||
"version": "1.3.14",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.3.14.tgz",
|
||||
"integrity": "sha512-gxEJD12nageCMqAjdbicNfDQolXU3nvnV0EX96OdZITRNj0Q1tisutVYoaxcCiJu3vvIEOzipjsAnQOubbFCEA==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@embedpdf/utils/-/utils-1.3.1.tgz",
|
||||
"integrity": "sha512-6trYysnggwCCTB2q7cX6tkOTbZJNtt2YYZohPCmh0yaDpkfNSgwDwD0jCLtEU2UZLQoH4+2GvNo+4xe+KAGlIQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"preact": "^10.26.4",
|
||||
|
||||
+18
-18
@@ -6,24 +6,24 @@
|
||||
"proxy": "http://localhost:8080",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.7.7",
|
||||
"@embedpdf/core": "^1.3.14",
|
||||
"@embedpdf/engines": "^1.3.14",
|
||||
"@embedpdf/plugin-annotation": "^1.3.14",
|
||||
"@embedpdf/plugin-export": "^1.3.14",
|
||||
"@embedpdf/plugin-history": "^1.3.14",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.14",
|
||||
"@embedpdf/plugin-loader": "^1.3.14",
|
||||
"@embedpdf/plugin-pan": "^1.3.14",
|
||||
"@embedpdf/plugin-render": "^1.3.14",
|
||||
"@embedpdf/plugin-rotate": "^1.3.14",
|
||||
"@embedpdf/plugin-scroll": "^1.3.14",
|
||||
"@embedpdf/plugin-search": "^1.3.14",
|
||||
"@embedpdf/plugin-selection": "^1.3.14",
|
||||
"@embedpdf/plugin-spread": "^1.3.14",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.14",
|
||||
"@embedpdf/plugin-tiling": "^1.3.14",
|
||||
"@embedpdf/plugin-viewport": "^1.3.14",
|
||||
"@embedpdf/plugin-zoom": "^1.3.14",
|
||||
"@embedpdf/core": "^1.3.1",
|
||||
"@embedpdf/engines": "^1.3.1",
|
||||
"@embedpdf/plugin-annotation": "^1.3.1",
|
||||
"@embedpdf/plugin-export": "^1.3.1",
|
||||
"@embedpdf/plugin-history": "^1.3.1",
|
||||
"@embedpdf/plugin-interaction-manager": "^1.3.1",
|
||||
"@embedpdf/plugin-loader": "^1.3.1",
|
||||
"@embedpdf/plugin-pan": "^1.3.1",
|
||||
"@embedpdf/plugin-render": "^1.3.1",
|
||||
"@embedpdf/plugin-rotate": "^1.3.1",
|
||||
"@embedpdf/plugin-scroll": "^1.3.1",
|
||||
"@embedpdf/plugin-search": "^1.3.1",
|
||||
"@embedpdf/plugin-selection": "^1.3.1",
|
||||
"@embedpdf/plugin-spread": "^1.3.1",
|
||||
"@embedpdf/plugin-thumbnail": "^1.3.1",
|
||||
"@embedpdf/plugin-tiling": "^1.3.1",
|
||||
"@embedpdf/plugin-viewport": "^1.3.1",
|
||||
"@embedpdf/plugin-zoom": "^1.3.1",
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@iconify/react": "^6.0.2",
|
||||
|
||||
@@ -399,3 +399,101 @@
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* -----------------------
|
||||
Async Job Progress HUD
|
||||
----------------------- */
|
||||
.jobProgressContainer {
|
||||
position: absolute;
|
||||
left: 8px;
|
||||
right: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
z-index: 6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.jobProgressRow {
|
||||
pointer-events: auto;
|
||||
border-radius: 12px;
|
||||
padding: 10px 14px 12px;
|
||||
background: rgba(18, 26, 44, 0.88);
|
||||
box-shadow: 0 18px 36px rgba(11, 16, 28, 0.25);
|
||||
backdrop-filter: blur(12px);
|
||||
color: #f1f6ff;
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.jobProgressRow[data-status='failed'] {
|
||||
background: rgba(128, 28, 38, 0.9);
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme='dark']) .jobProgressRow {
|
||||
background: rgba(28, 39, 58, 0.9);
|
||||
color: var(--mantine-color-gray-2);
|
||||
}
|
||||
|
||||
:global([data-mantine-color-scheme='dark']) .jobProgressRow[data-status='failed'] {
|
||||
background: rgba(138, 34, 43, 0.92);
|
||||
}
|
||||
|
||||
.jobProgressHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.jobProgressLabel {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.jobProgressValue {
|
||||
font-variant-numeric: tabular-nums;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.jobProgressTrack {
|
||||
position: relative;
|
||||
margin-top: 8px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(241, 246, 255, 0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.jobProgressRow[data-status='failed'] .jobProgressTrack {
|
||||
background: rgba(255, 255, 255, 0.23);
|
||||
}
|
||||
|
||||
.jobProgressFill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #5aa5ff 0%, #206de5 100%);
|
||||
transition: width 160ms ease;
|
||||
}
|
||||
|
||||
.jobProgressRow[data-status='queued'] .jobProgressFill {
|
||||
background: linear-gradient(90deg, #cfd8dc 0%, #a7b6bf 100%);
|
||||
}
|
||||
|
||||
.jobProgressRow[data-status='failed'] .jobProgressFill {
|
||||
background: linear-gradient(90deg, #ff8a80 0%, #e53935 100%);
|
||||
}
|
||||
|
||||
.jobProgressError {
|
||||
margin-top: 8px;
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.3;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ const FileEditorThumbnail = ({
|
||||
selectedFiles,
|
||||
onToggleFile,
|
||||
onCloseFile,
|
||||
onViewFile,
|
||||
_onSetStatus,
|
||||
onReorderFiles,
|
||||
onDownloadFile,
|
||||
@@ -56,6 +55,9 @@ const FileEditorThumbnail = ({
|
||||
const { pinFile, unpinFile, isFilePinned, activeFiles, actions: fileActions } = useFileContext();
|
||||
const { state } = useFileState();
|
||||
const hasError = state.ui.errorFileIds.includes(file.id);
|
||||
const activeJobs = useMemo(() => file.activeJobs ?? [], [file.activeJobs]);
|
||||
const visibleJobs = useMemo(() => activeJobs.filter(job => job.status !== 'completed'), [activeJobs]);
|
||||
const hasActiveJobs = visibleJobs.length > 0;
|
||||
|
||||
// ---- Drag state ----
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
@@ -206,11 +208,6 @@ const FileEditorThumbnail = ({
|
||||
onToggleFile(file.id);
|
||||
};
|
||||
|
||||
const handleCardDoubleClick = () => {
|
||||
if (!isSupported) return;
|
||||
onViewFile(file.id);
|
||||
};
|
||||
|
||||
// ---- Style helpers ----
|
||||
const getHeaderClassName = () => {
|
||||
if (hasError) return styles.headerError;
|
||||
@@ -219,6 +216,9 @@ const FileEditorThumbnail = ({
|
||||
};
|
||||
|
||||
|
||||
const progressContainerBottom = file.toolHistory ? '56px' : '12px';
|
||||
const toolChainBottom = hasActiveJobs ? '8px' : '4px';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={fileElementRef}
|
||||
@@ -232,7 +232,6 @@ const FileEditorThumbnail = ({
|
||||
role="listitem"
|
||||
aria-selected={isSelected}
|
||||
onClick={handleCardClick}
|
||||
onDoubleClick={handleCardDoubleClick}
|
||||
>
|
||||
{/* Header bar */}
|
||||
<div
|
||||
@@ -443,6 +442,54 @@ const FileEditorThumbnail = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasActiveJobs && (
|
||||
<div
|
||||
className={styles.jobProgressContainer}
|
||||
style={{ bottom: progressContainerBottom }}
|
||||
>
|
||||
{visibleJobs.map(job => {
|
||||
const queueSuffix = typeof job.queuePosition === 'number' ? ` (#${job.queuePosition + 1})` : '';
|
||||
const label = job.message
|
||||
|| (job.status === 'failed'
|
||||
? t('async.jobFailed', 'Job failed')
|
||||
: job.status === 'queued'
|
||||
? `${t('async.jobQueued', 'Waiting in queue')}${queueSuffix}`
|
||||
: t('async.jobProcessing', 'Processing…'));
|
||||
const rawPercent = Number.isFinite(job.progressPercent) ? Math.round(job.progressPercent) : 0;
|
||||
const percent = Math.max(0, Math.min(rawPercent, 100));
|
||||
const displayValue = job.status === 'failed'
|
||||
? t('async.jobFailedShort', 'Failed')
|
||||
: job.status === 'queued'
|
||||
? t('async.jobQueuedShort', 'Queued')
|
||||
: `${percent}%`;
|
||||
const fillBase = job.status === 'failed'
|
||||
? 100
|
||||
: Math.max(job.status === 'queued' ? 12 : 6, percent);
|
||||
const fillWidth = Math.max(0, Math.min(fillBase, 100));
|
||||
|
||||
return (
|
||||
<div
|
||||
key={job.jobId}
|
||||
className={styles.jobProgressRow}
|
||||
data-status={job.status}
|
||||
title={job.error || label}
|
||||
>
|
||||
<div className={styles.jobProgressHeader}>
|
||||
<span className={styles.jobProgressLabel}>{label}</span>
|
||||
<span className={styles.jobProgressValue}>{displayValue}</span>
|
||||
</div>
|
||||
<div className={styles.jobProgressTrack}>
|
||||
<div className={styles.jobProgressFill} style={{ width: `${fillWidth}%` }} />
|
||||
</div>
|
||||
{job.status === 'failed' && job.error && (
|
||||
<div className={styles.jobProgressError}>{job.error}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag handle (span wrapper so we can attach a ref reliably) */}
|
||||
<span ref={handleRef} className={styles.dragHandle} aria-hidden>
|
||||
<DragIndicatorIcon fontSize="small" />
|
||||
@@ -452,7 +499,7 @@ const FileEditorThumbnail = ({
|
||||
{file.toolHistory && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
bottom: '4px',
|
||||
bottom: toolChainBottom,
|
||||
left: '4px',
|
||||
right: '4px',
|
||||
padding: '4px 6px',
|
||||
|
||||
@@ -42,7 +42,6 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
<Box style={{ width: '7.5rem', height: '9.375rem', flexShrink: 0, position: 'relative', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{currentFile && thumbnail ? (
|
||||
<img
|
||||
className='ph-no-capture'
|
||||
src={thumbnail}
|
||||
alt={currentFile.name}
|
||||
style={{
|
||||
@@ -67,7 +66,7 @@ const CompactFileDetails: React.FC<CompactFileDetailsProps> = ({
|
||||
|
||||
{/* File info */}
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text className='ph-no-capture' size="sm" fw={500} truncate>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{currentFile ? currentFile.name : 'No file selected'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Box } from '@mantine/core';
|
||||
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
|
||||
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
|
||||
import { useFileHandler } from '../../hooks/useFileHandler';
|
||||
import { useFileState } from '../../contexts/FileContext';
|
||||
import { useNavigationState, useNavigationActions } from '../../contexts/NavigationContext';
|
||||
import { isBaseWorkbench } from '../../types/workbench';
|
||||
import { useViewer } from '../../contexts/ViewerContext';
|
||||
import './Workbench.css';
|
||||
|
||||
import TopControls from '../shared/TopControls';
|
||||
@@ -23,19 +20,18 @@ export default function Workbench() {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
|
||||
// Use context-based hooks to eliminate all prop drilling
|
||||
const { selectors } = useFileState();
|
||||
const { state } = useFileState();
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const { actions: navActions } = useNavigationActions();
|
||||
const setCurrentView = navActions.setWorkbench;
|
||||
const activeFiles = selectors.getFiles();
|
||||
const activeFiles = state.files.ids;
|
||||
const {
|
||||
previewFile,
|
||||
pageEditorFunctions,
|
||||
sidebarsVisible,
|
||||
setPreviewFile,
|
||||
setPageEditorFunctions,
|
||||
setSidebarsVisible,
|
||||
customWorkbenchViews,
|
||||
setSidebarsVisible
|
||||
} = useToolWorkflow();
|
||||
|
||||
const { handleToolSelect } = useToolWorkflow();
|
||||
@@ -48,9 +44,6 @@ export default function Workbench() {
|
||||
const selectedTool = selectedToolId ? toolRegistry[selectedToolId] : null;
|
||||
const { addFiles } = useFileHandler();
|
||||
|
||||
// Get active file index from ViewerContext
|
||||
const { activeFileIndex, setActiveFileIndex } = useViewer();
|
||||
|
||||
const handlePreviewClose = () => {
|
||||
setPreviewFile(null);
|
||||
const previousMode = sessionStorage.getItem('previousMode');
|
||||
@@ -102,8 +95,6 @@ export default function Workbench() {
|
||||
setSidebarsVisible={setSidebarsVisible}
|
||||
previewFile={previewFile}
|
||||
onClose={handlePreviewClose}
|
||||
activeFileIndex={activeFileIndex}
|
||||
setActiveFileIndex={setActiveFileIndex}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -139,14 +130,9 @@ export default function Workbench() {
|
||||
);
|
||||
|
||||
default:
|
||||
if (!isBaseWorkbench(currentView)) {
|
||||
const customView = customWorkbenchViews.find((view) => view.workbenchId === currentView && view.data != null);
|
||||
if (customView) {
|
||||
const CustomComponent = customView.component;
|
||||
return <CustomComponent data={customView.data} />;
|
||||
}
|
||||
}
|
||||
return <LandingPage />;
|
||||
return (
|
||||
<LandingPage/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -164,13 +150,6 @@ export default function Workbench() {
|
||||
<TopControls
|
||||
currentView={currentView}
|
||||
setCurrentView={setCurrentView}
|
||||
customViews={customWorkbenchViews}
|
||||
activeFiles={activeFiles.map(f => {
|
||||
const stub = selectors.getStirlingFileStub(f.fileId);
|
||||
return { fileId: f.fileId, name: f.name, versionNumber: stub?.versionNumber };
|
||||
})}
|
||||
currentFileIndex={activeFileIndex}
|
||||
onFileSelect={setActiveFileIndex}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -182,7 +161,7 @@ export default function Workbench() {
|
||||
className="flex-1 min-h-0 relative z-10 workbench-scrollable "
|
||||
style={{
|
||||
transition: 'opacity 0.15s ease-in-out',
|
||||
paddingTop: currentView === 'viewer' ? '0' : (activeFiles.length > 0 ? '3.5rem' : '0'),
|
||||
paddingTop: activeFiles.length > 0 ? '3.5rem' : '0',
|
||||
}}
|
||||
>
|
||||
{renderMainContent()}
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Menu, Loader, Group, Text } from '@mantine/core';
|
||||
import VisibilityIcon from '@mui/icons-material/Visibility';
|
||||
import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown';
|
||||
import FitText from './FitText';
|
||||
|
||||
interface FileDropdownMenuProps {
|
||||
displayName: string;
|
||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>;
|
||||
currentFileIndex: number;
|
||||
onFileSelect?: (index: number) => void;
|
||||
switchingTo?: string | null;
|
||||
viewOptionStyle: React.CSSProperties;
|
||||
pillRef?: React.RefObject<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const FileDropdownMenu: React.FC<FileDropdownMenuProps> = ({
|
||||
displayName,
|
||||
activeFiles,
|
||||
currentFileIndex,
|
||||
onFileSelect,
|
||||
switchingTo,
|
||||
viewOptionStyle,
|
||||
}) => {
|
||||
return (
|
||||
<Menu trigger="click" position="bottom" width="30rem">
|
||||
<Menu.Target>
|
||||
<div style={{...viewOptionStyle, cursor: 'pointer'}}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
)}
|
||||
<FitText text={displayName} fontSize={14} minimumFontScale={0.6} className="ph-no-capture" />
|
||||
<KeyboardArrowDownIcon fontSize="small" />
|
||||
</div>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown style={{
|
||||
backgroundColor: 'var(--right-rail-bg)',
|
||||
border: '1px solid var(--border-subtle)',
|
||||
borderRadius: '8px',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||
maxHeight: '50vh',
|
||||
overflowY: 'auto'
|
||||
}}>
|
||||
{activeFiles.map((file, index) => {
|
||||
const itemName = file?.name || 'Untitled';
|
||||
const isActive = index === currentFileIndex;
|
||||
return (
|
||||
<Menu.Item
|
||||
key={file.fileId}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFileSelect?.(index);
|
||||
}}
|
||||
className="viewer-file-tab"
|
||||
{...(isActive && { 'data-active': true })}
|
||||
style={{
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Group gap="xs" style={{ width: '100%', justifyContent: 'space-between' }}>
|
||||
<div style={{ flex: 1, textAlign: 'left', minWidth: 0 }}>
|
||||
<FitText text={itemName} fontSize={14} minimumFontScale={0.7} className="ph-no-capture" />
|
||||
</div>
|
||||
{file.versionNumber && file.versionNumber > 1 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
v{file.versionNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Menu.Item>
|
||||
);
|
||||
})}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
};
|
||||
@@ -43,7 +43,6 @@ export default function RightRail() {
|
||||
|
||||
// Navigation view
|
||||
const { workbench: currentView } = useNavigationState();
|
||||
const isCustomWorkbench = typeof currentView === 'string' && currentView.startsWith('custom:');
|
||||
|
||||
// File state and selection
|
||||
const { state, selectors } = useFileState();
|
||||
@@ -184,7 +183,7 @@ export default function RightRail() {
|
||||
return (
|
||||
<div ref={sidebarRefs.rightRailRef} className={`right-rail`} data-sidebar="right-rail">
|
||||
<div className="right-rail-inner">
|
||||
{topButtons.length > 0 && !isCustomWorkbench && (
|
||||
{topButtons.length > 0 && (
|
||||
<>
|
||||
<div className="right-rail-section">
|
||||
{topButtons.map(btn => (
|
||||
@@ -206,7 +205,6 @@ export default function RightRail() {
|
||||
)}
|
||||
|
||||
{/* Group: PDF Viewer Controls - visible only in viewer mode */}
|
||||
{!isCustomWorkbench && (
|
||||
<div
|
||||
className={`right-rail-slot ${currentView === 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||
aria-hidden={currentView !== 'viewer'}
|
||||
@@ -310,10 +308,8 @@ export default function RightRail() {
|
||||
</div>
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Group: Selection controls + Close, animate as one unit when entering/leaving viewer */}
|
||||
{!isCustomWorkbench && (
|
||||
<div
|
||||
className={`right-rail-slot ${currentView !== 'viewer' ? 'visible right-rail-enter' : 'right-rail-exit'}`}
|
||||
aria-hidden={currentView === 'viewer'}
|
||||
@@ -451,7 +447,6 @@ export default function RightRail() {
|
||||
|
||||
<Divider className="right-rail-divider" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme toggle and Language dropdown */}
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: '1rem' }}>
|
||||
|
||||
@@ -5,13 +5,10 @@ import rainbowStyles from '../../styles/rainbow.module.css';
|
||||
import VisibilityIcon from "@mui/icons-material/Visibility";
|
||||
import EditNoteIcon from "@mui/icons-material/EditNote";
|
||||
import FolderIcon from "@mui/icons-material/Folder";
|
||||
import PictureAsPdfIcon from "@mui/icons-material/PictureAsPdf";
|
||||
import { WorkbenchType, isValidWorkbench } from '../../types/workbench';
|
||||
import type { CustomWorkbenchViewInstance } from '../../contexts/ToolWorkflowContext';
|
||||
import { FileDropdownMenu } from './FileDropdownMenu';
|
||||
|
||||
|
||||
const viewOptionStyle: React.CSSProperties = {
|
||||
const viewOptionStyle = {
|
||||
display: 'inline-flex',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
@@ -22,39 +19,16 @@ const viewOptionStyle: React.CSSProperties = {
|
||||
|
||||
|
||||
// Build view options showing text always
|
||||
const createViewOptions = (
|
||||
currentView: WorkbenchType,
|
||||
switchingTo: WorkbenchType | null,
|
||||
activeFiles: Array<{ fileId: string; name: string; versionNumber?: number }>,
|
||||
currentFileIndex: number,
|
||||
onFileSelect?: (index: number) => void,
|
||||
customViews?: CustomWorkbenchViewInstance[]
|
||||
) => {
|
||||
const currentFile = activeFiles[currentFileIndex];
|
||||
const isInViewer = currentView === 'viewer';
|
||||
const fileName = currentFile?.name || '';
|
||||
const displayName = isInViewer && fileName ? fileName : 'Viewer';
|
||||
const hasMultipleFiles = activeFiles.length > 1;
|
||||
const showDropdown = isInViewer && hasMultipleFiles;
|
||||
|
||||
const createViewOptions = (currentView: WorkbenchType, switchingTo: WorkbenchType | null) => {
|
||||
const viewerOption = {
|
||||
label: showDropdown ? (
|
||||
<FileDropdownMenu
|
||||
displayName={displayName}
|
||||
activeFiles={activeFiles}
|
||||
currentFileIndex={currentFileIndex}
|
||||
onFileSelect={onFileSelect}
|
||||
switchingTo={switchingTo}
|
||||
viewOptionStyle={viewOptionStyle}
|
||||
/>
|
||||
) : (
|
||||
<div style={viewOptionStyle}>
|
||||
label: (
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{switchingTo === "viewer" ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
<VisibilityIcon fontSize="small" />
|
||||
)}
|
||||
<span className="ph-no-capture">{displayName}</span>
|
||||
<span>Viewer</span>
|
||||
</div>
|
||||
),
|
||||
value: "viewer",
|
||||
@@ -62,7 +36,7 @@ const createViewOptions = (
|
||||
|
||||
const pageEditorOption = {
|
||||
label: (
|
||||
<div style={viewOptionStyle}>
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{currentView === "pageEditor" ? (
|
||||
<>
|
||||
{switchingTo === "pageEditor" ? <Loader size="xs" /> : <EditNoteIcon fontSize="small" />}
|
||||
@@ -81,7 +55,7 @@ const createViewOptions = (
|
||||
|
||||
const fileEditorOption = {
|
||||
label: (
|
||||
<div style={viewOptionStyle}>
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{currentView === "fileEditor" ? (
|
||||
<>
|
||||
{switchingTo === "fileEditor" ? <Loader size="xs" /> : <FolderIcon fontSize="small" />}
|
||||
@@ -98,48 +72,23 @@ const createViewOptions = (
|
||||
value: "fileEditor",
|
||||
};
|
||||
|
||||
const baseOptions = [
|
||||
// Build options array conditionally
|
||||
return [
|
||||
viewerOption,
|
||||
pageEditorOption,
|
||||
fileEditorOption,
|
||||
];
|
||||
|
||||
const customOptions = (customViews ?? [])
|
||||
.filter((view) => view.data != null)
|
||||
.map((view) => ({
|
||||
label: (
|
||||
<div style={viewOptionStyle as React.CSSProperties}>
|
||||
{switchingTo === view.workbenchId ? (
|
||||
<Loader size="xs" />
|
||||
) : (
|
||||
view.icon || <PictureAsPdfIcon fontSize="small" />
|
||||
)}
|
||||
<span>{view.label}</span>
|
||||
</div>
|
||||
),
|
||||
value: view.workbenchId,
|
||||
}));
|
||||
|
||||
return [...baseOptions, ...customOptions];
|
||||
};
|
||||
|
||||
interface TopControlsProps {
|
||||
currentView: WorkbenchType;
|
||||
setCurrentView: (view: WorkbenchType) => void;
|
||||
customViews?: CustomWorkbenchViewInstance[];
|
||||
activeFiles?: Array<{ fileId: string; name: string; versionNumber?: number }>;
|
||||
currentFileIndex?: number;
|
||||
onFileSelect?: (index: number) => void;
|
||||
}
|
||||
|
||||
const TopControls = ({
|
||||
currentView,
|
||||
setCurrentView,
|
||||
customViews = [],
|
||||
activeFiles = [],
|
||||
currentFileIndex = 0,
|
||||
onFileSelect,
|
||||
}: TopControlsProps) => {
|
||||
}: TopControlsProps) => {
|
||||
const { isRainbowMode } = useRainbowThemeContext();
|
||||
const [switchingTo, setSwitchingTo] = useState<WorkbenchType | null>(null);
|
||||
|
||||
@@ -169,7 +118,7 @@ const TopControls = ({
|
||||
<div className="absolute left-0 w-full top-0 z-[100] pointer-events-none">
|
||||
<div className="flex justify-center mt-[0.5rem]">
|
||||
<SegmentedControl
|
||||
data={createViewOptions(currentView, switchingTo, activeFiles, currentFileIndex, onFileSelect)}
|
||||
data={createViewOptions(currentView, switchingTo)}
|
||||
value={currentView}
|
||||
onChange={handleViewChange}
|
||||
color="blue"
|
||||
|
||||
@@ -17,7 +17,6 @@ const FavoriteStar: React.FC<FavoriteStarProps> = ({ isFavorite, onToggle, class
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
component="span"
|
||||
variant="subtle"
|
||||
radius="xl"
|
||||
size={size}
|
||||
@@ -25,12 +24,6 @@ const FavoriteStar: React.FC<FavoriteStarProps> = ({ isFavorite, onToggle, class
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
onMouseDown={(e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
className={className}
|
||||
aria-label={isFavorite ? t('toolPanel.fullscreen.unfavorite', 'Remove from favourites') : t('toolPanel.fullscreen.favorite', 'Add to favourites')}
|
||||
>
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Badge, Group, Stack, Text, Divider } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SignatureValidationReportData } from '../../../types/validateSignature';
|
||||
import './reportView/styles.css';
|
||||
import ThumbnailPreview from './reportView/ThumbnailPreview';
|
||||
import FileSummaryHeader from './reportView/FileSummaryHeader';
|
||||
import SignatureSection from './reportView/SignatureSection';
|
||||
|
||||
interface ValidateSignatureReportViewProps {
|
||||
data: SignatureValidationReportData;
|
||||
}
|
||||
|
||||
const NoSignatureSection = ({ message, label }: { message: string; label: string }) => (
|
||||
<Stack align="center" justify="center" gap="xs" style={{ minHeight: 360, width: '100%' }}>
|
||||
<Badge color="gray" variant="light" size="lg" style={{ textTransform: 'uppercase' }}>
|
||||
{label}
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed" style={{ textAlign: 'center' }}>
|
||||
{message}
|
||||
</Text>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
const ValidateSignatureReportView: React.FC<ValidateSignatureReportViewProps> = ({ data }) => {
|
||||
const { t } = useTranslation();
|
||||
const noSignaturesLabel = t('validateSignature.noSignaturesShort', 'No signatures');
|
||||
|
||||
const pages = useMemo(() => {
|
||||
const result: Array<{
|
||||
entry: SignatureValidationReportData['entries'][number];
|
||||
signatureIndex: number | null;
|
||||
includeSummary: boolean;
|
||||
}> = [];
|
||||
|
||||
for (const entry of data.entries) {
|
||||
if (entry.signatures.length === 0 || entry.error) {
|
||||
result.push({ entry, signatureIndex: null, includeSummary: true });
|
||||
continue;
|
||||
}
|
||||
|
||||
// First page includes summary and the first signature
|
||||
result.push({ entry, signatureIndex: 0, includeSummary: true });
|
||||
|
||||
// Subsequent signatures each get their own page
|
||||
for (let i = 1; i < entry.signatures.length; i += 1) {
|
||||
result.push({ entry, signatureIndex: i, includeSummary: false });
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [data.entries]);
|
||||
|
||||
return (
|
||||
<div className="report-container">
|
||||
<Stack gap="xl" align="center">
|
||||
<Stack gap="xs" align="center">
|
||||
<Badge size="lg" color="blue" variant="light">
|
||||
{t('validateSignature.report.title', 'Signature Validation Report')}
|
||||
</Badge>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('validateSignature.report.generatedAt', 'Generated')}{' '}
|
||||
{new Date(data.generatedAt).toLocaleString()}
|
||||
</Text>
|
||||
</Stack>
|
||||
|
||||
{pages.map((pageDef, index) => (
|
||||
<div className="simulated-page" key={`${pageDef.entry.fileId}-${index}`}>
|
||||
<Stack gap="lg" style={{ flex: 1 }}>
|
||||
{pageDef.includeSummary && (
|
||||
<>
|
||||
<Group align="flex-start" gap="lg">
|
||||
<ThumbnailPreview
|
||||
thumbnailUrl={pageDef.entry.thumbnailUrl}
|
||||
fileName={pageDef.entry.fileName}
|
||||
/>
|
||||
<Stack gap="sm" style={{ flex: 1 }}>
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={700} size="xl" style={{ lineHeight: 1.1 }}>
|
||||
{pageDef.entry.fileName}
|
||||
</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('validateSignature.report.entryLabel', 'Signature Summary')}
|
||||
</Text>
|
||||
</div>
|
||||
<Badge color="gray" variant="light">
|
||||
{t('validateSignature.report.page', 'Page')} {index + 1}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<FileSummaryHeader
|
||||
fileSize={pageDef.entry.fileSize}
|
||||
createdAt={pageDef.entry.createdAtLabel ?? null}
|
||||
totalSignatures={pageDef.entry.signatures.length}
|
||||
lastSignatureDate={pageDef.entry.signatures[0]?.signatureDate}
|
||||
/>
|
||||
</Stack>
|
||||
</Group>
|
||||
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
{pageDef.entry.error ? (
|
||||
<NoSignatureSection
|
||||
message={pageDef.entry.error}
|
||||
label={t('validateSignature.status.invalid', 'Invalid')}
|
||||
/>
|
||||
) : pageDef.entry.signatures.length === 0 ? (
|
||||
<NoSignatureSection
|
||||
message={t(
|
||||
'validateSignature.noSignatures',
|
||||
'No digital signatures found in this document'
|
||||
)}
|
||||
label={noSignaturesLabel}
|
||||
/>
|
||||
) : (
|
||||
<Stack gap="xl">
|
||||
{pageDef.signatureIndex === null ? null : (
|
||||
<SignatureSection
|
||||
signature={pageDef.entry.signatures[pageDef.signatureIndex]}
|
||||
index={pageDef.signatureIndex}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Group justify="space-between" align="center" mt="auto" pt="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('validateSignature.report.footer', 'Validated via Stirling PDF')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('validateSignature.report.page', 'Page')} {index + 1} / {pages.length}
|
||||
</Text>
|
||||
</Group>
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidateSignatureReportView;
|
||||
@@ -1,230 +0,0 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { Alert, Badge, Button, Divider, Group, Loader, Stack, Text, SegmentedControl } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SignatureValidationReportEntry } from '../../../types/validateSignature';
|
||||
import type { ValidateSignatureOperationHook } from '../../../hooks/tools/validateSignature/useValidateSignatureOperation';
|
||||
import './reportView/styles.css';
|
||||
|
||||
interface ValidateSignatureResultsProps {
|
||||
operation: ValidateSignatureOperationHook;
|
||||
results: SignatureValidationReportEntry[];
|
||||
isLoading: boolean;
|
||||
errorMessage: string | null;
|
||||
reportAvailable?: boolean;
|
||||
}
|
||||
|
||||
const useFileSummary = (results: SignatureValidationReportEntry[]) => {
|
||||
return useMemo(() => {
|
||||
if (results.length === 0) {
|
||||
return { fileCount: 0, signatureCount: 0, fullyValidCount: 0 };
|
||||
}
|
||||
|
||||
let signatureCount = 0;
|
||||
let fullyValidCount = 0;
|
||||
|
||||
results.forEach((result) => {
|
||||
signatureCount += result.signatures.length;
|
||||
result.signatures.forEach((signature) => {
|
||||
const isFullyValid =
|
||||
signature.valid &&
|
||||
signature.chainValid &&
|
||||
signature.trustValid &&
|
||||
signature.notExpired &&
|
||||
signature.notRevoked;
|
||||
if (isFullyValid) {
|
||||
fullyValidCount += 1;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
fileCount: results.length,
|
||||
signatureCount,
|
||||
fullyValidCount,
|
||||
};
|
||||
}, [results]);
|
||||
};
|
||||
|
||||
const findFileByExtension = (files: File[], extension: string) => {
|
||||
return files.find((file) => file.name.toLowerCase().endsWith(extension));
|
||||
};
|
||||
|
||||
const ValidateSignatureResults = ({
|
||||
operation,
|
||||
results,
|
||||
isLoading,
|
||||
errorMessage,
|
||||
}: ValidateSignatureResultsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const summary = useFileSummary(results);
|
||||
|
||||
const pdfFile = useMemo(() => findFileByExtension(operation.files, '.pdf'), [operation.files]);
|
||||
const csvFile = useMemo(() => findFileByExtension(operation.files, '.csv'), [operation.files]);
|
||||
const jsonFile = useMemo(() => findFileByExtension(operation.files, '.json'), [operation.files]);
|
||||
|
||||
const [selectedType, setSelectedType] = useState<'pdf' | 'csv' | 'json'>('pdf');
|
||||
|
||||
const selectedFile = useMemo(() => {
|
||||
if (selectedType === 'pdf') return pdfFile ?? null;
|
||||
if (selectedType === 'csv') return csvFile ?? null;
|
||||
return jsonFile ?? null;
|
||||
}, [selectedType, pdfFile, csvFile, jsonFile]);
|
||||
|
||||
const selectedDownloadLabel = useMemo(() => {
|
||||
if (selectedType === 'pdf') return t('validateSignature.downloadPdf', 'Download PDF Report');
|
||||
if (selectedType === 'csv') return t('validateSignature.downloadCsv', 'Download CSV');
|
||||
return t('validateSignature.downloadJson', 'Download JSON');
|
||||
}, [selectedType, t]);
|
||||
|
||||
const downloadTypeOptions = [
|
||||
{ label: t('validateSignature.downloadType.pdf', 'PDF'), value: 'pdf' },
|
||||
{ label: t('validateSignature.downloadType.csv', 'CSV'), value: 'csv' },
|
||||
{ label: t('validateSignature.downloadType.json', 'JSON'), value: 'json' },
|
||||
];
|
||||
|
||||
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);
|
||||
}, []);
|
||||
|
||||
// Show the big loader only while we're still waiting for the first results.
|
||||
if (isLoading && results.length === 0) {
|
||||
return (
|
||||
<Group justify="center" gap="sm" py="md">
|
||||
<Loader size="sm" />
|
||||
<Text>{t('validateSignature.processing', 'Validating signatures...')}</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isLoading && results.length === 0) {
|
||||
return (
|
||||
<Alert color="gray" variant="light" title={t('validateSignature.results', 'Validation Results')}>
|
||||
<Text size="sm">{t('validateSignature.noResults', 'Run the validation to generate a report.')}</Text>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{/* While results are visible but background work continues (e.g. generating files),
|
||||
show a light inline indicator without blocking downloads UI. */}
|
||||
{isLoading && results.length > 0 && (
|
||||
<Group justify="center" gap="xs">
|
||||
<Loader size="xs" />
|
||||
<Text size="sm">{t('validateSignature.finalizing', 'Preparing downloads...')}</Text>
|
||||
</Group>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<Alert color="yellow" variant="light">
|
||||
<Text size="sm">{errorMessage}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Group gap="sm">
|
||||
<Badge color="blue" variant="light">
|
||||
{t('validateSignature.report.filesEvaluated', '{{count}} files evaluated', {
|
||||
count: summary.fileCount,
|
||||
})}
|
||||
</Badge>
|
||||
<Badge color="teal" variant="light">
|
||||
{t('validateSignature.report.signaturesFound', '{{count}} signatures detected', {
|
||||
count: summary.signatureCount,
|
||||
})}
|
||||
</Badge>
|
||||
{summary.signatureCount > 0 && (
|
||||
<Badge color="green" variant="light">
|
||||
{t('validateSignature.report.signaturesValid', '{{count}} fully valid', {
|
||||
count: summary.fullyValidCount,
|
||||
})}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Stack gap="sm">
|
||||
{results.map((result) => {
|
||||
const hasError = Boolean(result.error);
|
||||
const hasSignatures = result.signatures.length > 0;
|
||||
const allValid = hasSignatures && result.signatures.every((signature) => signature.valid);
|
||||
const badgeLabel = hasError
|
||||
? t('validateSignature.status.invalid', 'Invalid')
|
||||
: hasSignatures
|
||||
? allValid
|
||||
? t('validateSignature.status.valid', 'Valid')
|
||||
: t('validateSignature.status.needsAttention', 'Needs Attention')
|
||||
: t('validateSignature.noSignaturesShort', 'No signatures');
|
||||
const badgeClass = hasError
|
||||
? 'status-badge status-badge--invalid'
|
||||
: hasSignatures
|
||||
? allValid
|
||||
? 'status-badge status-badge--valid'
|
||||
: 'status-badge status-badge--warning'
|
||||
: 'status-badge status-badge--neutral';
|
||||
|
||||
return (
|
||||
<Stack key={result.fileId} gap={4} p="xs" style={{ borderLeft: '2px solid var(--mantine-color-gray-4)' }}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Text fw={600} size="sm">
|
||||
{result.fileName}
|
||||
</Text>
|
||||
<Badge className={badgeClass} variant="light">
|
||||
{badgeLabel}
|
||||
</Badge>
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('validateSignature.report.signatureCountLabel', '{{count}} signatures', {
|
||||
count: result.signatures.length,
|
||||
})}
|
||||
</Text>
|
||||
{result.error && (
|
||||
<Text size="xs" c="red">
|
||||
{result.error}
|
||||
</Text>
|
||||
)}
|
||||
{!result.error && result.signatures.length === 0 && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('validateSignature.noSignatures', 'No digital signatures found in this document')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
|
||||
<Divider />
|
||||
|
||||
<Stack gap="xs">
|
||||
<Text size="sm" fw={600}>
|
||||
{t('validateSignature.report.downloads', 'Downloads')}
|
||||
</Text>
|
||||
<SegmentedControl
|
||||
value={selectedType}
|
||||
onChange={(v) => setSelectedType(v as 'pdf' | 'csv' | 'json')}
|
||||
data={downloadTypeOptions}
|
||||
/>
|
||||
<div>
|
||||
<Button
|
||||
color="blue"
|
||||
onClick={() => selectedFile && handleDownload(selectedFile)}
|
||||
disabled={!selectedFile}
|
||||
>
|
||||
{selectedDownloadLabel}
|
||||
</Button>
|
||||
</div>
|
||||
{selectedType === 'pdf' && !pdfFile && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('validateSignature.report.noPdf', 'PDF report will be available after a successful validation.')}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidateSignatureResults;
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Card, Group, Stack, Text, Button } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import FileUploadButton from '../../shared/FileUploadButton';
|
||||
import { ValidateSignatureParameters } from '../../../hooks/tools/validateSignature/useValidateSignatureParameters';
|
||||
|
||||
interface ValidateSignatureSettingsProps {
|
||||
parameters: ValidateSignatureParameters;
|
||||
onParameterChange: <K extends keyof ValidateSignatureParameters>(parameter: K, value: ValidateSignatureParameters[K]) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const ValidateSignatureSettings = ({
|
||||
parameters,
|
||||
onParameterChange,
|
||||
disabled = false,
|
||||
}: ValidateSignatureSettingsProps) => {
|
||||
const { t } = useTranslation();
|
||||
const certFile = parameters.certFile;
|
||||
|
||||
const handleCertFileChange = (file: File | null) => {
|
||||
onParameterChange('certFile', file);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Stack gap="sm">
|
||||
<div>
|
||||
<Text fw={600}>{t('validateSignature.selectCustomCert', 'Custom Certificate File X.509 (Optional)')}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'validateSignature.settings.certHint',
|
||||
'Upload a trusted X.509 certificate to validate against a custom trust source.'
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Group align="center" gap="sm">
|
||||
<FileUploadButton
|
||||
file={certFile ?? undefined}
|
||||
onChange={handleCertFileChange}
|
||||
accept=".cer,.crt,.pem,.der"
|
||||
disabled={disabled}
|
||||
variant="filled"
|
||||
/>
|
||||
{certFile && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={() => handleCertFileChange(null)}
|
||||
disabled={disabled}
|
||||
>
|
||||
{t('sign.clear', 'Clear')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
{certFile && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('size', 'Size')}: {Math.round(certFile.size / 1024)} KB
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ValidateSignatureSettings;
|
||||
@@ -1,23 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Text } from '@mantine/core';
|
||||
import './styles.css';
|
||||
|
||||
const FieldBlock = (label: string, value: React.ReactNode) => {
|
||||
const displayValue =
|
||||
value === null || value === undefined || value === '' ? '-' : value;
|
||||
|
||||
return (
|
||||
<div className="field-container" key={label}>
|
||||
<Text size="xs" fw={600} c="dimmed" tt="uppercase" style={{ letterSpacing: 0.6 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<div className="field-value">
|
||||
<Text size="sm" fw={500} style={{ lineHeight: 1.35, whiteSpace: 'pre-wrap' }}>
|
||||
{displayValue}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FieldBlock;
|
||||
@@ -1,47 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './styles.css';
|
||||
import FieldBlock from './FieldBlock';
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return '--';
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return new Date(parsed).toLocaleString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes?: number | null) => {
|
||||
if (bytes === undefined || bytes === null) return '--';
|
||||
if (bytes === 0) return '0 B';
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const size = bytes / Math.pow(1024, exponent);
|
||||
return `${size.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;
|
||||
};
|
||||
|
||||
const FileSummaryHeader = ({
|
||||
fileSize,
|
||||
createdAt,
|
||||
totalSignatures,
|
||||
lastSignatureDate,
|
||||
}: {
|
||||
fileSize?: number | null;
|
||||
createdAt?: string | null;
|
||||
totalSignatures: number;
|
||||
lastSignatureDate?: string | null;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const infoBlocks = [
|
||||
FieldBlock(t('files.size', 'File Size'), formatFileSize(fileSize ?? null)),
|
||||
FieldBlock(t('files.created', 'Created'), createdAt || '-'),
|
||||
FieldBlock(t('validateSignature.signatureDate', 'Signature Date'), formatDate(lastSignatureDate)),
|
||||
FieldBlock(t('validateSignature.totalSignatures', 'Total Signatures'), totalSignatures.toString()),
|
||||
];
|
||||
|
||||
return <div className="grid-container">{infoBlocks}</div>;
|
||||
};
|
||||
|
||||
export default FileSummaryHeader;
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Divider, Group, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { SignatureValidationSignature } from '../../../../types/validateSignature';
|
||||
import SignatureStatusBadge from './SignatureStatusBadge';
|
||||
import FieldBlock from './FieldBlock';
|
||||
import './styles.css';
|
||||
|
||||
const formatDate = (value?: string | null) => {
|
||||
if (!value) return '-';
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return new Date(parsed).toLocaleString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const SignatureSection = ({
|
||||
signature,
|
||||
index,
|
||||
}: {
|
||||
signature: SignatureValidationSignature;
|
||||
index: number;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const signatureFields = [
|
||||
FieldBlock(t('validateSignature.signer', 'Signer'), signature.signerName || '-'),
|
||||
FieldBlock(t('validateSignature.date', 'Date'), formatDate(signature.signatureDate)),
|
||||
FieldBlock(t('validateSignature.reason', 'Reason'), signature.reason || '-'),
|
||||
FieldBlock(t('validateSignature.location', 'Location'), signature.location || '-'),
|
||||
];
|
||||
|
||||
const certificateFields = [
|
||||
FieldBlock(t('validateSignature.cert.issuer', 'Issuer'), signature.issuerDN || '-'),
|
||||
FieldBlock(t('validateSignature.cert.subject', 'Subject'), signature.subjectDN || '-'),
|
||||
FieldBlock(t('validateSignature.cert.serialNumber', 'Serial Number'), signature.serialNumber || '-'),
|
||||
FieldBlock(t('validateSignature.cert.validFrom', 'Valid From'), formatDate(signature.validFrom)),
|
||||
FieldBlock(t('validateSignature.cert.validUntil', 'Valid Until'), formatDate(signature.validUntil)),
|
||||
FieldBlock(t('validateSignature.cert.algorithm', 'Algorithm'), signature.signatureAlgorithm || '-'),
|
||||
FieldBlock(
|
||||
t('validateSignature.cert.keySize', 'Key Size'),
|
||||
signature.keySize != null ? `${signature.keySize} ${t('validateSignature.cert.bits', 'bits')}` : '--'
|
||||
),
|
||||
FieldBlock(t('validateSignature.cert.version', 'Version'), signature.version || '-'),
|
||||
FieldBlock(
|
||||
t('validateSignature.cert.keyUsage', 'Key Usage'),
|
||||
signature.keyUsages.length > 0 ? signature.keyUsages.join(', ') : '--'
|
||||
),
|
||||
FieldBlock(t('validateSignature.cert.selfSigned', 'Self-Signed'), signature.selfSigned ? t('yes', 'Yes') : t('no', 'No')),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="md" key={signature.id}>
|
||||
<Group justify="space-between" align="center">
|
||||
<Group gap="sm">
|
||||
<Text fw={700} size="lg">
|
||||
{t('validateSignature.signature._value', 'Signature')} {index + 1}
|
||||
</Text>
|
||||
<SignatureStatusBadge signature={signature} />
|
||||
</Group>
|
||||
{signature.errorMessage && (
|
||||
<Text c="red" size="sm">{signature.errorMessage}</Text>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<div className="grid-container">{signatureFields}</div>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Text fw={600} size="sm" c="dimmed" tt="uppercase" style={{ letterSpacing: 0.8 }}>
|
||||
{t('validateSignature.cert.details', 'Certificate Details')}
|
||||
</Text>
|
||||
<div className="grid-container">{certificateFields}</div>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignatureSection;
|
||||
@@ -1,40 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Badge, Popover, Text } from '@mantine/core';
|
||||
import './styles.css';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { computeSignatureStatus } from '../../../../hooks/tools/validateSignature/utils/signatureStatus';
|
||||
import type { SignatureValidationSignature } from '../../../../types/validateSignature';
|
||||
|
||||
const SignatureStatusBadge = ({ signature }: { signature: SignatureValidationSignature }) => {
|
||||
const { t } = useTranslation();
|
||||
const status = computeSignatureStatus(signature, t);
|
||||
const classMap = {
|
||||
valid: 'status-badge status-badge--valid',
|
||||
warning: 'status-badge status-badge--warning',
|
||||
invalid: 'status-badge status-badge--invalid',
|
||||
neutral: 'status-badge status-badge--neutral',
|
||||
} as const;
|
||||
|
||||
return (
|
||||
<Popover withinPortal position="bottom" withArrow shadow="md" disabled={status.details.length === 0}>
|
||||
<Popover.Target>
|
||||
<Badge className={classMap[status.kind]} variant="light" style={{ cursor: status.details.length ? 'pointer' : 'default' }}>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</Popover.Target>
|
||||
{status.details.length > 0 && (
|
||||
<Popover.Dropdown>
|
||||
<Text size="sm" fw={600} mb={4}>{t('details', 'Details')}</Text>
|
||||
{status.details.map((d, i) => (
|
||||
<Text size="sm" key={i}>
|
||||
- {d}
|
||||
</Text>
|
||||
))}
|
||||
</Popover.Dropdown>
|
||||
)}
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignatureStatusBadge;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import './styles.css';
|
||||
|
||||
const ThumbnailPreview = ({
|
||||
thumbnailUrl,
|
||||
fileName,
|
||||
}: {
|
||||
thumbnailUrl?: string | null;
|
||||
fileName: string;
|
||||
}) => {
|
||||
if (thumbnailUrl) {
|
||||
return (
|
||||
<div className="thumbnail-container">
|
||||
<img
|
||||
src={thumbnailUrl}
|
||||
alt={`${fileName} thumbnail`}
|
||||
className="thumbnail-image"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="thumbnail-placeholder">
|
||||
<PictureAsPdfIcon fontSize="large" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ThumbnailPreview;
|
||||
@@ -1,105 +0,0 @@
|
||||
.grid-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.field-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.field-value {
|
||||
border: 1px solid rgb(var(--pdf-light-box-border));
|
||||
border-radius: 8px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
background-color: rgb(var(--pdf-light-box-bg));
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
/* Status badge colors sourced from light palette tokens */
|
||||
.status-badge {
|
||||
border-radius: 9999px !important;
|
||||
font-weight: 700 !important;
|
||||
letter-spacing: 0.02em !important;
|
||||
}
|
||||
.status-badge--valid {
|
||||
background-color: rgb(var(--pdf-light-status-valid-bg)) !important;
|
||||
color: rgb(var(--pdf-light-status-valid-text)) !important;
|
||||
}
|
||||
.status-badge--warning {
|
||||
background-color: rgb(var(--pdf-light-status-warning-bg)) !important;
|
||||
color: rgb(var(--pdf-light-status-warning-text)) !important;
|
||||
}
|
||||
.status-badge--invalid {
|
||||
background-color: rgb(var(--pdf-light-status-invalid-bg)) !important;
|
||||
color: rgb(var(--pdf-light-status-invalid-text)) !important;
|
||||
}
|
||||
.status-badge--neutral {
|
||||
background-color: rgb(var(--pdf-light-status-neutral-bg)) !important;
|
||||
color: rgb(var(--pdf-light-status-neutral-text)) !important;
|
||||
}
|
||||
|
||||
.simulated-page {
|
||||
width: min(820px, 100%);
|
||||
min-height: 1040px;
|
||||
background-color: rgb(var(--pdf-light-simulated-page-bg)) !important;
|
||||
box-shadow: 0 12px 32px rgba(var(--pdf-light-simulated-page-text), 0.12) !important;
|
||||
border-radius: 12px !important;
|
||||
padding: 48px 56px !important;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: rgb(var(--pdf-light-simulated-page-text)) !important;
|
||||
}
|
||||
|
||||
/* Container for the interactive report view */
|
||||
.report-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* Match Active Files/Page Editor background */
|
||||
background: var(--bg-background) !important;
|
||||
padding: 32px 24px 48px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Keep field blocks stable colors across themes */
|
||||
.field-value {
|
||||
border: 1px solid rgb(var(--pdf-light-box-border)) !important;
|
||||
background-color: rgb(var(--pdf-light-box-bg)) !important;
|
||||
}
|
||||
|
||||
.field-container {
|
||||
color: rgb(var(--pdf-light-simulated-page-text)) !important;
|
||||
}
|
||||
|
||||
/* Thumbnail preview styles */
|
||||
.thumbnail-container {
|
||||
width: 140px;
|
||||
height: 180px;
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 6px 18px rgba(var(--pdf-light-simulated-page-text), 0.15);
|
||||
flex-shrink: 0;
|
||||
background-color: rgb(var(--pdf-light-simulated-page-text));
|
||||
}
|
||||
|
||||
.thumbnail-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumbnail-placeholder {
|
||||
width: 140px;
|
||||
height: 180px;
|
||||
border-radius: 12px;
|
||||
border: 1px dashed rgba(var(--pdf-light-neutral), 0.6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: rgb(var(--pdf-light-text-muted));
|
||||
background: linear-gradient(145deg, var(--mantine-color-gray-1) 0%, var(--mantine-color-gray-0) 100%);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { Box, Center, Text, ActionIcon } from '@mantine/core';
|
||||
import { useMantineTheme, useMantineColorScheme } from '@mantine/core';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
import { useFileState, useFileActions } from "../../contexts/FileContext";
|
||||
@@ -19,8 +20,6 @@ export interface EmbedPdfViewerProps {
|
||||
setSidebarsVisible: (v: boolean) => void;
|
||||
onClose?: () => void;
|
||||
previewFile?: File | null;
|
||||
activeFileIndex?: number;
|
||||
setActiveFileIndex?: (index: number) => void;
|
||||
}
|
||||
|
||||
const EmbedPdfViewerContent = ({
|
||||
@@ -28,9 +27,9 @@ const EmbedPdfViewerContent = ({
|
||||
setSidebarsVisible: _setSidebarsVisible,
|
||||
onClose,
|
||||
previewFile,
|
||||
activeFileIndex: externalActiveFileIndex,
|
||||
setActiveFileIndex: externalSetActiveFileIndex,
|
||||
}: EmbedPdfViewerProps) => {
|
||||
const theme = useMantineTheme();
|
||||
const { colorScheme: _colorScheme } = useMantineColorScheme();
|
||||
const viewerRef = React.useRef<HTMLDivElement>(null);
|
||||
const [isViewerHovered, setIsViewerHovered] = React.useState(false);
|
||||
|
||||
@@ -53,11 +52,10 @@ const EmbedPdfViewerContent = ({
|
||||
const { signatureApiRef, historyApiRef } = useSignature();
|
||||
|
||||
// Get current file from FileContext
|
||||
const { selectors, state } = useFileState();
|
||||
const { selectors } = useFileState();
|
||||
const { actions } = useFileActions();
|
||||
const activeFiles = selectors.getFiles();
|
||||
const activeFileIds = activeFiles.map(f => f.fileId);
|
||||
const selectedFileIds = state.ui.selectedFileIds;
|
||||
|
||||
// Navigation guard for unsaved changes
|
||||
const { setHasUnsavedChanges, registerUnsavedChangesChecker, unregisterUnsavedChangesChecker } = useNavigationGuard();
|
||||
@@ -69,40 +67,15 @@ const EmbedPdfViewerContent = ({
|
||||
// Enable annotations when: in sign mode, OR annotation mode is active, OR we want to show existing annotations
|
||||
const shouldEnableAnnotations = isSignatureMode || isAnnotationMode || isAnnotationsVisible;
|
||||
|
||||
// Track which file tab is active
|
||||
const [internalActiveFileIndex, setInternalActiveFileIndex] = useState(0);
|
||||
const activeFileIndex = externalActiveFileIndex ?? internalActiveFileIndex;
|
||||
const setActiveFileIndex = externalSetActiveFileIndex ?? setInternalActiveFileIndex;
|
||||
const hasInitializedFromSelection = useRef(false);
|
||||
|
||||
// When viewer opens with a selected file, switch to that file
|
||||
useEffect(() => {
|
||||
if (!hasInitializedFromSelection.current && selectedFileIds.length > 0 && activeFiles.length > 0) {
|
||||
const selectedFileId = selectedFileIds[0];
|
||||
const index = activeFiles.findIndex(f => f.fileId === selectedFileId);
|
||||
if (index !== -1 && index !== activeFileIndex) {
|
||||
setActiveFileIndex(index);
|
||||
}
|
||||
hasInitializedFromSelection.current = true;
|
||||
}
|
||||
}, [selectedFileIds, activeFiles, activeFileIndex]);
|
||||
|
||||
// Reset active tab if it's out of bounds
|
||||
useEffect(() => {
|
||||
if (activeFileIndex >= activeFiles.length && activeFiles.length > 0) {
|
||||
setActiveFileIndex(0);
|
||||
}
|
||||
}, [activeFiles.length, activeFileIndex]);
|
||||
|
||||
// Determine which file to display
|
||||
const currentFile = React.useMemo(() => {
|
||||
if (previewFile) {
|
||||
return previewFile;
|
||||
} else if (activeFiles.length > 0) {
|
||||
return activeFiles[activeFileIndex] || activeFiles[0];
|
||||
return activeFiles[0]; // Use first file for simplicity
|
||||
}
|
||||
return null;
|
||||
}, [previewFile, activeFiles, activeFileIndex]);
|
||||
}, [previewFile, activeFiles]);
|
||||
|
||||
// Get file with URL for rendering
|
||||
const fileWithUrl = useFileWithUrl(currentFile);
|
||||
@@ -271,6 +244,15 @@ const EmbedPdfViewerContent = ({
|
||||
</Center>
|
||||
) : (
|
||||
<>
|
||||
{/* Tabs for multiple files */}
|
||||
{activeFiles.length > 1 && !previewFile && (
|
||||
<Box p="md" style={{ borderBottom: `1px solid ${theme.colors.gray[3]}` }}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Multiple files loaded - showing first file for now
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* EmbedPDF Viewer */}
|
||||
<Box style={{
|
||||
position: 'relative',
|
||||
@@ -335,7 +317,6 @@ const EmbedPdfViewerContent = ({
|
||||
<ThumbnailSidebar
|
||||
visible={isThumbnailSidebarVisible}
|
||||
onToggle={toggleThumbnailSidebar}
|
||||
activeFileIndex={activeFileIndex}
|
||||
/>
|
||||
|
||||
{/* Navigation Warning Modal */}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SearchPluginPackage } from '@embedpdf/plugin-search/react';
|
||||
import { ThumbnailPluginPackage } from '@embedpdf/plugin-thumbnail/react';
|
||||
import { RotatePluginPackage, Rotate } from '@embedpdf/plugin-rotate/react';
|
||||
import { ExportPluginPackage } from '@embedpdf/plugin-export/react';
|
||||
import { Rotation } from '@embedpdf/models';
|
||||
|
||||
// Import annotation plugins
|
||||
import { HistoryPluginPackage } from '@embedpdf/plugin-history/react';
|
||||
@@ -66,10 +67,6 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
const plugins = useMemo(() => {
|
||||
if (!pdfUrl) return [];
|
||||
|
||||
// Calculate 3.5rem in pixels dynamically based on root font size
|
||||
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const viewportGap = rootFontSize * 3.5;
|
||||
|
||||
return [
|
||||
createPluginRegistration(LoaderPluginPackage, {
|
||||
loadingOptions: {
|
||||
@@ -81,7 +78,7 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
},
|
||||
}),
|
||||
createPluginRegistration(ViewportPluginPackage, {
|
||||
viewportGap,
|
||||
viewportGap: 10,
|
||||
}),
|
||||
createPluginRegistration(ScrollPluginPackage, {
|
||||
strategy: ScrollStrategy.Vertical,
|
||||
@@ -137,7 +134,9 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
createPluginRegistration(ThumbnailPluginPackage),
|
||||
|
||||
// Register rotate plugin
|
||||
createPluginRegistration(RotatePluginPackage),
|
||||
createPluginRegistration(RotatePluginPackage, {
|
||||
defaultRotation: Rotation.Degree0, // Start with no rotation
|
||||
}),
|
||||
|
||||
// Register export plugin for downloading PDFs
|
||||
createPluginRegistration(ExportPluginPackage, {
|
||||
@@ -289,50 +288,48 @@ export function LocalEmbedPDF({ file, url, enableAnnotations = false, onSignatur
|
||||
}}
|
||||
>
|
||||
<Scroller
|
||||
renderPage={({ document, width, height, pageIndex, scale, rotation }) => {
|
||||
return (
|
||||
<Rotate key={document?.id} pageSize={{ width, height }}>
|
||||
<PagePointerProvider pageIndex={pageIndex} pageWidth={width} pageHeight={height} scale={scale} rotation={rotation}>
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)'
|
||||
}}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* High-resolution tile layer */}
|
||||
<TilingLayer pageIndex={pageIndex} scale={scale} />
|
||||
renderPage={({ width, height, pageIndex, scale, rotation }: { width: number; height: number; pageIndex: number; scale: number; rotation?: number }) => (
|
||||
<Rotate pageSize={{ width, height }}>
|
||||
<PagePointerProvider {...{ pageWidth: width, pageHeight: height, pageIndex, scale, rotation: rotation || 0 }}>
|
||||
<div
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
position: 'relative',
|
||||
userSelect: 'none',
|
||||
WebkitUserSelect: 'none',
|
||||
MozUserSelect: 'none',
|
||||
msUserSelect: 'none',
|
||||
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)'
|
||||
}}
|
||||
draggable={false}
|
||||
onDragStart={(e) => e.preventDefault()}
|
||||
onDrop={(e) => e.preventDefault()}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* High-resolution tile layer */}
|
||||
<TilingLayer pageIndex={pageIndex} scale={scale} />
|
||||
|
||||
{/* Search highlight layer */}
|
||||
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
|
||||
{/* Search highlight layer */}
|
||||
<CustomSearchLayer pageIndex={pageIndex} scale={scale} />
|
||||
|
||||
{/* Selection layer for text interaction */}
|
||||
<SelectionLayer pageIndex={pageIndex} scale={scale} />
|
||||
{/* Annotation layer for signatures (only when enabled) */}
|
||||
{enableAnnotations && (
|
||||
<AnnotationLayer
|
||||
pageIndex={pageIndex}
|
||||
scale={scale}
|
||||
pageWidth={width}
|
||||
pageHeight={height}
|
||||
rotation={rotation}
|
||||
selectionOutlineColor="#007ACC"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PagePointerProvider>
|
||||
</Rotate>
|
||||
);
|
||||
}}
|
||||
{/* Selection layer for text interaction */}
|
||||
<SelectionLayer pageIndex={pageIndex} scale={scale} />
|
||||
{/* Annotation layer for signatures (only when enabled) */}
|
||||
{enableAnnotations && (
|
||||
<AnnotationLayer
|
||||
pageIndex={pageIndex}
|
||||
scale={scale}
|
||||
pageWidth={width}
|
||||
pageHeight={height}
|
||||
rotation={rotation || 0}
|
||||
selectionOutlineColor="#007ACC"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</PagePointerProvider>
|
||||
</Rotate>
|
||||
)}
|
||||
/>
|
||||
</Viewport>
|
||||
</GlobalPointerProvider>
|
||||
|
||||
@@ -64,10 +64,6 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
const plugins = useMemo(() => {
|
||||
if (!pdfUrl) return [];
|
||||
|
||||
// Calculate 3.5rem in pixels dynamically based on root font size
|
||||
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
const viewportGap = rootFontSize * 3.5;
|
||||
|
||||
return [
|
||||
createPluginRegistration(LoaderPluginPackage, {
|
||||
loadingOptions: {
|
||||
@@ -79,7 +75,7 @@ export function LocalEmbedPDFWithAnnotations({
|
||||
},
|
||||
}),
|
||||
createPluginRegistration(ViewportPluginPackage, {
|
||||
viewportGap,
|
||||
viewportGap: 10,
|
||||
}),
|
||||
createPluginRegistration(ScrollPluginPackage, {
|
||||
strategy: ScrollStrategy.Vertical,
|
||||
|
||||
@@ -5,27 +5,15 @@ import { useViewer } from '../../contexts/ViewerContext';
|
||||
interface ThumbnailSidebarProps {
|
||||
visible: boolean;
|
||||
onToggle: () => void;
|
||||
activeFileIndex?: number;
|
||||
}
|
||||
|
||||
export function ThumbnailSidebar({ visible, onToggle: _onToggle, activeFileIndex }: ThumbnailSidebarProps) {
|
||||
export function ThumbnailSidebar({ visible, onToggle: _onToggle }: ThumbnailSidebarProps) {
|
||||
const { getScrollState, scrollActions, getThumbnailAPI } = useViewer();
|
||||
const [thumbnails, setThumbnails] = useState<{ [key: number]: string }>({});
|
||||
|
||||
const scrollState = getScrollState();
|
||||
const thumbnailAPI = getThumbnailAPI();
|
||||
|
||||
// Clear thumbnails when active file changes
|
||||
useEffect(() => {
|
||||
// Revoke old blob URLs to prevent memory leaks
|
||||
Object.values(thumbnails).forEach((thumbUrl) => {
|
||||
if (typeof thumbUrl === 'string' && thumbUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(thumbUrl);
|
||||
}
|
||||
});
|
||||
setThumbnails({});
|
||||
}, [activeFileIndex]);
|
||||
|
||||
// Clear thumbnails when sidebar closes and revoke blob URLs to prevent memory leaks
|
||||
useEffect(() => {
|
||||
if (!visible) {
|
||||
|
||||
@@ -5,8 +5,6 @@ export interface ViewerProps {
|
||||
setSidebarsVisible: (v: boolean) => void;
|
||||
onClose?: () => void;
|
||||
previewFile?: File | null;
|
||||
activeFileIndex?: number;
|
||||
setActiveFileIndex?: (index: number) => void;
|
||||
}
|
||||
|
||||
const Viewer = (props: ViewerProps) => {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { PageEditorFunctions } from '../types/pageEditor';
|
||||
import { ToolRegistryEntry, ToolRegistry } from '../data/toolsTaxonomy';
|
||||
import { useNavigationActions, useNavigationState } from './NavigationContext';
|
||||
import { ToolId, isValidToolId } from '../types/toolId';
|
||||
import { WorkbenchType, getDefaultWorkbench, isBaseWorkbench } from '../types/workbench';
|
||||
import { getDefaultWorkbench } from '../types/workbench';
|
||||
import { filterToolRegistryByQuery } from '../utils/toolSearch';
|
||||
import { useToolHistory } from '../hooks/tools/useUserToolActivity';
|
||||
import {
|
||||
@@ -25,18 +25,6 @@ import { usePreferences } from './PreferencesContext';
|
||||
// Types and reducer/state moved to './toolWorkflow/state'
|
||||
|
||||
// Context value interface
|
||||
export interface CustomWorkbenchViewRegistration {
|
||||
id: string;
|
||||
workbenchId: WorkbenchType;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
component: React.ComponentType<{ data: any }>;
|
||||
}
|
||||
|
||||
export interface CustomWorkbenchViewInstance extends CustomWorkbenchViewRegistration {
|
||||
data: any;
|
||||
}
|
||||
|
||||
interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
// Tool management (from hook)
|
||||
selectedToolKey: ToolId | null;
|
||||
@@ -75,21 +63,9 @@ interface ToolWorkflowContextValue extends ToolWorkflowState {
|
||||
favoriteTools: ToolId[];
|
||||
toggleFavorite: (toolId: ToolId) => void;
|
||||
isFavorite: (toolId: ToolId) => boolean;
|
||||
|
||||
customWorkbenchViews: CustomWorkbenchViewInstance[];
|
||||
registerCustomWorkbenchView: (view: CustomWorkbenchViewRegistration) => void;
|
||||
unregisterCustomWorkbenchView: (id: string) => void;
|
||||
setCustomWorkbenchViewData: (id: string, data: any) => void;
|
||||
clearCustomWorkbenchViewData: (id: string) => void;
|
||||
}
|
||||
|
||||
// Ensure a single context instance across HMR to avoid provider/consumer mismatches
|
||||
const __GLOBAL_CONTEXT_KEY__ = '__ToolWorkflowContext__';
|
||||
const existingContext = (globalThis as any)[__GLOBAL_CONTEXT_KEY__] as React.Context<ToolWorkflowContextValue | undefined> | undefined;
|
||||
const ToolWorkflowContext = existingContext ?? createContext<ToolWorkflowContextValue | undefined>(undefined);
|
||||
if (!existingContext) {
|
||||
(globalThis as any)[__GLOBAL_CONTEXT_KEY__] = ToolWorkflowContext;
|
||||
}
|
||||
const ToolWorkflowContext = createContext<ToolWorkflowContextValue | undefined>(undefined);
|
||||
|
||||
// Provider component
|
||||
interface ToolWorkflowProviderProps {
|
||||
@@ -103,9 +79,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
// Store reset functions for tools
|
||||
const [toolResetFunctions, setToolResetFunctions] = React.useState<Record<string, () => void>>({});
|
||||
|
||||
const [customViewRegistry, setCustomViewRegistry] = React.useState<Record<string, CustomWorkbenchViewRegistration>>({});
|
||||
const [customViewData, setCustomViewData] = React.useState<Record<string, any>>({});
|
||||
|
||||
// Navigation actions and state are available since we're inside NavigationProvider
|
||||
const { actions } = useNavigationActions();
|
||||
const navigationState = useNavigationState();
|
||||
@@ -163,71 +136,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
dispatch({ type: 'SET_SEARCH_QUERY', payload: query });
|
||||
}, []);
|
||||
|
||||
const registerCustomWorkbenchView = useCallback((view: CustomWorkbenchViewRegistration) => {
|
||||
setCustomViewRegistry(prev => ({ ...prev, [view.id]: view }));
|
||||
}, []);
|
||||
|
||||
const unregisterCustomWorkbenchView = useCallback((id: string) => {
|
||||
let removedView: CustomWorkbenchViewRegistration | undefined;
|
||||
|
||||
setCustomViewRegistry(prev => {
|
||||
const existing = prev[id];
|
||||
if (!existing) {
|
||||
return prev;
|
||||
}
|
||||
removedView = existing;
|
||||
const updated = { ...prev };
|
||||
delete updated[id];
|
||||
return updated;
|
||||
});
|
||||
|
||||
setCustomViewData(prev => {
|
||||
if (!(id in prev)) {
|
||||
return prev;
|
||||
}
|
||||
const updated = { ...prev };
|
||||
delete updated[id];
|
||||
return updated;
|
||||
});
|
||||
|
||||
if (removedView && navigationState.workbench === removedView.workbenchId) {
|
||||
actions.setWorkbench(getDefaultWorkbench());
|
||||
}
|
||||
}, [actions, navigationState.workbench]);
|
||||
|
||||
const setCustomWorkbenchViewData = useCallback((id: string, data: any) => {
|
||||
setCustomViewData(prev => ({ ...prev, [id]: data }));
|
||||
}, []);
|
||||
|
||||
const clearCustomWorkbenchViewData = useCallback((id: string) => {
|
||||
setCustomViewData(prev => {
|
||||
if (!(id in prev)) {
|
||||
return prev;
|
||||
}
|
||||
const updated = { ...prev };
|
||||
delete updated[id];
|
||||
return updated;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const customWorkbenchViews = useMemo<CustomWorkbenchViewInstance[]>(() => {
|
||||
return Object.values(customViewRegistry).map(view => ({
|
||||
...view,
|
||||
data: Object.prototype.hasOwnProperty.call(customViewData, view.id) ? customViewData[view.id] : null,
|
||||
}));
|
||||
}, [customViewRegistry, customViewData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isBaseWorkbench(navigationState.workbench)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentCustomView = customWorkbenchViews.find(view => view.workbenchId === navigationState.workbench);
|
||||
if (!currentCustomView || currentCustomView.data == null) {
|
||||
actions.setWorkbench(getDefaultWorkbench());
|
||||
}
|
||||
}, [actions, customWorkbenchViews, navigationState.workbench]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
@@ -268,15 +176,11 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
|
||||
// Workflow actions (compound actions that coordinate multiple state changes)
|
||||
const handleToolSelect = useCallback((toolId: ToolId) => {
|
||||
// If we're currently on a custom workbench (e.g., Validate Signature report),
|
||||
// selecting any tool should take the user back to the default file manager view.
|
||||
const wasInCustomWorkbench = !isBaseWorkbench(navigationState.workbench);
|
||||
|
||||
// Handle read tool selection - should behave exactly like QuickAccessBar read button
|
||||
if (toolId === 'read') {
|
||||
setReaderMode(true);
|
||||
actions.setSelectedTool('read');
|
||||
actions.setWorkbench(wasInCustomWorkbench ? getDefaultWorkbench() : 'viewer');
|
||||
actions.setWorkbench('viewer');
|
||||
setSearchQuery('');
|
||||
return;
|
||||
}
|
||||
@@ -286,7 +190,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setReaderMode(false);
|
||||
setLeftPanelView('hidden');
|
||||
actions.setSelectedTool('multiTool');
|
||||
actions.setWorkbench(wasInCustomWorkbench ? getDefaultWorkbench() : 'pageEditor');
|
||||
actions.setWorkbench('pageEditor');
|
||||
setSearchQuery('');
|
||||
return;
|
||||
}
|
||||
@@ -297,9 +201,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
|
||||
// Get the tool from registry to determine workbench
|
||||
const tool = getSelectedTool(toolId);
|
||||
if (wasInCustomWorkbench) {
|
||||
actions.setWorkbench(getDefaultWorkbench());
|
||||
} else if (tool && tool.workbench) {
|
||||
if (tool && tool.workbench) {
|
||||
actions.setWorkbench(tool.workbench);
|
||||
} else {
|
||||
actions.setWorkbench(getDefaultWorkbench());
|
||||
@@ -309,7 +211,7 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
setSearchQuery('');
|
||||
setLeftPanelView('toolContent');
|
||||
setReaderMode(false); // Disable read mode when selecting tools
|
||||
}, [actions, getSelectedTool, navigationState.workbench, setLeftPanelView, setReaderMode, setSearchQuery]);
|
||||
}, [actions, getSelectedTool, setLeftPanelView, setReaderMode, setSearchQuery]);
|
||||
|
||||
const handleBackToTools = useCallback(() => {
|
||||
setLeftPanelView('toolPicker');
|
||||
@@ -370,13 +272,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
favoriteTools,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
|
||||
// Custom workbench views
|
||||
customWorkbenchViews,
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
}), [
|
||||
state,
|
||||
navigationState.selectedTool,
|
||||
@@ -401,11 +296,6 @@ export function ToolWorkflowProvider({ children }: ToolWorkflowProviderProps) {
|
||||
favoriteTools,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
customWorkbenchViews,
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -132,10 +132,6 @@ interface ViewerContextType {
|
||||
setAnnotationMode: (enabled: boolean) => void;
|
||||
toggleAnnotationMode: () => void;
|
||||
|
||||
// Active file index for multi-file viewing
|
||||
activeFileIndex: number;
|
||||
setActiveFileIndex: (index: number) => void;
|
||||
|
||||
// State getters - read current state from bridges
|
||||
getScrollState: () => ScrollState;
|
||||
getZoomState: () => ZoomState;
|
||||
@@ -223,7 +219,6 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
const [isThumbnailSidebarVisible, setIsThumbnailSidebarVisible] = useState(false);
|
||||
const [isAnnotationsVisible, setIsAnnotationsVisible] = useState(true);
|
||||
const [isAnnotationMode, setIsAnnotationModeState] = useState(false);
|
||||
const [activeFileIndex, setActiveFileIndex] = useState(0);
|
||||
|
||||
// Get current navigation state to check if we're in sign mode
|
||||
useNavigation();
|
||||
@@ -582,10 +577,6 @@ export const ViewerProvider: React.FC<ViewerProviderProps> = ({ children }) => {
|
||||
setAnnotationMode,
|
||||
toggleAnnotationMode,
|
||||
|
||||
// Active file index
|
||||
activeFileIndex,
|
||||
setActiveFileIndex,
|
||||
|
||||
// State getters
|
||||
getScrollState,
|
||||
getZoomState,
|
||||
|
||||
@@ -136,7 +136,7 @@ export function createChildStub(
|
||||
const originalFileId = parentStub.originalFileId || parentStub.id;
|
||||
|
||||
// Copy parent metadata but exclude processedFile to prevent stale data
|
||||
const { processedFile: _processedFile, ...parentMetadata } = parentStub;
|
||||
const { processedFile: _processedFile, activeJobs: _activeJobs, ...parentMetadata } = parentStub;
|
||||
|
||||
return {
|
||||
// Copy parent metadata (excluding processedFile)
|
||||
|
||||
@@ -87,7 +87,11 @@ export function createFileSelectors(
|
||||
return stateRef.current.files.ids
|
||||
.map(id => {
|
||||
const record = stateRef.current.files.byId[id];
|
||||
return record ? `${id}:${record.size}:${record.lastModified}` : '';
|
||||
if (!record) return '';
|
||||
const jobsSignature = (record.activeJobs || [])
|
||||
.map(job => `${job.jobId}:${job.status}:${Math.round(job.progressPercent)}`)
|
||||
.join(';');
|
||||
return `${id}:${record.size}:${record.lastModified}:${jobsSignature}`;
|
||||
})
|
||||
.join('|');
|
||||
},
|
||||
|
||||
@@ -108,7 +108,6 @@ import RemovePagesSettings from "../components/tools/removePages/RemovePagesSett
|
||||
import RemoveBlanksSettings from "../components/tools/removeBlanks/RemoveBlanksSettings";
|
||||
import AddPageNumbersAutomationSettings from "../components/tools/addPageNumbers/AddPageNumbersAutomationSettings";
|
||||
import OverlayPdfsSettings from "../components/tools/overlayPdfs/OverlayPdfsSettings";
|
||||
import ValidateSignature from "../tools/ValidateSignature";
|
||||
|
||||
const showPlaceholderTools = true; // Show all tools; grey out unavailable ones in UI
|
||||
|
||||
@@ -282,12 +281,10 @@ export function useFlatToolRegistry(): ToolRegistry {
|
||||
validateSignature: {
|
||||
icon: <LocalIcon icon="verified-rounded" width="1.5rem" height="1.5rem" />,
|
||||
name: t("home.validateSignature.title", "Validate PDF Signature"),
|
||||
component: ValidateSignature,
|
||||
component: null,
|
||||
description: t("home.validateSignature.desc", "Verify digital signatures and certificates in PDF documents"),
|
||||
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||
subcategoryId: SubcategoryId.VERIFICATION,
|
||||
maxFiles: -1,
|
||||
endpoints: ["validate-signature"],
|
||||
synonyms: getSynonyms(t, "validateSignature"),
|
||||
automationSettings: null
|
||||
},
|
||||
|
||||
@@ -2,9 +2,12 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useToolOperation, ToolType } from '../shared/useToolOperation';
|
||||
import { createStandardErrorHandler } from '../../../utils/toolErrorHandler';
|
||||
import { RemoveAnnotationsParameters, defaultParameters } from './useRemoveAnnotationsParameters';
|
||||
import { PDFDocument, PDFName, PDFRef, PDFDict } from 'pdf-lib';
|
||||
|
||||
// Client-side PDF processing using PDF-lib
|
||||
const removeAnnotationsProcessor = async (_parameters: RemoveAnnotationsParameters, files: File[]): Promise<File[]> => {
|
||||
// Dynamic import of PDF-lib for client-side processing
|
||||
const { PDFDocument, PDFName, PDFRef, PDFDict } = await import('pdf-lib');
|
||||
|
||||
const processedFiles: File[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { useCallback, useRef } from 'react';
|
||||
import axios, {type CancelTokenSource} from 'axios'; // Real axios for static methods (CancelToken, isCancel)
|
||||
import axios, { type CancelTokenSource } from 'axios'; // Real axios for static methods (CancelToken, isCancel)
|
||||
import apiClient from '../../../services/apiClient'; // Our configured instance
|
||||
import { processResponse, ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { isEmptyOutput } from '../../../services/errorUtils';
|
||||
import {
|
||||
ensureAsyncParam,
|
||||
waitForJobCompletion,
|
||||
fetchJobResult,
|
||||
downloadResultFile,
|
||||
readJobResponseBlob,
|
||||
} from '../../../services/jobService';
|
||||
import type { JobStatus } from '../../../services/jobService';
|
||||
import type { ProcessingProgress } from './useToolState';
|
||||
import type { FileId } from '../../../types/file';
|
||||
import type { FileJobStatus } from '../../../types/fileContext';
|
||||
|
||||
export interface ApiCallsConfig<TParams = void> {
|
||||
endpoint: string | ((params: TParams) => string);
|
||||
@@ -13,8 +23,233 @@ export interface ApiCallsConfig<TParams = void> {
|
||||
preserveBackendFilename?: boolean;
|
||||
}
|
||||
|
||||
export interface BatchApiCallsConfig<TParams = void> extends Omit<ApiCallsConfig<TParams>, 'buildFormData'> {
|
||||
buildFormData: (params: TParams, files: File[]) => FormData;
|
||||
}
|
||||
|
||||
export interface JobUpdate {
|
||||
jobId: string;
|
||||
status: FileJobStatus;
|
||||
progressPercent: number;
|
||||
message?: string;
|
||||
queuePosition?: number | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
type JobUpdateCallback = (fileIds: FileId[], update: JobUpdate) => void;
|
||||
|
||||
type BuildStatus = (status: JobStatus) => JobUpdate;
|
||||
|
||||
interface RunToolJobOptions<TParams> {
|
||||
params: TParams;
|
||||
endpoint: string;
|
||||
formData: FormData;
|
||||
originalFiles: File[];
|
||||
filePrefix?: string;
|
||||
responseHandler?: ResponseHandler;
|
||||
preserveBackendFilename?: boolean;
|
||||
onStatus: (status: string) => void;
|
||||
onJobUpdate?: JobUpdateCallback;
|
||||
buildStatus: BuildStatus;
|
||||
isCancelled: () => boolean;
|
||||
cancelToken?: CancelTokenSource | null;
|
||||
}
|
||||
|
||||
const DEFAULT_PROGRESS_FALLBACK = 10;
|
||||
|
||||
function getFileIds(files: File[]): FileId[] {
|
||||
return files
|
||||
.map(file => (file as any)?.fileId)
|
||||
.filter((id): id is FileId => typeof id === 'string' && id.length > 0);
|
||||
}
|
||||
|
||||
export const useToolApiCalls = <TParams = void>() => {
|
||||
const cancelTokenRef = useRef<CancelTokenSource | null>(null);
|
||||
const isCancelledRef = useRef(false);
|
||||
|
||||
const jobStatusToUpdate = useCallback<BuildStatus>((status) => {
|
||||
const hasError = Boolean(status.error);
|
||||
const isComplete = status.complete && !hasError;
|
||||
let derivedStatus: FileJobStatus = 'processing';
|
||||
|
||||
if (hasError) {
|
||||
derivedStatus = 'failed';
|
||||
} else if (status.inQueue && !status.complete) {
|
||||
derivedStatus = 'queued';
|
||||
} else if (isComplete) {
|
||||
derivedStatus = 'completed';
|
||||
}
|
||||
|
||||
const queueMessage = (() => {
|
||||
if (!status.inQueue) return undefined;
|
||||
if (typeof status.queuePosition === 'number' && status.queuePosition >= 0) {
|
||||
return `Queued (#${status.queuePosition + 1})`;
|
||||
}
|
||||
return 'Queued';
|
||||
})();
|
||||
|
||||
const message =
|
||||
status.error ??
|
||||
status.progressMessage ??
|
||||
(isComplete ? 'Completed' : queueMessage);
|
||||
|
||||
const progress = typeof status.progressPercent === 'number'
|
||||
? status.progressPercent
|
||||
: derivedStatus === 'completed'
|
||||
? 100
|
||||
: derivedStatus === 'queued'
|
||||
? 0
|
||||
: DEFAULT_PROGRESS_FALLBACK;
|
||||
|
||||
return {
|
||||
jobId: status.jobId,
|
||||
status: derivedStatus,
|
||||
progressPercent: Math.max(0, Math.min(progress, 100)),
|
||||
message,
|
||||
queuePosition: typeof status.queuePosition === 'number' ? status.queuePosition : null,
|
||||
error: status.error ?? undefined,
|
||||
};
|
||||
}, []);
|
||||
|
||||
const runToolJob = useCallback(async <T>(options: RunToolJobOptions<T>): Promise<File[]> => {
|
||||
const {
|
||||
endpoint,
|
||||
formData,
|
||||
originalFiles,
|
||||
filePrefix,
|
||||
responseHandler,
|
||||
preserveBackendFilename,
|
||||
onStatus,
|
||||
onJobUpdate,
|
||||
buildStatus,
|
||||
isCancelled,
|
||||
cancelToken,
|
||||
} = options;
|
||||
|
||||
const asyncEndpoint = ensureAsyncParam(endpoint);
|
||||
const token = cancelToken?.token;
|
||||
const response = await apiClient.post(asyncEndpoint, formData, {
|
||||
responseType: 'blob',
|
||||
cancelToken: token,
|
||||
});
|
||||
|
||||
const headers = response.headers ?? {};
|
||||
const contentType = (headers['content-type'] || '') as string;
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const payload = await readJobResponseBlob(response.data);
|
||||
|
||||
if (payload && typeof payload === 'object' && payload.async && payload.jobId) {
|
||||
const fileIds = getFileIds(originalFiles);
|
||||
const initialUpdate: JobUpdate = {
|
||||
jobId: payload.jobId,
|
||||
status: 'queued',
|
||||
progressPercent: 0,
|
||||
message: 'Job submitted',
|
||||
queuePosition: null,
|
||||
};
|
||||
onJobUpdate?.(fileIds, initialUpdate);
|
||||
onStatus(initialUpdate.message ?? 'Job submitted');
|
||||
|
||||
const finalStatus = await waitForJobCompletion(payload.jobId, {
|
||||
cancelToken: token,
|
||||
isCancelled,
|
||||
onUpdate: (status) => {
|
||||
const update = buildStatus(status);
|
||||
onJobUpdate?.(fileIds, update);
|
||||
if (update.message) {
|
||||
onStatus(update.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const completionUpdate = buildStatus(finalStatus);
|
||||
|
||||
if (completionUpdate.status === 'failed') {
|
||||
onJobUpdate?.(fileIds, completionUpdate);
|
||||
if (completionUpdate.message) {
|
||||
onStatus(completionUpdate.message);
|
||||
}
|
||||
throw new Error(completionUpdate.error || 'Job failed');
|
||||
}
|
||||
|
||||
const downloadUpdate: JobUpdate = {
|
||||
jobId: completionUpdate.jobId,
|
||||
status: 'processing',
|
||||
progressPercent: Math.max(
|
||||
96,
|
||||
completionUpdate.progressPercent
|
||||
? Math.min(completionUpdate.progressPercent, 98)
|
||||
: 96,
|
||||
),
|
||||
message: 'Downloading results...',
|
||||
queuePosition: null,
|
||||
};
|
||||
onJobUpdate?.(fileIds, downloadUpdate);
|
||||
onStatus(downloadUpdate.message);
|
||||
|
||||
const jobResult = await fetchJobResult(payload.jobId, token);
|
||||
|
||||
const prepareUpdate: JobUpdate = {
|
||||
jobId: completionUpdate.jobId,
|
||||
status: 'processing',
|
||||
progressPercent: Math.max(downloadUpdate.progressPercent, 98),
|
||||
message: 'Preparing files...',
|
||||
queuePosition: null,
|
||||
};
|
||||
|
||||
let processedFiles: File[];
|
||||
|
||||
if (jobResult.type === 'blob') {
|
||||
processedFiles = await processResponse(
|
||||
jobResult.blob,
|
||||
originalFiles,
|
||||
filePrefix,
|
||||
responseHandler,
|
||||
preserveBackendFilename ? jobResult.headers : undefined,
|
||||
);
|
||||
} else if (jobResult.type === 'multipleFiles') {
|
||||
processedFiles = await Promise.all(
|
||||
jobResult.files.map(meta => downloadResultFile(meta, token))
|
||||
);
|
||||
} else {
|
||||
throw new Error('Unsupported async job result format');
|
||||
}
|
||||
|
||||
onJobUpdate?.(fileIds, prepareUpdate);
|
||||
if (prepareUpdate.message) {
|
||||
onStatus(prepareUpdate.message);
|
||||
}
|
||||
|
||||
const finalNormalizedUpdate = {
|
||||
...completionUpdate,
|
||||
progressPercent: 100,
|
||||
message: completionUpdate.message ?? 'Completed',
|
||||
};
|
||||
onJobUpdate?.(fileIds, finalNormalizedUpdate);
|
||||
if (finalNormalizedUpdate.message) {
|
||||
onStatus(finalNormalizedUpdate.message);
|
||||
}
|
||||
|
||||
return processedFiles;
|
||||
}
|
||||
|
||||
if (payload && typeof payload === 'object' && payload.error) {
|
||||
throw new Error(payload.error);
|
||||
}
|
||||
|
||||
throw new Error('Async job response missing jobId');
|
||||
}
|
||||
|
||||
// Fallback: backend returned immediate blob (synchronous)
|
||||
return processResponse(
|
||||
response.data,
|
||||
originalFiles,
|
||||
filePrefix,
|
||||
responseHandler,
|
||||
preserveBackendFilename ? headers : undefined,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const processFiles = useCallback(async (
|
||||
params: TParams,
|
||||
@@ -23,70 +258,62 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
onProgress: (progress: ProcessingProgress) => void,
|
||||
onStatus: (status: string) => void,
|
||||
markFileError?: (fileId: string) => void,
|
||||
onJobUpdate?: JobUpdateCallback,
|
||||
): Promise<{ outputFiles: File[]; successSourceIds: string[] }> => {
|
||||
const processedFiles: File[] = [];
|
||||
const successSourceIds: string[] = [];
|
||||
const failedFiles: string[] = [];
|
||||
const total = validFiles.length;
|
||||
|
||||
// Create cancel token for this operation
|
||||
isCancelledRef.current = false;
|
||||
cancelTokenRef.current = axios.CancelToken.source();
|
||||
|
||||
for (let i = 0; i < validFiles.length; i++) {
|
||||
const file = validFiles[i];
|
||||
try {
|
||||
for (let i = 0; i < validFiles.length; i++) {
|
||||
const file = validFiles[i];
|
||||
onProgress({ current: i + 1, total, currentFileName: file.name });
|
||||
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
|
||||
|
||||
console.debug('[processFiles] Start', { index: i, total, name: file.name, fileId: (file as any).fileId });
|
||||
onProgress({ current: i + 1, total, currentFileName: file.name });
|
||||
onStatus(`Processing ${file.name} (${i + 1}/${total})`);
|
||||
|
||||
try {
|
||||
const formData = config.buildFormData(params, file);
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
console.debug('[processFiles] POST', { endpoint, name: file.name });
|
||||
const response = await apiClient.post(endpoint, formData, {
|
||||
responseType: 'blob',
|
||||
cancelToken: cancelTokenRef.current?.token,
|
||||
});
|
||||
console.debug('[processFiles] Response OK', { name: file.name, status: (response as any)?.status });
|
||||
|
||||
// Forward to shared response processor (uses tool-specific responseHandler if provided)
|
||||
const responseFiles = await processResponse(
|
||||
response.data,
|
||||
[file],
|
||||
config.filePrefix,
|
||||
config.responseHandler,
|
||||
config.preserveBackendFilename ? response.headers : undefined
|
||||
);
|
||||
// Guard: some endpoints may return an empty/0-byte file with 200
|
||||
const empty = isEmptyOutput(responseFiles);
|
||||
if (empty) {
|
||||
console.warn('[processFiles] Empty output treated as failure', { name: file.name });
|
||||
failedFiles.push(file.name);
|
||||
try {
|
||||
(markFileError as any)?.((file as any).fileId);
|
||||
} catch (e) {
|
||||
console.debug('markFileError', e);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
processedFiles.push(...responseFiles);
|
||||
// record source id as successful
|
||||
successSourceIds.push((file as any).fileId);
|
||||
console.debug('[processFiles] Success', { name: file.name, produced: responseFiles.length });
|
||||
|
||||
} catch (error) {
|
||||
if (axios.isCancel(error)) {
|
||||
throw new Error('Operation was cancelled');
|
||||
}
|
||||
console.error('[processFiles] Failed', { name: file.name, error });
|
||||
failedFiles.push(file.name);
|
||||
// mark errored file so UI can highlight
|
||||
try {
|
||||
(markFileError as any)?.((file as any).fileId);
|
||||
} catch (e) {
|
||||
console.debug('markFileError', e);
|
||||
const formData = config.buildFormData(params, file);
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
|
||||
const responseFiles = await runToolJob({
|
||||
params,
|
||||
endpoint,
|
||||
formData,
|
||||
originalFiles: [file],
|
||||
filePrefix: config.filePrefix,
|
||||
responseHandler: config.responseHandler,
|
||||
preserveBackendFilename: config.preserveBackendFilename,
|
||||
onStatus,
|
||||
onJobUpdate,
|
||||
buildStatus: jobStatusToUpdate,
|
||||
isCancelled: () => isCancelledRef.current,
|
||||
cancelToken: cancelTokenRef.current,
|
||||
});
|
||||
|
||||
const empty = isEmptyOutput(responseFiles);
|
||||
if (empty) {
|
||||
failedFiles.push(file.name);
|
||||
markFileError?.((file as any).fileId);
|
||||
continue;
|
||||
}
|
||||
|
||||
processedFiles.push(...responseFiles);
|
||||
successSourceIds.push((file as any).fileId);
|
||||
} catch (error) {
|
||||
if (axios.isCancel(error) || (error as Error)?.message === 'Operation was cancelled') {
|
||||
throw new Error('Operation was cancelled');
|
||||
}
|
||||
|
||||
failedFiles.push(file.name);
|
||||
markFileError?.((file as any).fileId);
|
||||
console.error('[processFiles] Job failed', { name: file.name, error });
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
cancelTokenRef.current = null;
|
||||
}
|
||||
|
||||
if (failedFiles.length > 0 && processedFiles.length === 0) {
|
||||
@@ -99,11 +326,59 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
onStatus(`Successfully processed ${processedFiles.length} file${processedFiles.length === 1 ? '' : 's'}`);
|
||||
}
|
||||
|
||||
console.debug('[processFiles] Completed batch', { total, successes: successSourceIds.length, outputs: processedFiles.length, failed: failedFiles.length });
|
||||
return { outputFiles: processedFiles, successSourceIds };
|
||||
}, []);
|
||||
}, [jobStatusToUpdate, runToolJob]);
|
||||
|
||||
const processBatchJob = useCallback(async (
|
||||
params: TParams,
|
||||
files: File[],
|
||||
config: BatchApiCallsConfig<TParams>,
|
||||
onProgress: (progress: ProcessingProgress) => void,
|
||||
onStatus: (status: string) => void,
|
||||
onJobUpdate?: JobUpdateCallback,
|
||||
): Promise<{ outputFiles: File[]; successSourceIds: string[] }> => {
|
||||
isCancelledRef.current = false;
|
||||
cancelTokenRef.current = axios.CancelToken.source();
|
||||
|
||||
try {
|
||||
onStatus('Processing files...');
|
||||
onProgress({ current: 0, total: files.length, currentFileName: files[0]?.name });
|
||||
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
const formData = config.buildFormData(params, files);
|
||||
|
||||
const responseFiles = await runToolJob({
|
||||
params,
|
||||
endpoint,
|
||||
formData,
|
||||
originalFiles: files,
|
||||
filePrefix: config.filePrefix,
|
||||
responseHandler: config.responseHandler,
|
||||
preserveBackendFilename: config.preserveBackendFilename,
|
||||
onStatus,
|
||||
onJobUpdate,
|
||||
buildStatus: jobStatusToUpdate,
|
||||
isCancelled: () => isCancelledRef.current,
|
||||
cancelToken: cancelTokenRef.current,
|
||||
});
|
||||
|
||||
const empty = isEmptyOutput(responseFiles);
|
||||
if (empty) {
|
||||
throw new Error('No files produced by operation');
|
||||
}
|
||||
|
||||
onProgress({ current: files.length, total: files.length, currentFileName: files[files.length - 1]?.name });
|
||||
onStatus(`Successfully processed ${responseFiles.length} file${responseFiles.length === 1 ? '' : 's'}`);
|
||||
|
||||
const successIds = getFileIds(files).map(id => id as unknown as string);
|
||||
return { outputFiles: responseFiles, successSourceIds: successIds };
|
||||
} finally {
|
||||
cancelTokenRef.current = null;
|
||||
}
|
||||
}, [jobStatusToUpdate, runToolJob]);
|
||||
|
||||
const cancelOperation = useCallback(() => {
|
||||
isCancelledRef.current = true;
|
||||
if (cancelTokenRef.current) {
|
||||
cancelTokenRef.current.cancel('Operation cancelled by user');
|
||||
cancelTokenRef.current = null;
|
||||
@@ -112,6 +387,7 @@ export const useToolApiCalls = <TParams = void>() => {
|
||||
|
||||
return {
|
||||
processFiles,
|
||||
processBatchJob,
|
||||
cancelOperation,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import { useCallback, useRef, useEffect } from 'react';
|
||||
import apiClient from '../../../services/apiClient';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFileContext } from '../../../contexts/FileContext';
|
||||
import { useToolState, type ProcessingProgress } from './useToolState';
|
||||
import { useToolApiCalls, type ApiCallsConfig } from './useToolApiCalls';
|
||||
import { useToolApiCalls, type ApiCallsConfig, type BatchApiCallsConfig, type JobUpdate } from './useToolApiCalls';
|
||||
import { useToolResources } from './useToolResources';
|
||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile } from '../../../types/fileContext';
|
||||
import { StirlingFile, extractFiles, FileId, StirlingFileStub, createStirlingFile, FileJobProgress } from '../../../types/fileContext';
|
||||
import { FILE_EVENTS } from '../../../services/errorUtils';
|
||||
import { ResponseHandler } from '../../../utils/toolResponseProcessor';
|
||||
import { createChildStub, generateProcessedFileMetadata } from '../../../contexts/file/fileActions';
|
||||
@@ -145,13 +144,13 @@ export const useToolOperation = <TParams>(
|
||||
config: ToolOperationConfig<TParams>
|
||||
): ToolOperationHook<TParams> => {
|
||||
const { t } = useTranslation();
|
||||
const { addFiles, consumeFiles, undoConsumeFiles, selectors } = useFileContext();
|
||||
const { consumeFiles, undoConsumeFiles, selectors } = useFileContext();
|
||||
|
||||
// Composed hooks
|
||||
const { state, actions } = useToolState();
|
||||
const { actions: fileActions } = useFileContext();
|
||||
const { processFiles, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
||||
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles } = useToolResources();
|
||||
const { processFiles, processBatchJob, cancelOperation: cancelApiCalls } = useToolApiCalls<TParams>();
|
||||
const { generateThumbnails, createDownloadInfo, cleanupBlobUrls } = useToolResources();
|
||||
|
||||
// Track last operation for undo functionality
|
||||
const lastOperationRef = useRef<{
|
||||
@@ -160,6 +159,45 @@ export const useToolOperation = <TParams>(
|
||||
outputFileIds: FileId[];
|
||||
} | null>(null);
|
||||
|
||||
const handleJobUpdate = useCallback((fileIds: FileId[], update: JobUpdate) => {
|
||||
if (!fileIds || fileIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
fileIds.forEach(fileId => {
|
||||
const record = selectors.getStirlingFileStub(fileId);
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = record.activeJobs ?? [];
|
||||
const entry: FileJobProgress = {
|
||||
jobId: update.jobId,
|
||||
status: update.status,
|
||||
progressPercent: Math.max(0, Math.min(update.progressPercent, 100)),
|
||||
message: update.message,
|
||||
queuePosition: update.queuePosition ?? null,
|
||||
error: update.error,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
let nextJobs: FileJobProgress[];
|
||||
if (update.status === 'completed') {
|
||||
nextJobs = existing.filter(job => job.jobId !== update.jobId);
|
||||
} else {
|
||||
const idx = existing.findIndex(job => job.jobId === update.jobId);
|
||||
if (idx >= 0) {
|
||||
nextJobs = [...existing];
|
||||
nextJobs[idx] = entry;
|
||||
} else {
|
||||
nextJobs = [...existing, entry];
|
||||
}
|
||||
}
|
||||
|
||||
fileActions.updateStirlingFileStub(fileId, { activeJobs: nextJobs });
|
||||
});
|
||||
}, [selectors, fileActions]);
|
||||
|
||||
const executeOperation = useCallback(async (
|
||||
params: TParams,
|
||||
selectedFiles: StirlingFile[]
|
||||
@@ -230,7 +268,8 @@ export const useToolOperation = <TParams>(
|
||||
apiCallsConfig,
|
||||
actions.setProgress,
|
||||
actions.setStatus,
|
||||
fileActions.markFileError as any
|
||||
fileActions.markFileError as any,
|
||||
handleJobUpdate
|
||||
);
|
||||
processedFiles = result.outputFiles;
|
||||
successSourceIds = result.successSourceIds as any;
|
||||
@@ -238,35 +277,25 @@ export const useToolOperation = <TParams>(
|
||||
break;
|
||||
}
|
||||
case ToolType.multiFile: {
|
||||
// Multi-file processing - single API call with all files
|
||||
actions.setStatus('Processing files...');
|
||||
const formData = config.buildFormData(params, filesForAPI);
|
||||
const endpoint = typeof config.endpoint === 'function' ? config.endpoint(params) : config.endpoint;
|
||||
const batchConfig: BatchApiCallsConfig<TParams> = {
|
||||
endpoint: config.endpoint,
|
||||
buildFormData: config.buildFormData,
|
||||
filePrefix: config.filePrefix,
|
||||
responseHandler: config.responseHandler,
|
||||
preserveBackendFilename: config.preserveBackendFilename,
|
||||
};
|
||||
|
||||
const response = await apiClient.post(endpoint, formData, { responseType: 'blob' });
|
||||
const result = await processBatchJob(
|
||||
params,
|
||||
filesForAPI,
|
||||
batchConfig,
|
||||
actions.setProgress,
|
||||
actions.setStatus,
|
||||
handleJobUpdate
|
||||
);
|
||||
|
||||
// Multi-file responses are typically ZIP files that need extraction, but some may return single PDFs
|
||||
if (config.responseHandler) {
|
||||
// Use custom responseHandler for multi-file (handles ZIP extraction)
|
||||
processedFiles = await config.responseHandler(response.data, filesForAPI);
|
||||
} else if (response.data.type === 'application/pdf' ||
|
||||
(response.headers && response.headers['content-type'] === 'application/pdf')) {
|
||||
// Single PDF response (e.g. split with merge option) - add prefix to first original filename
|
||||
const filename = `${config.filePrefix}${filesForAPI[0]?.name || 'document.pdf'}`;
|
||||
const singleFile = new File([response.data], filename, { type: 'application/pdf' });
|
||||
processedFiles = [singleFile];
|
||||
} else {
|
||||
// Default: assume ZIP response for multi-file endpoints
|
||||
// Note: extractZipFiles will check preferences.autoUnzip setting
|
||||
processedFiles = await extractZipFiles(response.data);
|
||||
|
||||
if (processedFiles.length === 0) {
|
||||
// Try the generic extraction as fallback
|
||||
processedFiles = await extractAllZipFiles(response.data);
|
||||
}
|
||||
}
|
||||
// Assume all inputs succeeded together unless server provided an error earlier
|
||||
successSourceIds = validFiles.map(f => (f as any).fileId) as any;
|
||||
processedFiles = result.outputFiles;
|
||||
successSourceIds = result.successSourceIds as any;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -446,7 +475,7 @@ export const useToolOperation = <TParams>(
|
||||
actions.setLoading(false);
|
||||
actions.setProgress(null);
|
||||
}
|
||||
}, [t, config, actions, addFiles, consumeFiles, processFiles, generateThumbnails, createDownloadInfo, cleanupBlobUrls, extractZipFiles, extractAllZipFiles]);
|
||||
}, [t, config, actions, consumeFiles, processFiles, processBatchJob, generateThumbnails, createDownloadInfo, cleanupBlobUrls, handleJobUpdate]);
|
||||
|
||||
const cancelOperation = useCallback(() => {
|
||||
cancelApiCalls();
|
||||
|
||||
-68
@@ -1,68 +0,0 @@
|
||||
import { PDFFont, PDFPage, rgb } from 'pdf-lib';
|
||||
import { wrapText } from '../utils/pdfText';
|
||||
import { colorPalette } from '../utils/pdfPalette';
|
||||
|
||||
interface DrawCenteredMessageOptions {
|
||||
page: PDFPage;
|
||||
font: PDFFont;
|
||||
fontBold: PDFFont;
|
||||
text: string;
|
||||
description: string;
|
||||
marginX: number;
|
||||
contentWidth: number;
|
||||
cursorY: number;
|
||||
badgeColor: ReturnType<typeof rgb>;
|
||||
}
|
||||
|
||||
export const drawCenteredMessage = ({
|
||||
page,
|
||||
font,
|
||||
fontBold,
|
||||
text,
|
||||
description,
|
||||
marginX,
|
||||
contentWidth,
|
||||
cursorY,
|
||||
badgeColor,
|
||||
}: DrawCenteredMessageOptions): number => {
|
||||
const badgeFontSize = 10;
|
||||
const badgePaddingX = 14;
|
||||
const badgePaddingY = 6;
|
||||
const badgeWidth = font.widthOfTextAtSize(text, badgeFontSize) + badgePaddingX * 2;
|
||||
const badgeHeight = badgeFontSize + badgePaddingY * 2;
|
||||
const badgeX = marginX + (contentWidth - badgeWidth) / 2;
|
||||
|
||||
page.drawRectangle({
|
||||
x: badgeX,
|
||||
y: cursorY - badgeHeight,
|
||||
width: badgeWidth,
|
||||
height: badgeHeight,
|
||||
color: badgeColor,
|
||||
});
|
||||
|
||||
page.drawText(text, {
|
||||
x: badgeX + badgePaddingX,
|
||||
y: cursorY - badgePaddingY - badgeFontSize + 2,
|
||||
size: badgeFontSize,
|
||||
font: fontBold,
|
||||
color: rgb(1, 1, 1),
|
||||
});
|
||||
|
||||
let nextCursor = cursorY - 32;
|
||||
const lines = wrapText(description, font, 11, contentWidth * 0.75);
|
||||
|
||||
lines.forEach((line) => {
|
||||
const lineWidth = font.widthOfTextAtSize(line, 11);
|
||||
const lineX = marginX + (contentWidth - lineWidth) / 2;
|
||||
page.drawText(line, {
|
||||
x: lineX,
|
||||
y: nextCursor,
|
||||
size: 11,
|
||||
font,
|
||||
color: colorPalette.textPrimary,
|
||||
});
|
||||
nextCursor -= 18;
|
||||
});
|
||||
|
||||
return nextCursor - 8;
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
import { PDFFont, PDFPage } from 'pdf-lib';
|
||||
import { wrapText } from '../utils/pdfText';
|
||||
import { colorPalette } from '../utils/pdfPalette';
|
||||
|
||||
interface FieldBoxOptions {
|
||||
page: PDFPage;
|
||||
font: PDFFont;
|
||||
fontBold: PDFFont;
|
||||
x: number;
|
||||
top: number;
|
||||
width: number;
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export const drawFieldBox = ({
|
||||
page,
|
||||
font,
|
||||
fontBold,
|
||||
x,
|
||||
top,
|
||||
width,
|
||||
label,
|
||||
value,
|
||||
}: FieldBoxOptions): number => {
|
||||
const labelFontSize = 8;
|
||||
const valueFontSize = 11;
|
||||
const valueLineHeight = valueFontSize * 1.25;
|
||||
const boxPadding = 6;
|
||||
|
||||
page.drawText(label.toUpperCase(), {
|
||||
x,
|
||||
y: top - labelFontSize,
|
||||
size: labelFontSize,
|
||||
font: fontBold,
|
||||
color: colorPalette.textMuted,
|
||||
});
|
||||
|
||||
const boxTop = top - labelFontSize - 6;
|
||||
const rawValue = value && value.trim().length > 0 ? value : '--';
|
||||
const lines = wrapText(rawValue, font, valueFontSize, width - boxPadding * 2);
|
||||
const boxHeight = Math.max(valueLineHeight, lines.length * valueLineHeight) + boxPadding * 2;
|
||||
|
||||
page.drawRectangle({
|
||||
x,
|
||||
y: boxTop - boxHeight,
|
||||
width,
|
||||
height: boxHeight,
|
||||
color: colorPalette.boxBackground,
|
||||
borderColor: colorPalette.boxBorder,
|
||||
});
|
||||
|
||||
let textY = boxTop - boxPadding - valueFontSize;
|
||||
lines.forEach((line) => {
|
||||
const lineWidth = font.widthOfTextAtSize(line, valueFontSize);
|
||||
const available = width - boxPadding * 2;
|
||||
const centeredX = x + boxPadding + Math.max(0, (available - lineWidth) / 2);
|
||||
|
||||
page.drawText(line, {
|
||||
x: centeredX,
|
||||
y: textY,
|
||||
size: valueFontSize,
|
||||
font,
|
||||
color: colorPalette.textPrimary,
|
||||
});
|
||||
textY -= valueLineHeight;
|
||||
});
|
||||
|
||||
return labelFontSize + 6 + boxHeight + 6;
|
||||
};
|
||||
@@ -1,173 +0,0 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { PDFFont, PDFPage } from 'pdf-lib';
|
||||
import { SignatureValidationSignature } from '../../../../types/validateSignature';
|
||||
import { drawFieldBox } from './FieldBoxSection';
|
||||
import { drawStatusBadge } from './StatusBadgeSection';
|
||||
import { computeSignatureStatus, statusKindToPdfColor } from '../utils/signatureStatus';
|
||||
import { formatDate } from '../utils/pdfText';
|
||||
import { colorPalette } from '../utils/pdfPalette';
|
||||
|
||||
interface DrawSignatureSectionOptions {
|
||||
page: PDFPage;
|
||||
cursorY: number;
|
||||
signature: SignatureValidationSignature;
|
||||
index: number;
|
||||
marginX: number;
|
||||
contentWidth: number;
|
||||
columnGap: number;
|
||||
font: PDFFont;
|
||||
fontBold: PDFFont;
|
||||
t: TFunction<'translation'>;
|
||||
}
|
||||
|
||||
export const drawSignatureSection = ({
|
||||
page,
|
||||
cursorY,
|
||||
signature,
|
||||
index,
|
||||
marginX,
|
||||
contentWidth,
|
||||
columnGap,
|
||||
font,
|
||||
fontBold,
|
||||
t,
|
||||
}: DrawSignatureSectionOptions): number => {
|
||||
const columnWidth = (contentWidth - columnGap) / 2;
|
||||
|
||||
const heading = `${t('validateSignature.signature._value', 'Signature')} ${index + 1}`;
|
||||
page.drawText(heading, {
|
||||
x: marginX,
|
||||
y: cursorY,
|
||||
size: 14,
|
||||
font: fontBold,
|
||||
color: colorPalette.textPrimary,
|
||||
});
|
||||
|
||||
const status = computeSignatureStatus(signature, t);
|
||||
const statusColor = statusKindToPdfColor(status.kind);
|
||||
|
||||
const headingWidth = fontBold.widthOfTextAtSize(heading, 14);
|
||||
drawStatusBadge({
|
||||
page,
|
||||
font,
|
||||
fontBold,
|
||||
text: status.label,
|
||||
x: marginX + headingWidth + 16,
|
||||
y: cursorY + 14,
|
||||
color: statusColor,
|
||||
});
|
||||
|
||||
let nextY = cursorY - 20;
|
||||
|
||||
const signatureFields = [
|
||||
{ label: t('validateSignature.signer', 'Signer'), value: signature.signerName || '-' },
|
||||
{ label: t('validateSignature.date', 'Date'), value: formatDate(signature.signatureDate) },
|
||||
{ label: t('validateSignature.reason', 'Reason'), value: signature.reason || '-' },
|
||||
{ label: t('validateSignature.location', 'Location'), value: signature.location || '-' },
|
||||
];
|
||||
|
||||
for (let i = 0; i < signatureFields.length; i += 2) {
|
||||
const leftField = signatureFields[i];
|
||||
const rightField = signatureFields[i + 1];
|
||||
|
||||
const leftHeight = drawFieldBox({
|
||||
page,
|
||||
font,
|
||||
fontBold,
|
||||
x: marginX,
|
||||
top: nextY,
|
||||
width: columnWidth,
|
||||
label: leftField.label,
|
||||
value: leftField.value,
|
||||
});
|
||||
|
||||
let rowHeight = leftHeight;
|
||||
if (rightField) {
|
||||
const rightHeight = drawFieldBox({
|
||||
page,
|
||||
font,
|
||||
fontBold,
|
||||
x: marginX + columnWidth + columnGap,
|
||||
top: nextY,
|
||||
width: columnWidth,
|
||||
label: rightField.label,
|
||||
value: rightField.value,
|
||||
});
|
||||
rowHeight = Math.max(leftHeight, rightHeight);
|
||||
}
|
||||
|
||||
nextY -= rowHeight + 8;
|
||||
}
|
||||
|
||||
nextY -= 6;
|
||||
page.drawLine({
|
||||
start: { x: marginX, y: nextY },
|
||||
end: { x: marginX + contentWidth, y: nextY },
|
||||
thickness: 1,
|
||||
color: colorPalette.boxBorder,
|
||||
});
|
||||
nextY -= 20;
|
||||
|
||||
const certificateFields = [
|
||||
{ label: t('validateSignature.cert.issuer', 'Issuer'), value: signature.issuerDN || '-' },
|
||||
{ label: t('validateSignature.cert.subject', 'Subject'), value: signature.subjectDN || '-' },
|
||||
{ label: t('validateSignature.cert.serialNumber', 'Serial Number'), value: signature.serialNumber || '-' },
|
||||
{ label: t('validateSignature.cert.algorithm', 'Algorithm'), value: signature.signatureAlgorithm || '-' },
|
||||
{ label: t('validateSignature.cert.validFrom', 'Valid From'), value: formatDate(signature.validFrom) },
|
||||
{ label: t('validateSignature.cert.validUntil', 'Valid Until'), value: formatDate(signature.validUntil) },
|
||||
{
|
||||
label: t('validateSignature.cert.keySize', 'Key Size'),
|
||||
value:
|
||||
signature.keySize != null
|
||||
? `${signature.keySize} ${t('validateSignature.cert.bits', 'bits')}`
|
||||
: '--',
|
||||
},
|
||||
{ label: t('validateSignature.cert.version', 'Version'), value: signature.version || '-' },
|
||||
{
|
||||
label: t('validateSignature.cert.keyUsage', 'Key Usage'),
|
||||
value:
|
||||
signature.keyUsages && signature.keyUsages.length > 0
|
||||
? signature.keyUsages.join(', ')
|
||||
: '--',
|
||||
},
|
||||
{
|
||||
label: t('validateSignature.cert.selfSigned', 'Self-Signed'),
|
||||
value: signature.selfSigned ? t('yes', 'Yes') : t('no', 'No'),
|
||||
},
|
||||
];
|
||||
|
||||
for (let i = 0; i < certificateFields.length; i += 2) {
|
||||
const leftField = certificateFields[i];
|
||||
const rightField = certificateFields[i + 1];
|
||||
|
||||
const leftHeight = drawFieldBox({
|
||||
page,
|
||||
font,
|
||||
fontBold,
|
||||
x: marginX,
|
||||
top: nextY,
|
||||
width: columnWidth,
|
||||
label: leftField.label,
|
||||
value: leftField.value,
|
||||
});
|
||||
|
||||
let rowHeight = leftHeight;
|
||||
if (rightField) {
|
||||
const rightHeight = drawFieldBox({
|
||||
page,
|
||||
font,
|
||||
fontBold,
|
||||
x: marginX + columnWidth + columnGap,
|
||||
top: nextY,
|
||||
width: columnWidth,
|
||||
label: rightField.label,
|
||||
value: rightField.value,
|
||||
});
|
||||
rowHeight = Math.max(leftHeight, rightHeight);
|
||||
}
|
||||
|
||||
nextY -= rowHeight + 8;
|
||||
}
|
||||
|
||||
return nextY - 12;
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import { PDFFont, PDFPage, rgb } from 'pdf-lib';
|
||||
|
||||
interface StatusBadgeOptions {
|
||||
page: PDFPage;
|
||||
font: PDFFont;
|
||||
fontBold: PDFFont;
|
||||
text: string;
|
||||
x: number;
|
||||
y: number;
|
||||
color: ReturnType<typeof rgb>;
|
||||
}
|
||||
|
||||
export const drawStatusBadge = ({ page, font, fontBold, text, x, y, color }: StatusBadgeOptions): number => {
|
||||
const paddingX = 14;
|
||||
const paddingY = 6;
|
||||
const fontSize = 10;
|
||||
const textWidth = font.widthOfTextAtSize(text, fontSize);
|
||||
const width = textWidth + paddingX * 2;
|
||||
const height = fontSize + paddingY * 2;
|
||||
|
||||
page.drawRectangle({
|
||||
x,
|
||||
y: y - height,
|
||||
width,
|
||||
height,
|
||||
color,
|
||||
});
|
||||
|
||||
page.drawText(text, {
|
||||
x: x + paddingX,
|
||||
y: y - paddingY - fontSize + 2,
|
||||
size: fontSize,
|
||||
font: fontBold,
|
||||
color: rgb(1, 1, 1),
|
||||
});
|
||||
|
||||
return width;
|
||||
};
|
||||
@@ -1,145 +0,0 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { PDFFont, PDFImage, PDFPage } from 'pdf-lib';
|
||||
import { SignatureValidationReportEntry } from '../../../../types/validateSignature';
|
||||
import { drawFieldBox } from './FieldBoxSection';
|
||||
import { drawThumbnailImage, drawThumbnailPlaceholder } from './ThumbnailSection';
|
||||
import { colorPalette } from '../utils/pdfPalette';
|
||||
import { formatFileSize } from '../utils/pdfText';
|
||||
|
||||
interface DrawSummarySectionOptions {
|
||||
page: PDFPage;
|
||||
cursorY: number;
|
||||
entry: SignatureValidationReportEntry;
|
||||
font: PDFFont;
|
||||
fontBold: PDFFont;
|
||||
marginX: number;
|
||||
contentWidth: number;
|
||||
columnGap: number;
|
||||
statusText: string;
|
||||
statusColor: (typeof colorPalette)['success'];
|
||||
loadThumbnail: (url: string) => Promise<{ image: PDFImage } | null>;
|
||||
t: TFunction<'translation'>;
|
||||
}
|
||||
|
||||
export const drawSummarySection = async ({
|
||||
page,
|
||||
cursorY,
|
||||
entry,
|
||||
font,
|
||||
fontBold,
|
||||
marginX,
|
||||
contentWidth,
|
||||
columnGap,
|
||||
loadThumbnail,
|
||||
t,
|
||||
}: DrawSummarySectionOptions): Promise<number> => {
|
||||
const thumbnailWidth = 140;
|
||||
const thumbnailHeight = 180;
|
||||
const summaryX = marginX + thumbnailWidth + 24;
|
||||
const summaryWidth = contentWidth - (thumbnailWidth + 24);
|
||||
const summaryColumnWidth = (summaryWidth - columnGap) / 2;
|
||||
const rowSpacing = 8;
|
||||
const summaryTop = cursorY;
|
||||
const titleFontSize = 22;
|
||||
const subtitleFontSize = 11;
|
||||
|
||||
const latestSignatureTimestamp = entry.signatures
|
||||
.map((sig) => (sig.signatureDate ? Date.parse(sig.signatureDate) : NaN))
|
||||
.filter((value) => !Number.isNaN(value));
|
||||
|
||||
const latestSignatureLabel = latestSignatureTimestamp.length
|
||||
? new Date(Math.max(...latestSignatureTimestamp)).toLocaleString()
|
||||
: '--';
|
||||
|
||||
const titleBaseline = summaryTop - 12 - titleFontSize;
|
||||
page.drawText(entry.fileName, {
|
||||
x: summaryX,
|
||||
y: titleBaseline,
|
||||
size: titleFontSize,
|
||||
font: fontBold,
|
||||
color: colorPalette.textPrimary,
|
||||
});
|
||||
|
||||
const subtitle = t('validateSignature.report.shortTitle', 'Signature Summary');
|
||||
const subtitleBaseline = titleBaseline - subtitleFontSize - 6;
|
||||
page.drawText(subtitle, {
|
||||
x: summaryX,
|
||||
y: subtitleBaseline,
|
||||
size: subtitleFontSize,
|
||||
font,
|
||||
color: colorPalette.textMuted,
|
||||
});
|
||||
|
||||
const summaryRows: Array<
|
||||
Array<{
|
||||
label: string;
|
||||
value: string;
|
||||
}>
|
||||
> = [
|
||||
[
|
||||
{ label: t('validateSignature.report.fields.fileSize', 'File Size'), value: formatFileSize(entry.fileSize) },
|
||||
{ label: t('validateSignature.report.fields.created', 'Created'), value: entry.createdAtLabel ?? '--' },
|
||||
],
|
||||
[
|
||||
{ label: t('validateSignature.report.fields.signatureDate', 'Signature Date'), value: latestSignatureLabel },
|
||||
{ label: t('validateSignature.report.fields.signatureCount', 'Total Signatures'), value: entry.signatures.length.toString() },
|
||||
],
|
||||
];
|
||||
|
||||
let rowTop = subtitleBaseline - subtitleFontSize - 18;
|
||||
|
||||
summaryRows.forEach((fields, rowIndex) => {
|
||||
let rowHeight = 0;
|
||||
|
||||
const singleColumn = fields.length === 1;
|
||||
fields.forEach((field, index) => {
|
||||
const fieldWidth = singleColumn ? summaryWidth : summaryColumnWidth;
|
||||
const x = singleColumn ? summaryX : summaryX + index * (summaryColumnWidth + columnGap);
|
||||
const fieldHeight = drawFieldBox({
|
||||
page,
|
||||
font,
|
||||
fontBold,
|
||||
x,
|
||||
top: rowTop,
|
||||
width: fieldWidth,
|
||||
label: field.label,
|
||||
value: field.value,
|
||||
});
|
||||
rowHeight = Math.max(rowHeight, fieldHeight);
|
||||
});
|
||||
|
||||
rowTop -= rowHeight;
|
||||
if (rowIndex < summaryRows.length - 1) {
|
||||
rowTop -= rowSpacing;
|
||||
}
|
||||
});
|
||||
|
||||
const rightContentHeight = summaryTop - rowTop;
|
||||
|
||||
const thumbX = marginX;
|
||||
const thumbTop = summaryTop;
|
||||
|
||||
if (entry.thumbnailUrl) {
|
||||
const thumbnail = await loadThumbnail(entry.thumbnailUrl);
|
||||
if (thumbnail?.image) {
|
||||
page.drawRectangle({
|
||||
x: thumbX,
|
||||
y: thumbTop - thumbnailHeight,
|
||||
width: thumbnailWidth,
|
||||
height: thumbnailHeight,
|
||||
color: colorPalette.boxBackground,
|
||||
borderColor: colorPalette.boxBorder,
|
||||
borderWidth: 1,
|
||||
});
|
||||
drawThumbnailImage(page, thumbnail.image, thumbX, thumbTop, thumbnailWidth, thumbnailHeight);
|
||||
} else {
|
||||
drawThumbnailPlaceholder(page, fontBold, thumbX, thumbTop, thumbnailWidth, thumbnailHeight);
|
||||
}
|
||||
} else {
|
||||
drawThumbnailPlaceholder(page, fontBold, thumbX, thumbTop, thumbnailWidth, thumbnailHeight);
|
||||
}
|
||||
|
||||
const summarySectionHeight = Math.max(thumbnailHeight, rightContentHeight);
|
||||
|
||||
return summaryTop - summarySectionHeight - 32;
|
||||
};
|
||||
@@ -1,55 +0,0 @@
|
||||
import { PDFFont, PDFPage, PDFImage } from 'pdf-lib';
|
||||
import { colorPalette } from '../utils/pdfPalette';
|
||||
|
||||
export const drawThumbnailPlaceholder = (
|
||||
page: PDFPage,
|
||||
fontBold: PDFFont,
|
||||
x: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
page.drawRectangle({
|
||||
x,
|
||||
y: top - height,
|
||||
width,
|
||||
height,
|
||||
color: colorPalette.boxBackground,
|
||||
borderColor: colorPalette.boxBorder,
|
||||
borderWidth: 1,
|
||||
});
|
||||
|
||||
const label = 'PDF';
|
||||
const labelSize = 22;
|
||||
const labelWidth = fontBold.widthOfTextAtSize(label, labelSize);
|
||||
const labelX = x + (width - labelWidth) / 2;
|
||||
const labelY = top - height / 2 - labelSize / 2;
|
||||
|
||||
page.drawText(label, {
|
||||
x: labelX,
|
||||
y: labelY,
|
||||
size: labelSize,
|
||||
font: fontBold,
|
||||
color: colorPalette.textMuted,
|
||||
});
|
||||
};
|
||||
|
||||
export const drawThumbnailImage = (
|
||||
page: PDFPage,
|
||||
image: PDFImage,
|
||||
x: number,
|
||||
top: number,
|
||||
width: number,
|
||||
height: number
|
||||
) => {
|
||||
const scaled = image.scaleToFit(width - 16, height - 16);
|
||||
const offsetX = x + (width - scaled.width) / 2;
|
||||
const offsetY = top - (height - scaled.height) / 2 - scaled.height;
|
||||
|
||||
page.drawImage(image, {
|
||||
x: offsetX,
|
||||
y: offsetY,
|
||||
width: scaled.width,
|
||||
height: scaled.height,
|
||||
});
|
||||
};
|
||||
@@ -1,139 +0,0 @@
|
||||
import { PDFDocument, PDFPage, StandardFonts } from 'pdf-lib';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { SignatureValidationReportEntry } from '../../../types/validateSignature';
|
||||
import { REPORT_PDF_FILENAME } from './utils/signatureUtils';
|
||||
import { colorPalette } from './utils/pdfPalette';
|
||||
import { startReportPage, createThumbnailLoader } from './utils/pdfPageHelpers';
|
||||
import { deriveEntryStatus } from './utils/reportStatus';
|
||||
import { drawCenteredMessage } from './outputtedPDFSections/CenteredMessageSection';
|
||||
import { drawSummarySection } from './outputtedPDFSections/SummarySection';
|
||||
import { drawSignatureSection } from './outputtedPDFSections/SignatureSection';
|
||||
|
||||
const PAGE_WIDTH = 612;
|
||||
const PAGE_HEIGHT = 792;
|
||||
const MARGIN_X = 52;
|
||||
const MARGIN_Y = 22;
|
||||
const CONTENT_WIDTH = PAGE_WIDTH - MARGIN_X * 2;
|
||||
const COLUMN_GAP = 18;
|
||||
|
||||
const drawDivider = (page: PDFPage, marginX: number, contentWidth: number, y: number) => {
|
||||
page.drawLine({
|
||||
start: { x: marginX, y },
|
||||
end: { x: marginX + contentWidth, y },
|
||||
thickness: 1,
|
||||
color: colorPalette.boxBorder,
|
||||
});
|
||||
};
|
||||
|
||||
export const createReportPdf = async (
|
||||
entries: SignatureValidationReportEntry[],
|
||||
t: TFunction<'translation'>
|
||||
): Promise<File> => {
|
||||
const doc = await PDFDocument.create();
|
||||
const font = await doc.embedFont(StandardFonts.Helvetica);
|
||||
const fontBold = await doc.embedFont(StandardFonts.HelveticaBold);
|
||||
const loadThumbnail = createThumbnailLoader(doc);
|
||||
|
||||
for (const entry of entries) {
|
||||
const { text: statusText, color: statusColor } = deriveEntryStatus(entry, t);
|
||||
|
||||
let { page, cursorY } = startReportPage({
|
||||
doc,
|
||||
font,
|
||||
fontBold,
|
||||
marginX: MARGIN_X,
|
||||
marginY: MARGIN_Y,
|
||||
contentWidth: CONTENT_WIDTH,
|
||||
pageWidth: PAGE_WIDTH,
|
||||
pageHeight: PAGE_HEIGHT,
|
||||
title: entry.fileName,
|
||||
isContinuation: false,
|
||||
t,
|
||||
});
|
||||
|
||||
cursorY = await drawSummarySection({
|
||||
page,
|
||||
cursorY,
|
||||
entry,
|
||||
font,
|
||||
fontBold,
|
||||
marginX: MARGIN_X,
|
||||
contentWidth: CONTENT_WIDTH,
|
||||
columnGap: COLUMN_GAP,
|
||||
statusText,
|
||||
statusColor,
|
||||
loadThumbnail,
|
||||
t,
|
||||
});
|
||||
|
||||
cursorY -= 12;
|
||||
drawDivider(page, MARGIN_X, CONTENT_WIDTH, cursorY);
|
||||
cursorY -= 16;
|
||||
|
||||
if (entry.error) {
|
||||
cursorY = drawCenteredMessage({
|
||||
page,
|
||||
font,
|
||||
fontBold,
|
||||
text: t('validateSignature.status.invalid', 'Invalid'),
|
||||
description: entry.error,
|
||||
marginX: MARGIN_X,
|
||||
contentWidth: CONTENT_WIDTH,
|
||||
cursorY,
|
||||
badgeColor: colorPalette.danger,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.signatures.length === 0) {
|
||||
cursorY = drawCenteredMessage({
|
||||
page,
|
||||
font,
|
||||
fontBold,
|
||||
text: t('validateSignature.noSignaturesShort', 'No signatures'),
|
||||
description: t('validateSignature.noSignatures', 'No digital signatures found in this document'),
|
||||
marginX: MARGIN_X,
|
||||
contentWidth: CONTENT_WIDTH,
|
||||
cursorY,
|
||||
badgeColor: colorPalette.neutral,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let i = 0; i < entry.signatures.length; i += 1) {
|
||||
// After the first signature, start a new page per signature
|
||||
if (i > 0) {
|
||||
({ page, cursorY } = startReportPage({
|
||||
doc,
|
||||
font,
|
||||
fontBold,
|
||||
marginX: MARGIN_X,
|
||||
marginY: MARGIN_Y,
|
||||
contentWidth: CONTENT_WIDTH,
|
||||
pageWidth: PAGE_WIDTH,
|
||||
pageHeight: PAGE_HEIGHT,
|
||||
title: entry.fileName,
|
||||
isContinuation: true,
|
||||
t,
|
||||
}));
|
||||
}
|
||||
|
||||
cursorY = drawSignatureSection({
|
||||
page,
|
||||
cursorY,
|
||||
signature: entry.signatures[i],
|
||||
index: i,
|
||||
marginX: MARGIN_X,
|
||||
contentWidth: CONTENT_WIDTH,
|
||||
columnGap: COLUMN_GAP,
|
||||
font,
|
||||
fontBold,
|
||||
t,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const pdfBytes = await doc.save();
|
||||
const copy = pdfBytes.slice();
|
||||
return new File([copy.buffer], REPORT_PDF_FILENAME, { type: 'application/pdf' });
|
||||
};
|
||||
@@ -1,231 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import apiClient from '../../../services/apiClient';
|
||||
import { useFileContext } from '../../../contexts/file/fileHooks';
|
||||
import { ToolOperationHook } from '../shared/useToolOperation';
|
||||
import type { StirlingFile } from '../../../types/fileContext';
|
||||
import { extractErrorMessage } from '../../../utils/toolErrorHandler';
|
||||
import {
|
||||
SignatureValidationBackendResult,
|
||||
SignatureValidationFileResult,
|
||||
SignatureValidationReportEntry,
|
||||
} from '../../../types/validateSignature';
|
||||
import { ValidateSignatureParameters } from './useValidateSignatureParameters';
|
||||
import { buildReportEntries } from './utils/signatureReportBuilder';
|
||||
import { createReportPdf } from './signatureReportPdf';
|
||||
import { createCsvFile as buildCsvFile } from './utils/signatureCsv';
|
||||
import { normalizeBackendResult, RESULT_JSON_FILENAME } from './utils/signatureUtils';
|
||||
|
||||
export interface ValidateSignatureOperationHook extends ToolOperationHook<ValidateSignatureParameters> {
|
||||
results: SignatureValidationReportEntry[];
|
||||
}
|
||||
|
||||
export const useValidateSignatureOperation = (): ValidateSignatureOperationHook => {
|
||||
const { t } = useTranslation();
|
||||
const { selectors } = useFileContext();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [status, setStatus] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const [downloadUrl, setDownloadUrl] = useState<string | null>(null);
|
||||
const [downloadFilename, setDownloadFilename] = useState('');
|
||||
const [results, setResults] = useState<SignatureValidationReportEntry[]>([]);
|
||||
|
||||
const cancelRequested = useRef(false);
|
||||
const previousUrl = useRef<string | null>(null);
|
||||
|
||||
const cleanupDownloadUrl = useCallback(() => {
|
||||
if (previousUrl.current) {
|
||||
URL.revokeObjectURL(previousUrl.current);
|
||||
previousUrl.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resetResults = useCallback(() => {
|
||||
cancelRequested.current = false;
|
||||
setResults([]);
|
||||
setFiles([]);
|
||||
cleanupDownloadUrl();
|
||||
setDownloadUrl(null);
|
||||
setDownloadFilename('');
|
||||
setStatus('');
|
||||
setErrorMessage(null);
|
||||
}, [cleanupDownloadUrl]);
|
||||
|
||||
const clearError = useCallback(() => {
|
||||
setErrorMessage(null);
|
||||
}, []);
|
||||
|
||||
const executeOperation = useCallback(
|
||||
async (params: ValidateSignatureParameters, selectedFiles: StirlingFile[]) => {
|
||||
if (selectedFiles.length === 0) {
|
||||
setErrorMessage(t('noFileSelected', 'No files selected'));
|
||||
return;
|
||||
}
|
||||
|
||||
cancelRequested.current = false;
|
||||
setIsLoading(true);
|
||||
setStatus(t('validateSignature.processing', 'Validating signatures...'));
|
||||
setErrorMessage(null);
|
||||
setResults([]);
|
||||
setFiles([]);
|
||||
cleanupDownloadUrl();
|
||||
setDownloadUrl(null);
|
||||
setDownloadFilename('');
|
||||
|
||||
try {
|
||||
const aggregated: SignatureValidationFileResult[] = [];
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
if (cancelRequested.current) {
|
||||
break;
|
||||
}
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('fileInput', file);
|
||||
if (params.certFile) {
|
||||
formData.append('certFile', params.certFile);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/api/v1/security/validate-signature', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
});
|
||||
|
||||
const data = Array.isArray(response.data)
|
||||
? (response.data as SignatureValidationBackendResult[])
|
||||
: [];
|
||||
const signatures = data.map((item, index) => normalizeBackendResult(item, file, index));
|
||||
|
||||
aggregated.push({
|
||||
fileId: file.fileId,
|
||||
fileName: file.name,
|
||||
signatures,
|
||||
error: null,
|
||||
fileSize: file.size ?? null,
|
||||
lastModified: file.lastModified ?? null,
|
||||
});
|
||||
} catch (error) {
|
||||
aggregated.push({
|
||||
fileId: file.fileId,
|
||||
fileName: file.name,
|
||||
signatures: [],
|
||||
error: extractErrorMessage(error),
|
||||
fileSize: file.size ?? null,
|
||||
lastModified: file.lastModified ?? null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelRequested.current) {
|
||||
const summaryTimestamp = Date.now();
|
||||
const enrichedEntries = buildReportEntries({
|
||||
results: aggregated,
|
||||
selectors,
|
||||
generatedAt: summaryTimestamp,
|
||||
t,
|
||||
});
|
||||
|
||||
setResults(enrichedEntries);
|
||||
|
||||
if (enrichedEntries.length > 0) {
|
||||
const json = JSON.stringify(enrichedEntries, null, 2);
|
||||
const resultFile = new File([json], RESULT_JSON_FILENAME, { type: 'application/json' });
|
||||
const csvFile = buildCsvFile(enrichedEntries);
|
||||
|
||||
setFiles([resultFile, csvFile]);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const pdfFile = await createReportPdf(enrichedEntries, t);
|
||||
setFiles((prev) => [pdfFile, ...prev.filter((f) => !f.name.toLowerCase().endsWith('.pdf'))]);
|
||||
setDownloadFilename(pdfFile.name);
|
||||
cleanupDownloadUrl();
|
||||
const blobUrl = URL.createObjectURL(pdfFile);
|
||||
previousUrl.current = blobUrl;
|
||||
setDownloadUrl(blobUrl);
|
||||
} catch (err) {
|
||||
console.warn('[validateSignature] PDF report generation failed', err);
|
||||
setErrorMessage((prev) =>
|
||||
prev ??
|
||||
t(
|
||||
'validateSignature.error.reportGeneration',
|
||||
'Could not generate the PDF report. JSON and CSV are available.'
|
||||
)
|
||||
);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
const anyError = aggregated.some((item) => item.error);
|
||||
const anySuccess = aggregated.some((item) => item.signatures.length > 0);
|
||||
|
||||
if (anyError && !anySuccess) {
|
||||
setErrorMessage(t('validateSignature.error.allFailed', 'Unable to validate the selected files.'));
|
||||
} else if (anyError) {
|
||||
setErrorMessage(t('validateSignature.error.partial', 'Some files could not be validated.'));
|
||||
}
|
||||
|
||||
setStatus(t('validateSignature.status.complete', 'Validation complete'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[validateSignature] unexpected failure', e);
|
||||
setErrorMessage(t('validateSignature.error.unexpected', 'Unexpected error during validation.'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[cleanupDownloadUrl, selectors, t]
|
||||
);
|
||||
|
||||
const cancelOperation = useCallback(() => {
|
||||
if (isLoading) {
|
||||
cancelRequested.current = true;
|
||||
setIsLoading(false);
|
||||
setStatus(t('operationCancelled', 'Operation cancelled'));
|
||||
}
|
||||
}, [isLoading, t]);
|
||||
|
||||
const undoOperation = useCallback(async () => {
|
||||
resetResults();
|
||||
}, [resetResults]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
cleanupDownloadUrl();
|
||||
};
|
||||
}, [cleanupDownloadUrl]);
|
||||
|
||||
return useMemo<ValidateSignatureOperationHook>(
|
||||
() => ({
|
||||
files,
|
||||
thumbnails: [],
|
||||
isGeneratingThumbnails: false,
|
||||
downloadUrl,
|
||||
downloadFilename,
|
||||
isLoading,
|
||||
status,
|
||||
errorMessage,
|
||||
progress: null,
|
||||
executeOperation,
|
||||
resetResults,
|
||||
clearError,
|
||||
cancelOperation,
|
||||
undoOperation,
|
||||
results,
|
||||
}),
|
||||
[
|
||||
cancelOperation,
|
||||
clearError,
|
||||
downloadFilename,
|
||||
downloadUrl,
|
||||
errorMessage,
|
||||
executeOperation,
|
||||
files,
|
||||
isLoading,
|
||||
resetResults,
|
||||
results,
|
||||
status,
|
||||
]
|
||||
);
|
||||
};
|
||||
@@ -1,18 +0,0 @@
|
||||
import { useBaseParameters, BaseParametersHook } from '../shared/useBaseParameters';
|
||||
|
||||
export interface ValidateSignatureParameters {
|
||||
certFile: File | null;
|
||||
}
|
||||
|
||||
export const defaultParameters: ValidateSignatureParameters = {
|
||||
certFile: null,
|
||||
};
|
||||
|
||||
export type ValidateSignatureParametersHook = BaseParametersHook<ValidateSignatureParameters>;
|
||||
|
||||
export const useValidateSignatureParameters = (): ValidateSignatureParametersHook => {
|
||||
return useBaseParameters({
|
||||
defaultParameters,
|
||||
endpointName: 'validate-signature',
|
||||
});
|
||||
};
|
||||
@@ -1,101 +0,0 @@
|
||||
import { PDFDocument, PDFFont, PDFImage } from 'pdf-lib';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { colorPalette } from './pdfPalette';
|
||||
|
||||
interface StartPageParams {
|
||||
doc: PDFDocument;
|
||||
font: PDFFont;
|
||||
fontBold: PDFFont;
|
||||
marginX: number;
|
||||
marginY: number;
|
||||
contentWidth: number;
|
||||
pageWidth: number;
|
||||
pageHeight: number;
|
||||
title: string;
|
||||
isContinuation: boolean;
|
||||
t: TFunction<'translation'>;
|
||||
}
|
||||
|
||||
export const startReportPage = ({
|
||||
doc,
|
||||
font,
|
||||
fontBold,
|
||||
marginX,
|
||||
marginY,
|
||||
pageWidth,
|
||||
pageHeight,
|
||||
title,
|
||||
isContinuation,
|
||||
t,
|
||||
}: StartPageParams) => {
|
||||
const page = doc.addPage([pageWidth, pageHeight]);
|
||||
let cursorY = pageHeight - marginY;
|
||||
|
||||
if (isContinuation) {
|
||||
const heading = `${title} - ${t('validateSignature.report.continued', 'Continued')}`;
|
||||
page.drawText(heading, {
|
||||
x: marginX,
|
||||
y: cursorY - 18,
|
||||
size: 12,
|
||||
font: fontBold,
|
||||
color: colorPalette.textMuted,
|
||||
});
|
||||
cursorY -= 36;
|
||||
}
|
||||
|
||||
const pageNumber = doc.getPageCount();
|
||||
page.drawText(`${t('validateSignature.report.page', 'Page')} ${pageNumber}`, {
|
||||
x: pageWidth - marginX - 80,
|
||||
y: marginY / 2,
|
||||
size: 9,
|
||||
font,
|
||||
color: colorPalette.textMuted,
|
||||
});
|
||||
|
||||
page.drawText(t('validateSignature.report.footer', 'Validated via Stirling PDF'), {
|
||||
x: marginX,
|
||||
y: marginY / 2,
|
||||
size: 9,
|
||||
font,
|
||||
color: colorPalette.textMuted,
|
||||
});
|
||||
|
||||
return { page, cursorY };
|
||||
};
|
||||
|
||||
export const createThumbnailLoader = (doc: PDFDocument) => {
|
||||
const cache = new Map<string, { image: PDFImage } | null>();
|
||||
|
||||
return async (url: string) => {
|
||||
if (cache.has(url)) {
|
||||
return cache.get(url) ?? null;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
let image: PDFImage;
|
||||
|
||||
if (contentType.includes('png')) {
|
||||
image = await doc.embedPng(bytes);
|
||||
} else if (contentType.includes('jpeg') || contentType.includes('jpg')) {
|
||||
image = await doc.embedJpg(bytes);
|
||||
} else {
|
||||
try {
|
||||
image = await doc.embedPng(bytes);
|
||||
} catch {
|
||||
image = await doc.embedJpg(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
const result = { image };
|
||||
cache.set(url, result);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.warn('[validateSignature] Failed to load thumbnail', error);
|
||||
cache.set(url, null);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -1,60 +0,0 @@
|
||||
import { rgb } from 'pdf-lib';
|
||||
|
||||
type RgbTuple = [number, number, number];
|
||||
|
||||
const defaultLightPalette: Record<
|
||||
'headerBackground' | 'accent' | 'textPrimary' | 'textMuted' | 'boxBackground' | 'boxBorder' | 'warning' | 'danger' | 'success' | 'neutral',
|
||||
RgbTuple
|
||||
> = {
|
||||
headerBackground: [239, 246, 255],
|
||||
accent: [59, 130, 246],
|
||||
textPrimary: [30, 41, 59],
|
||||
textMuted: [100, 116, 139],
|
||||
boxBackground: [248, 250, 252],
|
||||
boxBorder: [226, 232, 240],
|
||||
warning: [234, 179, 8],
|
||||
danger: [248, 113, 113],
|
||||
success: [34, 197, 94],
|
||||
neutral: [148, 163, 184],
|
||||
};
|
||||
|
||||
const toRgb = ([r, g, b]: RgbTuple) => rgb(r / 255, g / 255, b / 255);
|
||||
|
||||
/**
|
||||
* Utility function to get CSS variable values and convert them to pdf-lib RGB format.
|
||||
* Falls back to sensible defaults when the CSS variable cannot be resolved.
|
||||
*/
|
||||
function getCssVariableAsRgb(variableName: string, fallback: RgbTuple) {
|
||||
if (typeof window === 'undefined') {
|
||||
return toRgb(fallback);
|
||||
}
|
||||
|
||||
const value = getComputedStyle(document.documentElement).getPropertyValue(variableName).trim();
|
||||
|
||||
if (!value) {
|
||||
console.warn(`CSS variable ${variableName} not found, using fallback`);
|
||||
return toRgb(fallback);
|
||||
}
|
||||
|
||||
const [r, g, b] = value.split(' ').map(Number);
|
||||
|
||||
if ([r, g, b].some((component) => Number.isNaN(component))) {
|
||||
console.warn(`Invalid CSS variable format for ${variableName}: ${value}`);
|
||||
return toRgb(fallback);
|
||||
}
|
||||
|
||||
return rgb(r / 255, g / 255, b / 255);
|
||||
}
|
||||
|
||||
export const colorPalette = {
|
||||
headerBackground: getCssVariableAsRgb('--pdf-light-header-bg', defaultLightPalette.headerBackground),
|
||||
accent: getCssVariableAsRgb('--pdf-light-accent', defaultLightPalette.accent),
|
||||
textPrimary: getCssVariableAsRgb('--pdf-light-text-primary', defaultLightPalette.textPrimary),
|
||||
textMuted: getCssVariableAsRgb('--pdf-light-text-muted', defaultLightPalette.textMuted),
|
||||
boxBackground: getCssVariableAsRgb('--pdf-light-box-bg', defaultLightPalette.boxBackground),
|
||||
boxBorder: getCssVariableAsRgb('--pdf-light-box-border', defaultLightPalette.boxBorder),
|
||||
warning: getCssVariableAsRgb('--pdf-light-warning', defaultLightPalette.warning),
|
||||
danger: getCssVariableAsRgb('--pdf-light-danger', defaultLightPalette.danger),
|
||||
success: getCssVariableAsRgb('--pdf-light-success', defaultLightPalette.success),
|
||||
neutral: getCssVariableAsRgb('--pdf-light-neutral', defaultLightPalette.neutral),
|
||||
};
|
||||
@@ -1,51 +0,0 @@
|
||||
import { PDFFont } from 'pdf-lib';
|
||||
|
||||
export const wrapText = (text: string, font: PDFFont, fontSize: number, maxWidth: number): string[] => {
|
||||
const lines: string[] = [];
|
||||
const paragraphs = text.split(/\r?\n/);
|
||||
|
||||
paragraphs.forEach((paragraph) => {
|
||||
const trimmed = paragraph.trim();
|
||||
if (trimmed.length === 0) {
|
||||
lines.push('');
|
||||
return;
|
||||
}
|
||||
|
||||
const words = trimmed.split(/\s+/);
|
||||
let currentLine = '';
|
||||
words.forEach((word) => {
|
||||
const tentative = currentLine.length > 0 ? `${currentLine} ${word}` : word;
|
||||
const width = font.widthOfTextAtSize(tentative, fontSize);
|
||||
if (width <= maxWidth) {
|
||||
currentLine = tentative;
|
||||
} else {
|
||||
if (currentLine.length > 0) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
currentLine = word;
|
||||
}
|
||||
});
|
||||
if (currentLine.length > 0) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
});
|
||||
|
||||
return lines;
|
||||
};
|
||||
|
||||
export const formatFileSize = (bytes?: number | null) => {
|
||||
if (!bytes || bytes <= 0) return '--';
|
||||
const units = ['B', 'KB', 'MB', 'GB'];
|
||||
const exponent = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||
const size = bytes / Math.pow(1024, exponent);
|
||||
return `${size.toFixed(exponent === 0 ? 0 : 1)} ${units[exponent]}`;
|
||||
};
|
||||
|
||||
export const formatDate = (value?: string | null) => {
|
||||
if (!value) return '--';
|
||||
const parsed = Date.parse(value);
|
||||
if (!Number.isNaN(parsed)) {
|
||||
return new Date(parsed).toLocaleString();
|
||||
}
|
||||
return value;
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { SignatureValidationReportEntry } from '../../../../types/validateSignature';
|
||||
import { colorPalette } from './pdfPalette';
|
||||
|
||||
export const deriveEntryStatus = (
|
||||
entry: Pick<SignatureValidationReportEntry, 'error' | 'signatures'>,
|
||||
t: TFunction<'translation'>
|
||||
) => {
|
||||
if (entry.error) {
|
||||
return {
|
||||
text: t('validateSignature.status.invalid', 'Invalid'),
|
||||
color: colorPalette.danger,
|
||||
};
|
||||
}
|
||||
|
||||
if (entry.signatures.length === 0) {
|
||||
return {
|
||||
text: t('validateSignature.noSignaturesShort', 'No signatures'),
|
||||
color: colorPalette.neutral,
|
||||
};
|
||||
}
|
||||
|
||||
const allValid = entry.signatures.every(
|
||||
(sig) => sig.valid && sig.chainValid && sig.trustValid && sig.notExpired && sig.notRevoked
|
||||
);
|
||||
|
||||
if (allValid) {
|
||||
return {
|
||||
text: t('validateSignature.status.valid', 'Valid'),
|
||||
color: colorPalette.success,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text: t('validateSignature.status.reviewMissingFields', 'Needs Attention: Missing Fields'),
|
||||
color: colorPalette.warning,
|
||||
};
|
||||
};
|
||||
@@ -1,95 +0,0 @@
|
||||
import { SignatureValidationReportEntry } from '../../../../types/validateSignature';
|
||||
import { CSV_FILENAME, booleanToString, escapeCsvValue, keyUsagesToString } from './signatureUtils';
|
||||
|
||||
const buildCsvRows = (entries: SignatureValidationReportEntry[]): string[][] => {
|
||||
const headers = [
|
||||
'fileName',
|
||||
'signatureIndex',
|
||||
'valid',
|
||||
'chainValid',
|
||||
'trustValid',
|
||||
'notExpired',
|
||||
'notRevoked',
|
||||
'signerName',
|
||||
'signatureDate',
|
||||
'reason',
|
||||
'location',
|
||||
'issuerDN',
|
||||
'subjectDN',
|
||||
'serialNumber',
|
||||
'validFrom',
|
||||
'validUntil',
|
||||
'signatureAlgorithm',
|
||||
'keySize',
|
||||
'version',
|
||||
'keyUsages',
|
||||
'selfSigned',
|
||||
'errorMessage'
|
||||
];
|
||||
|
||||
const rows: string[][] = [headers];
|
||||
|
||||
entries.forEach((fileResult) => {
|
||||
if (fileResult.signatures.length > 0) {
|
||||
fileResult.signatures.forEach((signature, index) => {
|
||||
rows.push([
|
||||
fileResult.fileName,
|
||||
String(index + 1),
|
||||
booleanToString(signature.valid),
|
||||
booleanToString(signature.chainValid),
|
||||
booleanToString(signature.trustValid),
|
||||
booleanToString(signature.notExpired),
|
||||
booleanToString(signature.notRevoked),
|
||||
signature.signerName || '',
|
||||
signature.signatureDate || '',
|
||||
signature.reason || '',
|
||||
signature.location || '',
|
||||
signature.issuerDN || '',
|
||||
signature.subjectDN || '',
|
||||
signature.serialNumber || '',
|
||||
signature.validFrom || '',
|
||||
signature.validUntil || '',
|
||||
signature.signatureAlgorithm || '',
|
||||
signature.keySize !== null && signature.keySize !== undefined ? String(signature.keySize) : '',
|
||||
signature.version || '',
|
||||
keyUsagesToString(signature.keyUsages),
|
||||
booleanToString(signature.selfSigned),
|
||||
signature.errorMessage || fileResult.error || ''
|
||||
]);
|
||||
});
|
||||
} else {
|
||||
rows.push([
|
||||
fileResult.fileName,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
fileResult.error || ''
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
export const createCsvFile = (entries: SignatureValidationReportEntry[]): File => {
|
||||
const rows = buildCsvRows(entries);
|
||||
const csv = rows.map((row) => row.map(escapeCsvValue).join(',')).join('\r\n');
|
||||
return new File([csv], CSV_FILENAME, { type: 'text/csv;charset=utf-8;' });
|
||||
};
|
||||
@@ -1,46 +0,0 @@
|
||||
import { SignatureValidationFileResult, SignatureValidationReportEntry } from '../../../../types/validateSignature';
|
||||
import { FileContextSelectors } from '../../../../types/fileContext';
|
||||
import type { FileId } from '../../../../types/file';
|
||||
import type { TFunction } from 'i18next';
|
||||
import { deriveEntryStatus } from './reportStatus';
|
||||
|
||||
interface BuildReportEntriesOptions {
|
||||
results: SignatureValidationFileResult[];
|
||||
selectors: FileContextSelectors;
|
||||
generatedAt: number;
|
||||
t?: TFunction<'translation'>;
|
||||
}
|
||||
|
||||
export const buildReportEntries = ({
|
||||
results,
|
||||
selectors,
|
||||
generatedAt,
|
||||
t,
|
||||
}: BuildReportEntriesOptions): SignatureValidationReportEntry[] => {
|
||||
return results.map((entry) => {
|
||||
const fileId = entry.fileId as FileId;
|
||||
const stub = selectors.getStirlingFileStub(fileId);
|
||||
const file = selectors.getFile(fileId);
|
||||
|
||||
let createdAtLabel: string | null = null;
|
||||
const createdTimestamp = stub?.createdAt ?? null;
|
||||
if (createdTimestamp) {
|
||||
createdAtLabel = new Date(createdTimestamp).toLocaleString();
|
||||
}
|
||||
|
||||
const fileSize = file?.size ?? stub?.size ?? entry.fileSize ?? null;
|
||||
const lastModified = file?.lastModified ?? stub?.lastModified ?? entry.lastModified ?? null;
|
||||
|
||||
const statusMeta = t ? deriveEntryStatus(entry, t) : null;
|
||||
|
||||
return {
|
||||
...entry,
|
||||
thumbnailUrl: stub?.thumbnailUrl ?? null,
|
||||
fileSize,
|
||||
lastModified,
|
||||
createdAtLabel,
|
||||
summaryGeneratedAt: generatedAt,
|
||||
statusText: statusMeta?.text ?? null,
|
||||
};
|
||||
});
|
||||
};
|
||||
@@ -1,120 +0,0 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { SignatureValidationSignature } from '../../../../types/validateSignature';
|
||||
import { colorPalette } from './pdfPalette';
|
||||
|
||||
export type SignatureStatusKind = 'valid' | 'warning' | 'invalid' | 'neutral';
|
||||
|
||||
export interface SignatureStatus {
|
||||
kind: SignatureStatusKind;
|
||||
label: string;
|
||||
details: string[];
|
||||
}
|
||||
|
||||
export const computeSignatureStatus = (
|
||||
signature: SignatureValidationSignature,
|
||||
t: TFunction<'translation'>
|
||||
): SignatureStatus => {
|
||||
// Start with error
|
||||
if (signature.errorMessage) {
|
||||
return {
|
||||
kind: 'invalid',
|
||||
label: t('validateSignature.status.invalid', 'Invalid'),
|
||||
details: [signature.errorMessage],
|
||||
};
|
||||
}
|
||||
|
||||
const issues: string[] = [];
|
||||
const trustIssues: string[] = [];
|
||||
|
||||
if (!signature.valid) {
|
||||
issues.push(t('validateSignature.issue.signatureInvalid', 'Signature cryptographic check failed'));
|
||||
}
|
||||
if (!signature.chainValid) {
|
||||
trustIssues.push(t('validateSignature.issue.chainInvalid', 'Certificate chain invalid'));
|
||||
}
|
||||
if (!signature.trustValid) {
|
||||
trustIssues.push(t('validateSignature.issue.trustInvalid', 'Certificate not trusted'));
|
||||
}
|
||||
if (!signature.notExpired) {
|
||||
trustIssues.push(t('validateSignature.issue.certExpired', 'Certificate expired'));
|
||||
}
|
||||
|
||||
// Use new revocationStatus field if available, fallback to notRevoked for backward compatibility
|
||||
const revStatus = signature.revocationStatus || (signature.notRevoked ? 'good' : 'unknown');
|
||||
if (revStatus === 'revoked') {
|
||||
trustIssues.push(t('validateSignature.issue.certRevoked', 'Certificate revoked'));
|
||||
} else if (revStatus === 'soft-fail') {
|
||||
trustIssues.push(t('validateSignature.issue.certRevocationUnknown', 'Certificate revocation status unknown'));
|
||||
}
|
||||
// Don't report anything for 'not-checked', 'good', or 'unknown' unless actually revoked
|
||||
|
||||
// Check for missing common metadata fields
|
||||
const missing: string[] = [];
|
||||
if (!signature.signerName || signature.signerName.trim().length === 0) missing.push(t('validateSignature.signer', 'Signer'));
|
||||
if (!signature.reason || signature.reason.trim().length === 0) missing.push(t('validateSignature.reason', 'Reason'));
|
||||
if (!signature.location || signature.location.trim().length === 0) missing.push(t('validateSignature.location', 'Location'));
|
||||
|
||||
// Aggregate all issues for details UI
|
||||
issues.push(...trustIssues);
|
||||
if (missing.length > 0) {
|
||||
issues.push(t('validateSignature.issue.missingFields', 'Missing fields') + `: ${missing.join(', ')}`);
|
||||
}
|
||||
|
||||
if (issues.length === 0) {
|
||||
return {
|
||||
kind: 'valid',
|
||||
label: t('validateSignature.status.validFull', 'Fully Valid'),
|
||||
details: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Invalid ONLY when cryptographic signature itself failed or an explicit backend error occurred
|
||||
if (!signature.valid) {
|
||||
return {
|
||||
kind: 'invalid',
|
||||
label: t('validateSignature.status.invalid', 'Invalid'),
|
||||
details: issues,
|
||||
};
|
||||
}
|
||||
|
||||
// Otherwise, it's a signed document with issues
|
||||
const onlyMissing = missing.length > 0 && trustIssues.length === 0;
|
||||
const onlyTrust = missing.length === 0 && trustIssues.length > 0;
|
||||
|
||||
if (onlyMissing) {
|
||||
return {
|
||||
kind: 'warning',
|
||||
label: t('validateSignature.status.missingFields', 'Needs Attention: Missing Fields'),
|
||||
details: issues,
|
||||
};
|
||||
}
|
||||
|
||||
if (onlyTrust) {
|
||||
return {
|
||||
kind: 'warning',
|
||||
label: t('validateSignature.status.trustIssues', 'Needs Attention: Trust/Chain'),
|
||||
details: issues,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'warning',
|
||||
label: t('validateSignature.status.needsAttention', 'Needs Attention'),
|
||||
details: issues,
|
||||
};
|
||||
};
|
||||
|
||||
export const statusKindToPdfColor = (kind: SignatureStatusKind) => {
|
||||
switch (kind) {
|
||||
case 'valid':
|
||||
return colorPalette.success;
|
||||
case 'warning':
|
||||
return colorPalette.warning;
|
||||
case 'invalid':
|
||||
return colorPalette.danger;
|
||||
default:
|
||||
return colorPalette.neutral;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
import { SignatureValidationBackendResult, SignatureValidationSignature } from '../../../../types/validateSignature';
|
||||
import type { StirlingFile } from '../../../../types/fileContext';
|
||||
|
||||
export const RESULT_JSON_FILENAME = 'signature-validation.json';
|
||||
export const CSV_FILENAME = 'signature-validation.csv';
|
||||
export const REPORT_PDF_FILENAME = 'signature-validation-report.pdf';
|
||||
|
||||
export const coerceString = (value: string | number | null | undefined): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
export const coerceNumber = (value: number | string | null | undefined): number | null => {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const parsed = parseInt(value, 10);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const escapeCsvValue = (raw: string): string => {
|
||||
let value = raw ?? '';
|
||||
value = value.replace(/\r?\n|\r/g, ' ');
|
||||
if (value.includes('"')) {
|
||||
value = value.replace(/"/g, '""');
|
||||
}
|
||||
if (value.includes(',') || value.includes('"') || value.includes(';')) {
|
||||
value = `"${value}"`;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const booleanToString = (value: boolean | null | undefined): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return '';
|
||||
}
|
||||
return value ? 'true' : 'false';
|
||||
};
|
||||
|
||||
export const keyUsagesToString = (keyUsages: string[] | undefined): string => {
|
||||
if (!keyUsages || keyUsages.length === 0) {
|
||||
return '';
|
||||
}
|
||||
return keyUsages.join('; ');
|
||||
};
|
||||
|
||||
export const normalizeBackendResult = (
|
||||
item: SignatureValidationBackendResult,
|
||||
stirlingFile: StirlingFile,
|
||||
index: number
|
||||
): SignatureValidationSignature => ({
|
||||
id: `${stirlingFile.fileId}-${index}`,
|
||||
valid: Boolean(item.valid),
|
||||
chainValid: Boolean(item.chainValid),
|
||||
trustValid: Boolean(item.trustValid),
|
||||
notExpired: Boolean(item.notExpired),
|
||||
signerName: coerceString(item.signerName),
|
||||
signatureDate: coerceString(item.signatureDate),
|
||||
reason: coerceString(item.reason),
|
||||
location: coerceString(item.location),
|
||||
issuerDN: coerceString(item.issuerDN),
|
||||
subjectDN: coerceString(item.subjectDN),
|
||||
serialNumber: coerceString(item.serialNumber),
|
||||
validFrom: coerceString(item.validFrom),
|
||||
validUntil: coerceString(item.validUntil),
|
||||
signatureAlgorithm: coerceString(item.signatureAlgorithm),
|
||||
keySize: coerceNumber(item.keySize),
|
||||
version: coerceString(item.version),
|
||||
keyUsages: Array.isArray(item.keyUsages) ? item.keyUsages.filter(Boolean).map(coerceString) : [],
|
||||
selfSigned: Boolean(item.selfSigned),
|
||||
errorMessage: item.errorMessage ? coerceString(item.errorMessage) : null,
|
||||
});
|
||||
@@ -52,12 +52,3 @@ code {
|
||||
color: var(--mantine-color-blue-8);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Viewer file tabs */
|
||||
.viewer-file-tab {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.viewer-file-tab[data-active] {
|
||||
background-color: rgba(147, 197, 253, 0.5);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import apiClient from './apiClient';
|
||||
import type { CancelToken } from 'axios';
|
||||
import { getFilenameFromHeaders } from '../utils/fileResponseUtils';
|
||||
|
||||
export interface JobStatus {
|
||||
jobId: string;
|
||||
complete: boolean;
|
||||
error?: string | null;
|
||||
progressPercent?: number | null;
|
||||
progressMessage?: string | null;
|
||||
inQueue?: boolean;
|
||||
queuePosition?: number | null;
|
||||
notes?: string[];
|
||||
}
|
||||
|
||||
export interface JobResultFileMeta {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
export type JobResultData =
|
||||
| { type: 'blob'; blob: Blob; headers: Record<string, any> }
|
||||
| { type: 'multipleFiles'; files: JobResultFileMeta[] }
|
||||
| { type: 'json'; data: any };
|
||||
|
||||
type FetchStatusResponse = JobStatus & {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
interface QueueInfo {
|
||||
inQueue?: boolean;
|
||||
position?: number;
|
||||
}
|
||||
|
||||
export interface JobPollOptions {
|
||||
cancelToken?: CancelToken;
|
||||
intervalMs?: number;
|
||||
isCancelled?: () => boolean;
|
||||
onUpdate?: (status: JobStatus) => void;
|
||||
}
|
||||
|
||||
const JOB_BASE_URL = '/api/v1/general/job';
|
||||
|
||||
export function ensureAsyncParam(endpoint: string): string {
|
||||
if (endpoint.includes('async=')) {
|
||||
return endpoint;
|
||||
}
|
||||
const separator = endpoint.includes('?') ? '&' : '?';
|
||||
return `${endpoint}${separator}async=true`;
|
||||
}
|
||||
|
||||
function normalizeJobStatus(data: any, queueInfo?: QueueInfo): JobStatus {
|
||||
if (!data) {
|
||||
return {
|
||||
jobId: 'unknown',
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
|
||||
const base: FetchStatusResponse = {
|
||||
jobId: data.jobId ?? data.jobID ?? data.id ?? 'unknown',
|
||||
complete: Boolean(data.complete),
|
||||
error: data.error ?? null,
|
||||
progressPercent: typeof data.progressPercent === 'number' ? data.progressPercent : undefined,
|
||||
progressMessage: data.progressMessage ?? undefined,
|
||||
notes: Array.isArray(data.notes) ? data.notes : undefined,
|
||||
inQueue: queueInfo?.inQueue,
|
||||
queuePosition: queueInfo?.position ?? null,
|
||||
};
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
export async function fetchJobStatus(jobId: string, cancelToken?: CancelToken): Promise<JobStatus> {
|
||||
const response = await apiClient.get(`${JOB_BASE_URL}/${jobId}`, { cancelToken });
|
||||
const data = response.data;
|
||||
|
||||
if (data && typeof data === 'object' && 'jobResult' in data) {
|
||||
const queue = data.queueInfo as QueueInfo | undefined;
|
||||
return normalizeJobStatus((data as any).jobResult, queue);
|
||||
}
|
||||
|
||||
return normalizeJobStatus(data);
|
||||
}
|
||||
|
||||
export async function waitForJobCompletion(jobId: string, options: JobPollOptions = {}): Promise<JobStatus> {
|
||||
const { intervalMs = 1000, onUpdate, isCancelled } = options;
|
||||
|
||||
for (;;) {
|
||||
if (isCancelled?.()) {
|
||||
throw new Error('Operation was cancelled');
|
||||
}
|
||||
|
||||
const status = await fetchJobStatus(jobId, options.cancelToken);
|
||||
onUpdate?.(status);
|
||||
|
||||
if (status.complete) {
|
||||
return status;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchJobResult(jobId: string, cancelToken?: CancelToken): Promise<JobResultData> {
|
||||
const response = await apiClient.get(`${JOB_BASE_URL}/${jobId}/result`, {
|
||||
responseType: 'blob',
|
||||
cancelToken,
|
||||
});
|
||||
|
||||
const contentType = (response.headers?.['content-type'] || '') as string;
|
||||
|
||||
if (contentType.includes('application/json')) {
|
||||
const text = await response.data.text();
|
||||
let parsed: any;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch (_error) {
|
||||
throw new Error('Failed to parse async job result JSON');
|
||||
}
|
||||
|
||||
if (parsed?.hasMultipleFiles && Array.isArray(parsed.files)) {
|
||||
return { type: 'multipleFiles', files: parsed.files as JobResultFileMeta[] };
|
||||
}
|
||||
|
||||
return { type: 'json', data: parsed };
|
||||
}
|
||||
|
||||
return { type: 'blob', blob: response.data, headers: response.headers ?? {} };
|
||||
}
|
||||
|
||||
export async function downloadResultFile(meta: JobResultFileMeta, cancelToken?: CancelToken): Promise<File> {
|
||||
const response = await apiClient.get(`/api/v1/general/files/${meta.fileId}`, {
|
||||
responseType: 'blob',
|
||||
cancelToken,
|
||||
});
|
||||
|
||||
const blob = response.data as Blob;
|
||||
const type = blob.type || response.headers?.['content-type'] || meta.contentType || 'application/octet-stream';
|
||||
const filename = meta.fileName || getFilenameFromHeaders(response.headers?.['content-disposition']) || 'download';
|
||||
|
||||
return new File([blob], filename, {
|
||||
type,
|
||||
lastModified: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
export async function readJobResponseBlob(blob: Blob): Promise<any> {
|
||||
const text = await blob.text();
|
||||
return JSON.parse(text);
|
||||
}
|
||||
@@ -261,29 +261,6 @@
|
||||
--modal-nav-item-active-bg: rgba(10, 139, 255, 0.08);
|
||||
--modal-content-bg: #ffffff;
|
||||
--modal-header-border: rgba(0, 0, 0, 0.06);
|
||||
|
||||
/* PDF Report Colors (always light) */
|
||||
--pdf-light-header-bg: 239 246 255;
|
||||
--pdf-light-accent: 59 130 246;
|
||||
--pdf-light-text-primary: 30 41 59;
|
||||
--pdf-light-text-muted: 100 116 139;
|
||||
--pdf-light-box-bg: 248 250 252;
|
||||
--pdf-light-box-border: 226 232 240;
|
||||
--pdf-light-warning: 234 179 8;
|
||||
--pdf-light-danger: 248 113 113;
|
||||
--pdf-light-success: 34 197 94;
|
||||
--pdf-light-neutral: 148 163 184;
|
||||
--pdf-light-status-valid-bg: 209 250 229;
|
||||
--pdf-light-status-valid-text: 6 95 70;
|
||||
--pdf-light-status-warning-bg: 254 243 199;
|
||||
--pdf-light-status-warning-text: 146 64 14;
|
||||
--pdf-light-status-invalid-bg: 254 226 226;
|
||||
--pdf-light-status-invalid-text: 153 27 27;
|
||||
--pdf-light-status-neutral-bg: 229 231 235;
|
||||
--pdf-light-status-neutral-text: 55 65 81;
|
||||
--pdf-light-report-container-bg: 249 250 251;
|
||||
--pdf-light-simulated-page-bg: 255 255 255;
|
||||
--pdf-light-simulated-page-text: 15 23 42;
|
||||
}
|
||||
|
||||
[data-mantine-color-scheme="dark"] {
|
||||
|
||||
+16
-13
@@ -17,7 +17,7 @@ const Sign = (props: BaseToolProps) => {
|
||||
const { setWorkbench } = useNavigation();
|
||||
const { setSignatureConfig, activateDrawMode, activateSignaturePlacementMode, deactivateDrawMode, updateDrawSettings, undo, redo, signatureApiRef, getImageData, setSignaturesApplied } = useSignature();
|
||||
const { consumeFiles, selectors } = useFileContext();
|
||||
const { exportActions, getScrollState, activeFileIndex, setActiveFileIndex } = useViewer();
|
||||
const { exportActions, getScrollState } = useViewer();
|
||||
const { setHasUnsavedChanges, unregisterUnsavedChangesChecker } = useNavigation();
|
||||
|
||||
// Track which signature mode was active for reactivation after save
|
||||
@@ -75,11 +75,19 @@ const Sign = (props: BaseToolProps) => {
|
||||
unregisterUnsavedChangesChecker();
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Get the original file from FileContext using activeFileIndex
|
||||
// The viewer displays files from FileContext, not from base.selectedFiles
|
||||
const allFiles = selectors.getFiles();
|
||||
const fileIndex = activeFileIndex < allFiles.length ? activeFileIndex : 0;
|
||||
const originalFile = allFiles[fileIndex];
|
||||
// Get the original file
|
||||
let originalFile = null;
|
||||
if (base.selectedFiles.length > 0) {
|
||||
originalFile = base.selectedFiles[0];
|
||||
} else {
|
||||
const allFileIds = selectors.getAllFileIds();
|
||||
if (allFileIds.length > 0) {
|
||||
const stirlingFile = selectors.getFile(allFileIds[0]);
|
||||
if (stirlingFile) {
|
||||
originalFile = stirlingFile;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!originalFile) {
|
||||
console.error('No file available to replace');
|
||||
@@ -93,8 +101,7 @@ const Sign = (props: BaseToolProps) => {
|
||||
exportActions,
|
||||
selectors,
|
||||
originalFile,
|
||||
getScrollState,
|
||||
activeFileIndex
|
||||
getScrollState
|
||||
});
|
||||
|
||||
if (flattenResult) {
|
||||
@@ -105,10 +112,6 @@ const Sign = (props: BaseToolProps) => {
|
||||
[flattenResult.outputStub]
|
||||
);
|
||||
|
||||
// According to FileReducer.processFileSwap, new files are inserted at the beginning
|
||||
// So the new file will be at index 0
|
||||
setActiveFileIndex(0);
|
||||
|
||||
// Mark signatures as applied
|
||||
setSignaturesApplied(true);
|
||||
|
||||
@@ -122,7 +125,7 @@ const Sign = (props: BaseToolProps) => {
|
||||
} catch (error) {
|
||||
console.error('Error saving signed document:', error);
|
||||
}
|
||||
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, setHasUnsavedChanges, unregisterUnsavedChangesChecker, activeFileIndex, setActiveFileIndex]);
|
||||
}, [exportActions, base.selectedFiles, selectors, consumeFiles, signatureApiRef, getImageData, setWorkbench, activateDrawMode, setSignaturesApplied, getScrollState, handleDeactivateSignature, setHasUnsavedChanges, unregisterUnsavedChangesChecker]);
|
||||
|
||||
const getSteps = () => {
|
||||
const steps = [];
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf';
|
||||
import { createToolFlow } from '../components/tools/shared/createToolFlow';
|
||||
import { useBaseTool } from '../hooks/tools/shared/useBaseTool';
|
||||
import { BaseToolProps, ToolComponent } from '../types/tool';
|
||||
import { useValidateSignatureParameters, defaultParameters } from '../hooks/tools/validateSignature/useValidateSignatureParameters';
|
||||
import ValidateSignatureSettings from '../components/tools/validateSignature/ValidateSignatureSettings';
|
||||
import ValidateSignatureResults from '../components/tools/validateSignature/ValidateSignatureResults';
|
||||
import { useValidateSignatureOperation, ValidateSignatureOperationHook } from '../hooks/tools/validateSignature/useValidateSignatureOperation';
|
||||
import ValidateSignatureReportView from '../components/tools/validateSignature/ValidateSignatureReportView';
|
||||
import { useToolWorkflow } from '../contexts/ToolWorkflowContext';
|
||||
import { useNavigationActions, useNavigationState } from '../contexts/NavigationContext';
|
||||
import type { SignatureValidationReportData } from '../types/validateSignature';
|
||||
|
||||
const ValidateSignature = (props: BaseToolProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { actions: navigationActions } = useNavigationActions();
|
||||
const navigationState = useNavigationState();
|
||||
const {
|
||||
registerCustomWorkbenchView,
|
||||
unregisterCustomWorkbenchView,
|
||||
setCustomWorkbenchViewData,
|
||||
clearCustomWorkbenchViewData,
|
||||
} = useToolWorkflow();
|
||||
|
||||
const REPORT_VIEW_ID = 'validateSignatureReport';
|
||||
const REPORT_WORKBENCH_ID = 'custom:validateSignatureReport' as const;
|
||||
const reportIcon = useMemo(() => <PictureAsPdfIcon fontSize="small" />, []);
|
||||
|
||||
const base = useBaseTool(
|
||||
'validateSignature',
|
||||
useValidateSignatureParameters,
|
||||
useValidateSignatureOperation,
|
||||
props
|
||||
);
|
||||
|
||||
const operation = base.operation as ValidateSignatureOperationHook;
|
||||
const hasResults = operation.results.length > 0;
|
||||
const showResultsStep = hasResults || base.operation.isLoading || !!base.operation.errorMessage;
|
||||
|
||||
useEffect(() => {
|
||||
registerCustomWorkbenchView({
|
||||
id: REPORT_VIEW_ID,
|
||||
workbenchId: REPORT_WORKBENCH_ID,
|
||||
label: t('validateSignature.report.shortTitle', 'Signature Report'),
|
||||
icon: reportIcon,
|
||||
component: ValidateSignatureReportView,
|
||||
});
|
||||
|
||||
return () => {
|
||||
clearCustomWorkbenchViewData(REPORT_VIEW_ID);
|
||||
unregisterCustomWorkbenchView(REPORT_VIEW_ID);
|
||||
};
|
||||
}, [
|
||||
clearCustomWorkbenchViewData,
|
||||
registerCustomWorkbenchView,
|
||||
reportIcon,
|
||||
t,
|
||||
unregisterCustomWorkbenchView,
|
||||
]);
|
||||
|
||||
const reportData = useMemo<SignatureValidationReportData | null>(() => {
|
||||
if (operation.results.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const generatedAt = operation.results[0].summaryGeneratedAt ?? Date.now();
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
entries: operation.results,
|
||||
};
|
||||
}, [operation.results]);
|
||||
|
||||
// Track last time we auto-navigated to the report so we don't override
|
||||
// the user's manual tab change. Only navigate when a new report is generated.
|
||||
const lastReportGeneratedAtRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (reportData) {
|
||||
setCustomWorkbenchViewData(REPORT_VIEW_ID, reportData);
|
||||
|
||||
const generatedAt = reportData.generatedAt ?? null;
|
||||
const isNewReport = generatedAt && generatedAt !== lastReportGeneratedAtRef.current;
|
||||
|
||||
if (isNewReport) {
|
||||
lastReportGeneratedAtRef.current = generatedAt;
|
||||
if (navigationState.selectedTool === 'validateSignature' && navigationState.workbench !== REPORT_WORKBENCH_ID) {
|
||||
navigationActions.setWorkbench(REPORT_WORKBENCH_ID);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
clearCustomWorkbenchViewData(REPORT_VIEW_ID);
|
||||
lastReportGeneratedAtRef.current = null;
|
||||
}
|
||||
}, [
|
||||
clearCustomWorkbenchViewData,
|
||||
navigationActions,
|
||||
navigationState.selectedTool,
|
||||
navigationState.workbench,
|
||||
reportData,
|
||||
setCustomWorkbenchViewData,
|
||||
]);
|
||||
|
||||
return createToolFlow({
|
||||
files: {
|
||||
selectedFiles: base.selectedFiles,
|
||||
isCollapsed: hasResults,
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
title: t('validateSignature.settings.title', 'Validation Settings'),
|
||||
isCollapsed: base.settingsCollapsed,
|
||||
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||
content: (
|
||||
<ValidateSignatureSettings
|
||||
parameters={base.params.parameters}
|
||||
onParameterChange={base.params.updateParameter}
|
||||
disabled={base.operation.isLoading || base.endpointLoading}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('validateSignature.results', 'Validation Results'),
|
||||
isVisible: showResultsStep,
|
||||
isCollapsed: false,
|
||||
content: (
|
||||
<ValidateSignatureResults
|
||||
operation={operation}
|
||||
results={operation.results}
|
||||
isLoading={base.operation.isLoading}
|
||||
errorMessage={base.operation.errorMessage}
|
||||
reportAvailable={Boolean(reportData)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
executeButton: {
|
||||
text: t('validateSignature.submit', 'Validate Signatures'),
|
||||
loadingText: t('loading', 'Loading...'),
|
||||
onClick: base.handleExecute,
|
||||
disabled:
|
||||
!base.params.validateParameters() ||
|
||||
!base.hasFiles ||
|
||||
base.operation.isLoading ||
|
||||
!base.endpointEnabled,
|
||||
isVisible: true,
|
||||
},
|
||||
review: {
|
||||
isVisible: false,
|
||||
operation: base.operation,
|
||||
title: t('validateSignature.results', 'Validation Results'),
|
||||
onUndo: base.handleUndo,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const ValidateSignatureTool = ValidateSignature as ToolComponent;
|
||||
ValidateSignatureTool.tool = () => useValidateSignatureOperation;
|
||||
ValidateSignatureTool.getDefaultParameters = () => ({ ...defaultParameters });
|
||||
|
||||
export default ValidateSignatureTool;
|
||||
@@ -5,6 +5,18 @@
|
||||
import { PageOperation } from './pageEditor';
|
||||
import { FileId, BaseFileMetadata } from './file';
|
||||
|
||||
export type FileJobStatus = 'queued' | 'processing' | 'completed' | 'failed';
|
||||
|
||||
export interface FileJobProgress {
|
||||
jobId: string;
|
||||
status: FileJobStatus;
|
||||
progressPercent: number;
|
||||
message?: string;
|
||||
queuePosition?: number | null;
|
||||
error?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
// Re-export FileId for convenience
|
||||
export type { FileId };
|
||||
|
||||
@@ -45,6 +57,7 @@ export interface StirlingFileStub extends BaseFileMetadata {
|
||||
processedFile?: ProcessedFileMetadata; // PDF page data and processing results
|
||||
insertAfterPageId?: string; // Page ID after which this file should be inserted
|
||||
isPinned?: boolean; // Protected from tool consumption (replace/remove)
|
||||
activeJobs?: FileJobProgress[]; // In-flight async operations associated with this file
|
||||
// Note: File object stored in provider ref, not in state
|
||||
}
|
||||
|
||||
@@ -155,7 +168,8 @@ export function createNewStirlingFileStub(
|
||||
isLeaf: true, // New files are leaf nodes by default
|
||||
versionNumber: 1, // New files start at version 1
|
||||
thumbnailUrl: thumbnail,
|
||||
processedFile: processedFileMetadata
|
||||
processedFile: processedFileMetadata,
|
||||
activeJobs: []
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
export interface SignatureValidationBackendResult {
|
||||
valid: boolean;
|
||||
chainValid: boolean;
|
||||
trustValid: boolean;
|
||||
chainValidationError?: string | null;
|
||||
certPathLength?: number | null;
|
||||
notExpired: boolean;
|
||||
revocationChecked?: boolean | null;
|
||||
revocationStatus?: string | null; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
|
||||
validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp"
|
||||
signerName?: string | null;
|
||||
signatureDate?: string | null;
|
||||
reason?: string | null;
|
||||
location?: string | null;
|
||||
issuerDN?: string | null;
|
||||
subjectDN?: string | null;
|
||||
serialNumber?: string | null;
|
||||
validFrom?: string | null;
|
||||
validUntil?: string | null;
|
||||
signatureAlgorithm?: string | null;
|
||||
keySize?: number | string | null;
|
||||
version?: string | number | null;
|
||||
keyUsages?: string[] | null;
|
||||
selfSigned?: boolean | null;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
export interface SignatureValidationSignature {
|
||||
id: string;
|
||||
valid: boolean;
|
||||
chainValid: boolean;
|
||||
trustValid: boolean;
|
||||
chainValidationError?: string | null;
|
||||
certPathLength?: number | null;
|
||||
notExpired: boolean;
|
||||
revocationChecked?: boolean | null;
|
||||
revocationStatus?: string | null; // "not-checked" | "good" | "revoked" | "soft-fail" | "unknown"
|
||||
validationTimeSource?: string | null; // "current" | "signing-time" | "timestamp"
|
||||
signerName: string;
|
||||
signatureDate: string;
|
||||
reason: string;
|
||||
location: string;
|
||||
issuerDN: string;
|
||||
subjectDN: string;
|
||||
serialNumber: string;
|
||||
validFrom: string;
|
||||
validUntil: string;
|
||||
signatureAlgorithm: string;
|
||||
keySize: number | null;
|
||||
version: string;
|
||||
keyUsages: string[];
|
||||
selfSigned: boolean;
|
||||
errorMessage: string | null;
|
||||
}
|
||||
|
||||
export interface SignatureValidationFileResult {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
signatures: SignatureValidationSignature[];
|
||||
error?: string | null;
|
||||
fileSize?: number | null;
|
||||
lastModified?: number | null;
|
||||
}
|
||||
|
||||
export interface SignatureValidationReportEntry extends SignatureValidationFileResult {
|
||||
thumbnailUrl?: string | null;
|
||||
fileSize?: number | null;
|
||||
lastModified?: number | null;
|
||||
createdAtLabel?: string | null;
|
||||
summaryGeneratedAt?: number | null;
|
||||
statusText?: string | null;
|
||||
}
|
||||
|
||||
export interface SignatureValidationReportData {
|
||||
generatedAt: number;
|
||||
entries: SignatureValidationReportEntry[];
|
||||
}
|
||||
@@ -1,21 +1,12 @@
|
||||
// Define workbench values once as source of truth
|
||||
export const BASE_WORKBENCH_TYPES = ['viewer', 'pageEditor', 'fileEditor'] as const;
|
||||
const WORKBENCH_TYPES = ['viewer', 'pageEditor', 'fileEditor'] as const;
|
||||
|
||||
export type BaseWorkbenchType = typeof BASE_WORKBENCH_TYPES[number];
|
||||
|
||||
// Workbench types including custom views
|
||||
export type WorkbenchType = BaseWorkbenchType | `custom:${string}`;
|
||||
// Workbench types - how the user interacts with content
|
||||
export type WorkbenchType = typeof WORKBENCH_TYPES[number];
|
||||
|
||||
export const getDefaultWorkbench = (): WorkbenchType => 'fileEditor';
|
||||
|
||||
// Type guard using the same source of truth
|
||||
export const isValidWorkbench = (value: string): value is WorkbenchType => {
|
||||
if (BASE_WORKBENCH_TYPES.includes(value as BaseWorkbenchType)) {
|
||||
return true;
|
||||
}
|
||||
return value.startsWith('custom:');
|
||||
};
|
||||
|
||||
export const isBaseWorkbench = (value: WorkbenchType): value is BaseWorkbenchType => {
|
||||
return BASE_WORKBENCH_TYPES.includes(value as BaseWorkbenchType);
|
||||
return WORKBENCH_TYPES.includes(value as WorkbenchType);
|
||||
};
|
||||
|
||||
@@ -19,7 +19,6 @@ interface SignatureFlatteningOptions {
|
||||
selectors: MinimalFileContextSelectors;
|
||||
originalFile?: StirlingFile;
|
||||
getScrollState: () => { currentPage: number; totalPages: number };
|
||||
activeFileIndex?: number;
|
||||
}
|
||||
|
||||
export interface SignatureFlatteningResult {
|
||||
@@ -29,7 +28,7 @@ export interface SignatureFlatteningResult {
|
||||
}
|
||||
|
||||
export async function flattenSignatures(options: SignatureFlatteningOptions): Promise<SignatureFlatteningResult | null> {
|
||||
const { signatureApiRef, getImageData, exportActions, selectors, originalFile, getScrollState, activeFileIndex } = options;
|
||||
const { signatureApiRef, getImageData, exportActions, selectors, originalFile, getScrollState } = options;
|
||||
|
||||
try {
|
||||
// Step 1: Extract all annotations from EmbedPDF before export
|
||||
@@ -105,12 +104,10 @@ export async function flattenSignatures(options: SignatureFlatteningOptions): Pr
|
||||
if (!currentFile) {
|
||||
const allFileIds = selectors.getAllFileIds();
|
||||
if (allFileIds.length > 0) {
|
||||
// Use activeFileIndex if provided, otherwise default to 0
|
||||
const fileIndex = activeFileIndex !== undefined && activeFileIndex < allFileIds.length ? activeFileIndex : 0;
|
||||
const fileStub = selectors.getStirlingFileStub(allFileIds[fileIndex]);
|
||||
const fileObject = selectors.getFile(allFileIds[fileIndex]);
|
||||
const fileStub = selectors.getStirlingFileStub(allFileIds[0]);
|
||||
const fileObject = selectors.getFile(allFileIds[0]);
|
||||
if (fileStub && fileObject) {
|
||||
currentFile = createStirlingFile(fileObject, allFileIds[fileIndex] as FileId);
|
||||
currentFile = createStirlingFile(fileObject, allFileIds[0] as FileId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-1335
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Reference in New Issue
Block a user