mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 19:33:11 +02:00
feat(security): add RFC 3161 PDF timestamp tool (#5855)
Co-authored-by: Anthony Stirling <[email protected]>
This commit is contained in:
co-authored by
Anthony Stirling
parent
7b3985e34a
commit
8bbfbd63d7
@@ -356,6 +356,7 @@ public class EndpointConfiguration {
|
|||||||
addEndpointToGroup("Security", "cert-sign");
|
addEndpointToGroup("Security", "cert-sign");
|
||||||
addEndpointToGroup("Security", "remove-cert-sign");
|
addEndpointToGroup("Security", "remove-cert-sign");
|
||||||
addEndpointToGroup("Security", "sanitize-pdf");
|
addEndpointToGroup("Security", "sanitize-pdf");
|
||||||
|
addEndpointToGroup("Security", "timestamp-pdf");
|
||||||
addEndpointToGroup("Security", "auto-redact");
|
addEndpointToGroup("Security", "auto-redact");
|
||||||
addEndpointToGroup("Security", "validate-signature");
|
addEndpointToGroup("Security", "validate-signature");
|
||||||
addEndpointToGroup("Security", "add-stamp");
|
addEndpointToGroup("Security", "add-stamp");
|
||||||
@@ -472,6 +473,7 @@ public class EndpointConfiguration {
|
|||||||
addEndpointToGroup("Java", "auto-rename");
|
addEndpointToGroup("Java", "auto-rename");
|
||||||
addEndpointToGroup("Java", "auto-split-pdf");
|
addEndpointToGroup("Java", "auto-split-pdf");
|
||||||
addEndpointToGroup("Java", "sanitize-pdf");
|
addEndpointToGroup("Java", "sanitize-pdf");
|
||||||
|
addEndpointToGroup("Java", "timestamp-pdf");
|
||||||
addEndpointToGroup("Java", "crop");
|
addEndpointToGroup("Java", "crop");
|
||||||
addEndpointToGroup("Java", "get-info-on-pdf");
|
addEndpointToGroup("Java", "get-info-on-pdf");
|
||||||
addEndpointToGroup("Java", "pdf-to-single-page");
|
addEndpointToGroup("Java", "pdf-to-single-page");
|
||||||
|
|||||||
@@ -251,6 +251,7 @@ public class ApplicationProperties {
|
|||||||
private String customGlobalAPIKey;
|
private String customGlobalAPIKey;
|
||||||
private Jwt jwt = new Jwt();
|
private Jwt jwt = new Jwt();
|
||||||
private Validation validation = new Validation();
|
private Validation validation = new Validation();
|
||||||
|
private Timestamp timestamp = new Timestamp();
|
||||||
private String xFrameOptions = "DENY";
|
private String xFrameOptions = "DENY";
|
||||||
|
|
||||||
public Boolean isAltLogin() {
|
public Boolean isAltLogin() {
|
||||||
@@ -569,6 +570,12 @@ public class ApplicationProperties {
|
|||||||
private boolean hardFail = false;
|
private boolean hardFail = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class Timestamp {
|
||||||
|
private String defaultTsaUrl = "http://timestamp.digicert.com";
|
||||||
|
private List<String> customTsaUrls = new ArrayList<>();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|||||||
+8
@@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import stirling.software.SPDF.config.EndpointConfiguration;
|
import stirling.software.SPDF.config.EndpointConfiguration;
|
||||||
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability;
|
||||||
import stirling.software.SPDF.config.InitialSetup;
|
import stirling.software.SPDF.config.InitialSetup;
|
||||||
|
import stirling.software.SPDF.controller.api.security.TimestampController;
|
||||||
import stirling.software.common.annotations.api.ConfigApi;
|
import stirling.software.common.annotations.api.ConfigApi;
|
||||||
import stirling.software.common.configuration.AppConfig;
|
import stirling.software.common.configuration.AppConfig;
|
||||||
import stirling.software.common.model.ApplicationProperties;
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
@@ -245,6 +246,13 @@ public class ConfigController {
|
|||||||
// Premium/Enterprise settings
|
// Premium/Enterprise settings
|
||||||
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
configData.put("premiumEnabled", applicationProperties.getPremium().isEnabled());
|
||||||
|
|
||||||
|
// Timestamp TSA settings — single source of truth for presets + admin URLs
|
||||||
|
ApplicationProperties.Security.Timestamp tsConfig =
|
||||||
|
applicationProperties.getSecurity().getTimestamp();
|
||||||
|
configData.put("timestampDefaultTsaUrl", tsConfig.getDefaultTsaUrl());
|
||||||
|
configData.put("timestampCustomTsaUrls", tsConfig.getCustomTsaUrls());
|
||||||
|
configData.put("timestampTsaPresets", TimestampController.TSA_PRESETS);
|
||||||
|
|
||||||
// Server certificate settings
|
// Server certificate settings
|
||||||
configData.put(
|
configData.put(
|
||||||
"serverCertificateEnabled",
|
"serverCertificateEnabled",
|
||||||
|
|||||||
+264
@@ -0,0 +1,264 @@
|
|||||||
|
package stirling.software.SPDF.controller.api.security;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.math.BigInteger;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.URI;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.security.SecureRandom;
|
||||||
|
import java.security.Security;
|
||||||
|
import java.util.Calendar;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
import org.apache.pdfbox.cos.COSName;
|
||||||
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
|
import org.apache.pdfbox.pdmodel.interactive.digitalsignature.PDSignature;
|
||||||
|
import org.bouncycastle.asn1.ASN1ObjectIdentifier;
|
||||||
|
import org.bouncycastle.asn1.nist.NISTObjectIdentifiers;
|
||||||
|
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||||
|
import org.bouncycastle.tsp.TimeStampRequest;
|
||||||
|
import org.bouncycastle.tsp.TimeStampRequestGenerator;
|
||||||
|
import org.bouncycastle.tsp.TimeStampResponse;
|
||||||
|
import org.bouncycastle.tsp.TimeStampToken;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.ModelAttribute;
|
||||||
|
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.StandardPdfResponse;
|
||||||
|
import stirling.software.SPDF.model.api.security.TimestampPdfRequest;
|
||||||
|
import stirling.software.common.annotations.AutoJobPostMapping;
|
||||||
|
import stirling.software.common.annotations.api.SecurityApi;
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||||
|
import stirling.software.common.util.GeneralUtils;
|
||||||
|
import stirling.software.common.util.WebResponseUtils;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@SecurityApi
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TimestampController {
|
||||||
|
|
||||||
|
static {
|
||||||
|
Security.addProvider(new BouncyCastleProvider());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Built-in TSA presets with labels — single source of truth for backend + frontend. */
|
||||||
|
public static final List<Map<String, String>> TSA_PRESETS =
|
||||||
|
List.of(
|
||||||
|
Map.of("label", "DigiCert", "url", "http://timestamp.digicert.com"),
|
||||||
|
Map.of("label", "Sectigo", "url", "http://timestamp.sectigo.com"),
|
||||||
|
Map.of("label", "SSL.com", "url", "http://ts.ssl.com"),
|
||||||
|
Map.of("label", "FreeTSA", "url", "https://freetsa.org/tsr"),
|
||||||
|
Map.of("label", "MeSign", "url", "http://tsa.mesign.com"));
|
||||||
|
|
||||||
|
private static final Set<String> ALLOWED_TSA_PRESET_URLS =
|
||||||
|
TSA_PRESETS.stream().map(p -> p.get("url")).collect(Collectors.toUnmodifiableSet());
|
||||||
|
|
||||||
|
private static final int MAX_TSA_RESPONSE_SIZE = 1024 * 1024; // 1 MB
|
||||||
|
private static final SecureRandom SECURE_RANDOM = new SecureRandom();
|
||||||
|
|
||||||
|
private final CustomPDFDocumentFactory pdfDocumentFactory;
|
||||||
|
private final ApplicationProperties applicationProperties;
|
||||||
|
|
||||||
|
@AutoJobPostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE, value = "/timestamp-pdf")
|
||||||
|
@StandardPdfResponse
|
||||||
|
@Operation(
|
||||||
|
summary = "Add RFC 3161 document timestamp to a PDF",
|
||||||
|
description =
|
||||||
|
"Contacts a trusted Time Stamp Authority (TSA) server and embeds an RFC 3161"
|
||||||
|
+ " document timestamp into the PDF. Only a SHA-256 hash of the"
|
||||||
|
+ " document is sent to the TSA — the PDF itself never leaves the"
|
||||||
|
+ " server. Input:PDF Output:PDF Type:SISO")
|
||||||
|
public ResponseEntity<byte[]> timestampPdf(@ModelAttribute TimestampPdfRequest request)
|
||||||
|
throws Exception {
|
||||||
|
MultipartFile inputFile = request.getFileInput();
|
||||||
|
ApplicationProperties.Security.Timestamp tsConfig =
|
||||||
|
applicationProperties.getSecurity().getTimestamp();
|
||||||
|
|
||||||
|
// Determine effective TSA URL: use request value if provided, otherwise config default
|
||||||
|
String tsaUrl =
|
||||||
|
(request.getTsaUrl() != null && !request.getTsaUrl().isBlank())
|
||||||
|
? request.getTsaUrl()
|
||||||
|
: tsConfig.getDefaultTsaUrl();
|
||||||
|
|
||||||
|
// Build allowed set: built-in presets + admin-configured custom URLs
|
||||||
|
// Filter null/blank entries and validate protocol (TASK-6)
|
||||||
|
Set<String> allowedUrls = new HashSet<>(ALLOWED_TSA_PRESET_URLS);
|
||||||
|
if (tsConfig.getDefaultTsaUrl() != null
|
||||||
|
&& !tsConfig.getDefaultTsaUrl().isBlank()
|
||||||
|
&& isValidTsaUrlProtocol(tsConfig.getDefaultTsaUrl())) {
|
||||||
|
allowedUrls.add(tsConfig.getDefaultTsaUrl());
|
||||||
|
}
|
||||||
|
List<String> customUrls = tsConfig.getCustomTsaUrls();
|
||||||
|
if (customUrls != null) {
|
||||||
|
customUrls.stream()
|
||||||
|
.filter(u -> u != null && !u.isBlank() && isValidTsaUrlProtocol(u))
|
||||||
|
.forEach(allowedUrls::add);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize for case-insensitive comparison (TASK-12)
|
||||||
|
Set<String> normalizedAllowed =
|
||||||
|
allowedUrls.stream()
|
||||||
|
.map(TimestampController::normalizeTsaUrl)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
|
// Validate TSA URL against allowed set to prevent SSRF
|
||||||
|
if (!normalizedAllowed.contains(normalizeTsaUrl(tsaUrl))) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"TSA URL is not in the allowed list. Contact your administrator to add it"
|
||||||
|
+ " via settings.yml (security.timestamp.customTsaUrls).");
|
||||||
|
}
|
||||||
|
|
||||||
|
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
|
||||||
|
|
||||||
|
try (PDDocument document = pdfDocumentFactory.load(inputFile)) {
|
||||||
|
PDSignature signature = new PDSignature();
|
||||||
|
signature.setType(COSName.DOC_TIME_STAMP);
|
||||||
|
signature.setFilter(PDSignature.FILTER_ADOBE_PPKLITE);
|
||||||
|
signature.setSubFilter(COSName.getPDFName("ETSI.RFC3161"));
|
||||||
|
signature.setSignDate(Calendar.getInstance());
|
||||||
|
|
||||||
|
document.addSignature(signature, content -> requestTimestampToken(content, tsaUrl));
|
||||||
|
|
||||||
|
document.saveIncremental(outputStream);
|
||||||
|
}
|
||||||
|
|
||||||
|
return WebResponseUtils.bytesToWebResponse(
|
||||||
|
outputStream.toByteArray(),
|
||||||
|
GeneralUtils.generateFilename(inputFile.getOriginalFilename(), "_timestamped.pdf"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private byte[] requestTimestampToken(InputStream content, String tsaUrl) throws IOException {
|
||||||
|
HttpURLConnection connection = null;
|
||||||
|
try {
|
||||||
|
// Hash the PDF content byte range with SHA-256
|
||||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] buffer = new byte[8192];
|
||||||
|
int read;
|
||||||
|
while ((read = content.read(buffer)) != -1) {
|
||||||
|
digest.update(buffer, 0, read);
|
||||||
|
}
|
||||||
|
byte[] hash = digest.digest();
|
||||||
|
|
||||||
|
// Build the RFC 3161 timestamp request
|
||||||
|
TimeStampRequestGenerator generator = new TimeStampRequestGenerator();
|
||||||
|
generator.setCertReq(true);
|
||||||
|
BigInteger nonce = BigInteger.valueOf(SECURE_RANDOM.nextLong() & Long.MAX_VALUE);
|
||||||
|
ASN1ObjectIdentifier digestAlgorithm = NISTObjectIdentifiers.id_sha256;
|
||||||
|
TimeStampRequest tsaRequest = generator.generate(digestAlgorithm, hash, nonce);
|
||||||
|
byte[] requestBytes = tsaRequest.getEncoded();
|
||||||
|
|
||||||
|
// Contact the TSA server (redirects disabled to prevent SSRF via redirect)
|
||||||
|
connection = (HttpURLConnection) URI.create(tsaUrl).toURL().openConnection();
|
||||||
|
connection.setInstanceFollowRedirects(false);
|
||||||
|
connection.setDoOutput(true);
|
||||||
|
connection.setDoInput(true);
|
||||||
|
connection.setRequestMethod("POST");
|
||||||
|
connection.setRequestProperty("Content-Type", "application/timestamp-query");
|
||||||
|
connection.setRequestProperty("Content-Length", String.valueOf(requestBytes.length));
|
||||||
|
connection.setConnectTimeout(30_000);
|
||||||
|
connection.setReadTimeout(30_000);
|
||||||
|
|
||||||
|
try (OutputStream out = connection.getOutputStream()) {
|
||||||
|
out.write(requestBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
int responseCode = connection.getResponseCode();
|
||||||
|
if (responseCode != HttpURLConnection.HTTP_OK) {
|
||||||
|
// Read error stream for debugging (TASK-5)
|
||||||
|
String errorBody = readErrorStream(connection);
|
||||||
|
throw new IOException(
|
||||||
|
"TSA server returned HTTP "
|
||||||
|
+ responseCode
|
||||||
|
+ " for URL: "
|
||||||
|
+ tsaUrl
|
||||||
|
+ (errorBody.isEmpty() ? "" : " — " + errorBody));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read response with size limit to prevent OOM (TASK-4)
|
||||||
|
byte[] responseBytes;
|
||||||
|
try (InputStream in = connection.getInputStream()) {
|
||||||
|
responseBytes = in.readNBytes(MAX_TSA_RESPONSE_SIZE);
|
||||||
|
if (in.read() != -1) {
|
||||||
|
throw new IOException(
|
||||||
|
"TSA response exceeds maximum allowed size of "
|
||||||
|
+ MAX_TSA_RESPONSE_SIZE
|
||||||
|
+ " bytes");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and validate the TSA response
|
||||||
|
TimeStampResponse tsaResponse = new TimeStampResponse(responseBytes);
|
||||||
|
tsaResponse.validate(tsaRequest);
|
||||||
|
|
||||||
|
TimeStampToken token = tsaResponse.getTimeStampToken();
|
||||||
|
if (token == null) {
|
||||||
|
throw new IOException(
|
||||||
|
"TSA server did not return a timestamp token. Status: "
|
||||||
|
+ tsaResponse.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"RFC 3161 timestamp obtained from {} at {}",
|
||||||
|
tsaUrl,
|
||||||
|
token.getTimeStampInfo().getGenTime());
|
||||||
|
|
||||||
|
return token.getEncoded();
|
||||||
|
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw e;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new IOException(
|
||||||
|
"Failed to obtain RFC 3161 timestamp from " + tsaUrl + ": " + e.getMessage(),
|
||||||
|
e);
|
||||||
|
} finally {
|
||||||
|
// Always disconnect to release the underlying socket (TASK-1)
|
||||||
|
if (connection != null) {
|
||||||
|
connection.disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isValidTsaUrlProtocol(String url) {
|
||||||
|
String lower = url.toLowerCase(Locale.ROOT);
|
||||||
|
return lower.startsWith("http://") || lower.startsWith("https://");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeTsaUrl(String url) {
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(url.trim());
|
||||||
|
String scheme = uri.getScheme() == null ? "" : uri.getScheme().toLowerCase(Locale.ROOT);
|
||||||
|
String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT);
|
||||||
|
int port = uri.getPort();
|
||||||
|
String path = uri.getPath() == null ? "" : uri.getPath();
|
||||||
|
return scheme + "://" + host + (port == -1 ? "" : ":" + port) + path;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return url.toLowerCase(Locale.ROOT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String readErrorStream(HttpURLConnection connection) {
|
||||||
|
try (InputStream err = connection.getErrorStream()) {
|
||||||
|
if (err == null) return "";
|
||||||
|
byte[] body = err.readNBytes(2048);
|
||||||
|
return new String(body, StandardCharsets.UTF_8).trim();
|
||||||
|
} catch (IOException e) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package stirling.software.SPDF.model.api.security;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
|
||||||
|
import stirling.software.common.model.api.PDFFile;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@EqualsAndHashCode(callSuper = true)
|
||||||
|
public class TimestampPdfRequest extends PDFFile {
|
||||||
|
|
||||||
|
@Schema(
|
||||||
|
description =
|
||||||
|
"URL of the RFC 3161 Time Stamp Authority (TSA) server."
|
||||||
|
+ " Must be one of the built-in presets (DigiCert, Sectigo, SSL.com,"
|
||||||
|
+ " FreeTSA, MeSign) or an admin-configured URL in"
|
||||||
|
+ " settings.yml (security.timestamp.customTsaUrls)."
|
||||||
|
+ " If omitted, the server default is used.",
|
||||||
|
defaultValue = "http://timestamp.digicert.com",
|
||||||
|
requiredMode = Schema.RequiredMode.NOT_REQUIRED)
|
||||||
|
private String tsaUrl;
|
||||||
|
}
|
||||||
@@ -84,6 +84,9 @@ security:
|
|||||||
revocation:
|
revocation:
|
||||||
mode: none # Revocation checking mode: 'none' (disabled), 'ocsp' (OCSP only), 'crl' (CRL only), 'ocsp+crl' (OCSP with CRL fallback)
|
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)
|
hardFail: false # Fail validation if revocation status cannot be determined (true=strict, false=soft-fail)
|
||||||
|
timestamp:
|
||||||
|
defaultTsaUrl: http://timestamp.digicert.com # Default TSA server for RFC 3161 document timestamps
|
||||||
|
customTsaUrls: [] # Admin-configured additional TSA URLs (e.g. ['https://internal-tsa.corp.com/timestamp']). Users can only select from built-in presets and these URLs.
|
||||||
xFrameOptions: DENY # X-Frame-Options header value. Options: 'DENY' (default, prevents all framing), 'SAMEORIGIN' (allows framing from same domain), 'DISABLED' (no X-Frame-Options header sent). Note: automatically set to DISABLED when login is disabled
|
xFrameOptions: DENY # X-Frame-Options header value. Options: 'DENY' (default, prevents all framing), 'SAMEORIGIN' (allows framing from same domain), 'DISABLED' (no X-Frame-Options header sent). Note: automatically set to DISABLED when login is disabled
|
||||||
|
|
||||||
premium:
|
premium:
|
||||||
|
|||||||
+288
@@ -0,0 +1,288 @@
|
|||||||
|
package stirling.software.SPDF.controller.api.security;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
import static org.mockito.Mockito.*;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.DisplayName;
|
||||||
|
import org.junit.jupiter.api.Nested;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.mockito.junit.jupiter.MockitoSettings;
|
||||||
|
import org.mockito.quality.Strictness;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
|
||||||
|
import stirling.software.SPDF.model.api.security.TimestampPdfRequest;
|
||||||
|
import stirling.software.common.model.ApplicationProperties;
|
||||||
|
import stirling.software.common.service.CustomPDFDocumentFactory;
|
||||||
|
|
||||||
|
@DisplayName("TimestampController security tests")
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||||
|
class TimestampControllerTest {
|
||||||
|
|
||||||
|
@Mock private CustomPDFDocumentFactory pdfDocumentFactory;
|
||||||
|
@Mock private ApplicationProperties applicationProperties;
|
||||||
|
|
||||||
|
@InjectMocks private TimestampController controller;
|
||||||
|
|
||||||
|
private ApplicationProperties.Security security;
|
||||||
|
private ApplicationProperties.Security.Timestamp tsConfig;
|
||||||
|
private MockMultipartFile mockPdfFile;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
security = new ApplicationProperties.Security();
|
||||||
|
tsConfig = new ApplicationProperties.Security.Timestamp();
|
||||||
|
security.setTimestamp(tsConfig);
|
||||||
|
|
||||||
|
when(applicationProperties.getSecurity()).thenReturn(security);
|
||||||
|
|
||||||
|
mockPdfFile =
|
||||||
|
new MockMultipartFile(
|
||||||
|
"fileInput",
|
||||||
|
"test.pdf",
|
||||||
|
MediaType.APPLICATION_PDF_VALUE,
|
||||||
|
new byte[] {0x25, 0x50, 0x44, 0x46}); // %PDF header
|
||||||
|
}
|
||||||
|
|
||||||
|
private TimestampPdfRequest createRequest(String tsaUrl) {
|
||||||
|
TimestampPdfRequest request = new TimestampPdfRequest();
|
||||||
|
request.setFileInput(mockPdfFile);
|
||||||
|
request.setTsaUrl(tsaUrl);
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("URL Allowlist Validation")
|
||||||
|
class AllowlistTests {
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@DisplayName("Should accept built-in preset URLs")
|
||||||
|
@ValueSource(
|
||||||
|
strings = {
|
||||||
|
"http://timestamp.digicert.com",
|
||||||
|
"http://timestamp.sectigo.com",
|
||||||
|
"http://ts.ssl.com",
|
||||||
|
"https://freetsa.org/tsr",
|
||||||
|
"http://tsa.mesign.com"
|
||||||
|
})
|
||||||
|
void shouldAcceptPresetUrls(String presetUrl) throws Exception {
|
||||||
|
// Mock PDF loading to avoid actual TSA call — we only test validation here
|
||||||
|
PDDocument mockDoc = mock(PDDocument.class);
|
||||||
|
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDoc);
|
||||||
|
doAnswer(
|
||||||
|
inv -> {
|
||||||
|
ByteArrayOutputStream baos = inv.getArgument(0);
|
||||||
|
baos.write(new byte[] {0x25, 0x50, 0x44, 0x46});
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.when(mockDoc)
|
||||||
|
.saveIncremental(any(ByteArrayOutputStream.class));
|
||||||
|
doNothing().when(mockDoc).close();
|
||||||
|
|
||||||
|
TimestampPdfRequest request = createRequest(presetUrl);
|
||||||
|
|
||||||
|
// The method should NOT throw IllegalArgumentException for preset URLs
|
||||||
|
// It may throw IOException when contacting the TSA — that's expected
|
||||||
|
try {
|
||||||
|
controller.timestampPdf(request);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
fail("Preset URL should be in the allowlist: " + presetUrl);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// IOException from TSA contact is expected in test env — validation passed
|
||||||
|
assertFalse(
|
||||||
|
e instanceof IllegalArgumentException,
|
||||||
|
"Should not reject preset URL: " + presetUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should reject arbitrary URL not in allowlist")
|
||||||
|
void shouldRejectArbitraryUrl() {
|
||||||
|
TimestampPdfRequest request = createRequest("http://evil.internal.corp/ssrf");
|
||||||
|
|
||||||
|
IllegalArgumentException ex =
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class, () -> controller.timestampPdf(request));
|
||||||
|
assertTrue(ex.getMessage().contains("not in the allowed list"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@DisplayName("Should reject SSRF-prone URLs")
|
||||||
|
@ValueSource(
|
||||||
|
strings = {
|
||||||
|
"http://localhost/timestamp",
|
||||||
|
"http://127.0.0.1:8080/internal",
|
||||||
|
"http://192.168.1.1/admin",
|
||||||
|
"http://10.0.0.1/metadata",
|
||||||
|
"http://169.254.169.254/latest/meta-data",
|
||||||
|
"file:///etc/passwd",
|
||||||
|
"ftp://internal-server/data",
|
||||||
|
"gopher://internal:25/",
|
||||||
|
})
|
||||||
|
void shouldRejectSsrfUrls(String ssrfUrl) {
|
||||||
|
TimestampPdfRequest request = createRequest(ssrfUrl);
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
IllegalArgumentException.class,
|
||||||
|
() -> controller.timestampPdf(request),
|
||||||
|
"Should reject SSRF URL: " + ssrfUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should accept admin-configured custom URL")
|
||||||
|
void shouldAcceptAdminCustomUrl() throws Exception {
|
||||||
|
String customUrl = "https://internal-tsa.corp.com/timestamp";
|
||||||
|
tsConfig.setCustomTsaUrls(new ArrayList<>(List.of(customUrl)));
|
||||||
|
|
||||||
|
PDDocument mockDoc = mock(PDDocument.class);
|
||||||
|
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDoc);
|
||||||
|
doNothing().when(mockDoc).close();
|
||||||
|
|
||||||
|
TimestampPdfRequest request = createRequest(customUrl);
|
||||||
|
|
||||||
|
try {
|
||||||
|
controller.timestampPdf(request);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
fail("Admin-configured custom URL should be accepted: " + customUrl);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// IOException from TSA contact is expected
|
||||||
|
assertFalse(e instanceof IllegalArgumentException);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should reject URL not in admin custom list")
|
||||||
|
void shouldRejectUrlNotInAdminList() {
|
||||||
|
tsConfig.setCustomTsaUrls(
|
||||||
|
new ArrayList<>(List.of("https://allowed-tsa.corp.com/timestamp")));
|
||||||
|
|
||||||
|
TimestampPdfRequest request =
|
||||||
|
createRequest("https://not-allowed-tsa.evil.com/timestamp");
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> controller.timestampPdf(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Default TSA URL Fallback")
|
||||||
|
class DefaultFallbackTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should use config default when tsaUrl is null")
|
||||||
|
void shouldFallbackToConfigDefault() throws Exception {
|
||||||
|
tsConfig.setDefaultTsaUrl("http://timestamp.digicert.com");
|
||||||
|
|
||||||
|
PDDocument mockDoc = mock(PDDocument.class);
|
||||||
|
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDoc);
|
||||||
|
doNothing().when(mockDoc).close();
|
||||||
|
|
||||||
|
TimestampPdfRequest request = createRequest(null);
|
||||||
|
|
||||||
|
// Should not throw IllegalArgumentException — default is in presets
|
||||||
|
try {
|
||||||
|
controller.timestampPdf(request);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
fail("Default TSA URL should be accepted");
|
||||||
|
} catch (Exception e) {
|
||||||
|
// IOException expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should use config default when tsaUrl is blank")
|
||||||
|
void shouldFallbackToConfigDefaultWhenBlank() throws Exception {
|
||||||
|
tsConfig.setDefaultTsaUrl("http://timestamp.digicert.com");
|
||||||
|
|
||||||
|
PDDocument mockDoc = mock(PDDocument.class);
|
||||||
|
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDoc);
|
||||||
|
doNothing().when(mockDoc).close();
|
||||||
|
|
||||||
|
TimestampPdfRequest request = createRequest(" ");
|
||||||
|
|
||||||
|
try {
|
||||||
|
controller.timestampPdf(request);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
fail("Should fallback to config default when blank");
|
||||||
|
} catch (Exception e) {
|
||||||
|
// IOException expected
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Config URL Validation (TASK-6)")
|
||||||
|
class ConfigValidationTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should filter blank entries from admin custom URLs")
|
||||||
|
void shouldFilterBlankCustomUrls() {
|
||||||
|
List<String> customUrls = new ArrayList<>(List.of("", " ", "http://valid-tsa.com/ts"));
|
||||||
|
tsConfig.setCustomTsaUrls(customUrls);
|
||||||
|
|
||||||
|
TimestampPdfRequest request = createRequest("http://evil.com/ssrf");
|
||||||
|
|
||||||
|
// Blank entries should not expand the allowlist
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> controller.timestampPdf(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should reject file:// protocol in admin config")
|
||||||
|
void shouldRejectFileProtocolInConfig() {
|
||||||
|
tsConfig.setDefaultTsaUrl("file:///etc/passwd");
|
||||||
|
tsConfig.setCustomTsaUrls(new ArrayList<>());
|
||||||
|
|
||||||
|
TimestampPdfRequest request = createRequest(null);
|
||||||
|
|
||||||
|
// file:// default should be filtered out, leaving no valid default
|
||||||
|
// The request falls back to "file:///etc/passwd" which is not in allowed set
|
||||||
|
assertThrows(Exception.class, () -> controller.timestampPdf(request));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should reject ftp:// protocol in custom URLs")
|
||||||
|
void shouldRejectFtpProtocolInCustomUrls() {
|
||||||
|
tsConfig.setCustomTsaUrls(new ArrayList<>(List.of("ftp://internal-server/timestamp")));
|
||||||
|
|
||||||
|
TimestampPdfRequest request = createRequest("ftp://internal-server/timestamp");
|
||||||
|
|
||||||
|
assertThrows(IllegalArgumentException.class, () -> controller.timestampPdf(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Nested
|
||||||
|
@DisplayName("Case-insensitive URL matching (TASK-12)")
|
||||||
|
class CaseInsensitiveTests {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@DisplayName("Should match URLs regardless of case")
|
||||||
|
void shouldMatchCaseInsensitive() throws Exception {
|
||||||
|
PDDocument mockDoc = mock(PDDocument.class);
|
||||||
|
when(pdfDocumentFactory.load(any(MockMultipartFile.class))).thenReturn(mockDoc);
|
||||||
|
doNothing().when(mockDoc).close();
|
||||||
|
|
||||||
|
TimestampPdfRequest request = createRequest("HTTP://TIMESTAMP.DIGICERT.COM");
|
||||||
|
|
||||||
|
try {
|
||||||
|
controller.timestampPdf(request);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
fail("Case-insensitive URL should match preset");
|
||||||
|
} catch (Exception e) {
|
||||||
|
// IOException expected
|
||||||
|
assertFalse(e instanceof IllegalArgumentException);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3773,6 +3773,11 @@ desc = "Adds signature to PDF by drawing, text or image"
|
|||||||
tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorize,endorse,ink signature,handwriting"
|
tags = "signature,autograph,e-sign,electronic signature,digital signature,sign document,approval,signoff,authorize,endorse,ink signature,handwriting"
|
||||||
title = "Sign"
|
title = "Sign"
|
||||||
|
|
||||||
|
[home.timestampPdf]
|
||||||
|
desc = "Add an RFC 3161 document timestamp to prove when your PDF existed"
|
||||||
|
tags = "timestamp,RFC 3161,TSA,time stamp authority,document timestamp,proof of existence,timestamp token,trusted timestamp,sign timestamp,notarise"
|
||||||
|
title = "Timestamp PDF"
|
||||||
|
|
||||||
[home.split]
|
[home.split]
|
||||||
desc = "Split PDFs into multiple documents"
|
desc = "Split PDFs into multiple documents"
|
||||||
tags = "divide,separate,break,split,extract pages,separate pages,divide document,break apart,separate files,unbind,split by page,divide by chapter"
|
tags = "divide,separate,break,split,extract pages,separate pages,divide document,break apart,separate files,unbind,split by page,divide by chapter"
|
||||||
@@ -5718,6 +5723,32 @@ text = "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments
|
|||||||
[rotate.tooltip.header]
|
[rotate.tooltip.header]
|
||||||
title = "Rotate Settings Overview"
|
title = "Rotate Settings Overview"
|
||||||
|
|
||||||
|
[timestampPdf]
|
||||||
|
completed = "PDF timestamped successfully"
|
||||||
|
desc = "Add an RFC 3161 document timestamp to your PDF using a trusted Time Stamp Authority (TSA) server."
|
||||||
|
filenamePrefix = "timestamped"
|
||||||
|
results = "Timestamp Results"
|
||||||
|
submit = "Apply Timestamp"
|
||||||
|
title = "Timestamp PDF"
|
||||||
|
|
||||||
|
[timestampPdf.error]
|
||||||
|
failed = "An error occurred while timestamping the PDF."
|
||||||
|
generic = "Timestamping failed"
|
||||||
|
|
||||||
|
[timestampPdf.files]
|
||||||
|
placeholder = "Select a PDF file in the main view to get started"
|
||||||
|
|
||||||
|
[timestampPdf.options]
|
||||||
|
note = "Only a SHA-256 hash of your document is sent to the TSA server; the PDF file itself is never sent to the TSA server."
|
||||||
|
title = "Timestamp Server (TSA)"
|
||||||
|
|
||||||
|
[timestampPdf.options.tsaUrl]
|
||||||
|
desc = "Pick a trusted Time Stamp Authority"
|
||||||
|
label = "Select a TSA server"
|
||||||
|
|
||||||
|
[timestampPdf.steps]
|
||||||
|
settings = "Settings"
|
||||||
|
|
||||||
[sanitize]
|
[sanitize]
|
||||||
completed = "Sanitisation completed successfully"
|
completed = "Sanitisation completed successfully"
|
||||||
desc = "Remove potentially harmful elements from PDF files."
|
desc = "Remove potentially harmful elements from PDF files."
|
||||||
|
|||||||
@@ -142,6 +142,10 @@ welcome = "Bienvenue"
|
|||||||
white = "Blanc"
|
white = "Blanc"
|
||||||
WorkInProgess = "En cours de développement, merci de nous remonter les problèmes que vous pourriez constater!"
|
WorkInProgess = "En cours de développement, merci de nous remonter les problèmes que vous pourriez constater!"
|
||||||
yes = "Oui"
|
yes = "Oui"
|
||||||
|
insufficientCredits = "Insufficient credits. Required: {{requiredCredits}}, Available: {{currentBalance}}, Shortfall: {{shortfall}}"
|
||||||
|
loadingCredits = "Checking credits..."
|
||||||
|
loadingProStatus = "Checking subscription status..."
|
||||||
|
noticeTopUpOrPlan = "Not enough credits, please top up or upgrade to a plan"
|
||||||
|
|
||||||
[account]
|
[account]
|
||||||
accountSettings = "Paramètres du compte"
|
accountSettings = "Paramètres du compte"
|
||||||
@@ -1557,6 +1561,13 @@ underline = "Souligner"
|
|||||||
undo = "Annuler"
|
undo = "Annuler"
|
||||||
unsupportedType = "Ce type d’annotation n’est pas entièrement pris en charge pour l’édition."
|
unsupportedType = "Ce type d’annotation n’est pas entièrement pris en charge pour l’édition."
|
||||||
width = "Largeur"
|
width = "Largeur"
|
||||||
|
annotationStyle = "Annotation style"
|
||||||
|
comment = "Comment"
|
||||||
|
comments = "Comments"
|
||||||
|
insertText = "Insert Text"
|
||||||
|
lineArrow = "Arrow"
|
||||||
|
polyline = "Polyline"
|
||||||
|
replaceText = "Replace Text"
|
||||||
|
|
||||||
[app]
|
[app]
|
||||||
description = "L’alternative gratuite à Adobe Acrobat (10M+ téléchargements)"
|
description = "L’alternative gratuite à Adobe Acrobat (10M+ téléchargements)"
|
||||||
@@ -2574,10 +2585,22 @@ title = "Réglage de la qualité"
|
|||||||
[compressPdfs]
|
[compressPdfs]
|
||||||
tags = "compresser,réduire,taille,squish,small,tiny"
|
tags = "compresser,réduire,taille,squish,small,tiny"
|
||||||
|
|
||||||
|
[config]
|
||||||
|
plan = "Plan"
|
||||||
|
|
||||||
[config.account.overview]
|
[config.account.overview]
|
||||||
guestDescription = "Vous êtes connecté en tant qu’invité. Envisagez de mettre à niveau votre compte ci-dessus."
|
guestDescription = "Vous êtes connecté en tant qu’invité. Envisagez de mettre à niveau votre compte ci-dessus."
|
||||||
manageAccountPreferences = "Gérer les préférences de votre compte"
|
manageAccountPreferences = "Gérer les préférences de votre compte"
|
||||||
title = "Paramètres du compte"
|
title = "Paramètres du compte"
|
||||||
|
confirmDelete = "Delete My Account"
|
||||||
|
deleteAccount = "Delete Account"
|
||||||
|
deleteAccountTitle = "Delete Account"
|
||||||
|
deleteFailed = "Failed to delete account."
|
||||||
|
deleteFailedTitle = "Unable to delete account"
|
||||||
|
deleteWarning = "This action is permanent and cannot be undone. All your data will be deleted."
|
||||||
|
enterEmailConfirm = "To confirm deletion, please type your email address ({{email}}) below:"
|
||||||
|
label = "Overview"
|
||||||
|
signedInAs = "Signed in as"
|
||||||
|
|
||||||
[config.account.upgrade]
|
[config.account.upgrade]
|
||||||
description = "Liez votre compte pour préserver votre historique et accéder à davantage de fonctionnalités !"
|
description = "Liez votre compte pour préserver votre historique et accéder à davantage de fonctionnalités !"
|
||||||
@@ -2592,6 +2615,32 @@ socialLogin = "Mettre à niveau avec un compte social"
|
|||||||
title = "Mettre à niveau le compte invité"
|
title = "Mettre à niveau le compte invité"
|
||||||
upgradeButton = "Mettre à niveau le compte"
|
upgradeButton = "Mettre à niveau le compte"
|
||||||
|
|
||||||
|
[config.account.profilePicture]
|
||||||
|
description = "Upload an image to personalize your account."
|
||||||
|
help = "PNG, JPG, or WebP up to 2MB."
|
||||||
|
remove = "Remove"
|
||||||
|
sizeError = "Please select an image smaller than 2MB."
|
||||||
|
switchedToCustom = "Switched to custom picture. You can now upload your own."
|
||||||
|
title = "Profile picture"
|
||||||
|
upload = "Upload"
|
||||||
|
useCustom = "Use custom picture"
|
||||||
|
usingProvider = "Using {{provider}} profile picture"
|
||||||
|
|
||||||
|
[config.account.profilePicture.cropper]
|
||||||
|
cropError = "Failed to crop image. Please try again."
|
||||||
|
invalidImage = "Invalid image file. Please select a valid PNG, JPG, or WebP file."
|
||||||
|
processing = "Processing crop..."
|
||||||
|
save = "Save Cropped Image"
|
||||||
|
sizeErrorAfterCrop = "Cropped image is too large. Please zoom out or crop a smaller area."
|
||||||
|
title = "Crop Profile Picture"
|
||||||
|
zoom = "Zoom"
|
||||||
|
|
||||||
|
[config.account.security]
|
||||||
|
changePassword = "Change password"
|
||||||
|
description = "Manage your password and security settings."
|
||||||
|
title = "Passwords & Security"
|
||||||
|
update = "Update password"
|
||||||
|
|
||||||
[config.apiKeys]
|
[config.apiKeys]
|
||||||
chartAriaLabel = "Utilisation des crédits : inclus {{includedUsed}} sur {{includedTotal}}, achetés {{purchasedUsed}} sur {{purchasedTotal}}"
|
chartAriaLabel = "Utilisation des crédits : inclus {{includedUsed}} sur {{includedTotal}}, achetés {{purchasedUsed}} sur {{purchasedTotal}}"
|
||||||
copyKeyAriaLabel = "Copier la clé API"
|
copyKeyAriaLabel = "Copier la clé API"
|
||||||
@@ -2614,6 +2663,7 @@ refreshAriaLabel = "Actualiser la clé API"
|
|||||||
schemaLink = "Référence du schéma API"
|
schemaLink = "Référence du schéma API"
|
||||||
totalCredits = "Crédits totaux"
|
totalCredits = "Crédits totaux"
|
||||||
usage = "Incluez cette clé dans l’en-tête X-API-KEY pour toutes les requêtes API."
|
usage = "Incluez cette clé dans l’en-tête X-API-KEY pour toutes les requêtes API."
|
||||||
|
creditsRemaining = "Credits Remaining"
|
||||||
|
|
||||||
[config.apiKeys.alert]
|
[config.apiKeys.alert]
|
||||||
apiKeyErrorTitle = "Erreur de clé API"
|
apiKeyErrorTitle = "Erreur de clé API"
|
||||||
@@ -2728,6 +2778,15 @@ webOptions = "Options Web vers PDF"
|
|||||||
wordDoc = "Document Word"
|
wordDoc = "Document Word"
|
||||||
wordDocExt = "Document Word (.docx)"
|
wordDocExt = "Document Word (.docx)"
|
||||||
zoomLevel = "Niveau de zoom"
|
zoomLevel = "Niveau de zoom"
|
||||||
|
cbrToPdf = "CBR → PDF"
|
||||||
|
cbzToPdf = "CBZ → PDF"
|
||||||
|
ebookToPdf = "eBook → PDF"
|
||||||
|
emlToPdf = "Email → PDF"
|
||||||
|
fileToPdf = "Office/Document → PDF"
|
||||||
|
pdfToCbr = "PDF → CBR"
|
||||||
|
pdfToCbz = "PDF → CBZ"
|
||||||
|
pdfToEpub = "PDF → EPUB"
|
||||||
|
svgToPdf = "SVG → PDF"
|
||||||
|
|
||||||
[convert.ebookOptions]
|
[convert.ebookOptions]
|
||||||
ebookOptions = "Options eBook vers PDF"
|
ebookOptions = "Options eBook vers PDF"
|
||||||
@@ -3713,6 +3772,11 @@ desc = "Recherche et affiche tout JavaScript injecté dans un PDF."
|
|||||||
tags = "javascript,code,script"
|
tags = "javascript,code,script"
|
||||||
title = "Afficher le JavaScript"
|
title = "Afficher le JavaScript"
|
||||||
|
|
||||||
|
[home.timestampPdf]
|
||||||
|
desc = "Ajoutez un horodatage RFC 3161 pour prouver la date d'existence de votre PDF"
|
||||||
|
tags = "horodatage,RFC 3161,TSA,autorité de timestamp,horodatage de document,preuve d'existence,jeton d'horodatage,timestamp de confiance,signer horodatage,notariser"
|
||||||
|
title = "Horodater le PDF"
|
||||||
|
|
||||||
[home.sign]
|
[home.sign]
|
||||||
desc = "Ajoutez une signature au PDF avec un dessin, du texte ou une image."
|
desc = "Ajoutez une signature au PDF avec un dessin, du texte ou une image."
|
||||||
tags = "signature,autographe"
|
tags = "signature,autographe"
|
||||||
@@ -3920,6 +3984,13 @@ username = "Nom d’utilisateur"
|
|||||||
verifyingMfa = "Vérification..."
|
verifyingMfa = "Vérification..."
|
||||||
verifyMfa = "Vérifier le code"
|
verifyMfa = "Vérifier le code"
|
||||||
youAreLoggedIn = "Vous êtes connecté !"
|
youAreLoggedIn = "Vous êtes connecté !"
|
||||||
|
backToSignIn = "Back to sign in"
|
||||||
|
passwordUpdatedSuccess = "Your password has been updated successfully."
|
||||||
|
resetHelp = "Enter your email to receive a secure link to reset your password. If the link has expired, please request a new one."
|
||||||
|
resetYourPassword = "Reset your password"
|
||||||
|
sendResetLink = "Send reset link"
|
||||||
|
subtitle = "Sign back in to Stirling PDF"
|
||||||
|
updatePassword = "Update password"
|
||||||
|
|
||||||
[login.slides.edit]
|
[login.slides.edit]
|
||||||
alt = "Modifier des PDF"
|
alt = "Modifier des PDF"
|
||||||
@@ -4280,6 +4351,15 @@ rightRail = "Le <strong>rail de droite</strong> contient des actions rapides pou
|
|||||||
topBar = "La barre supérieure permet de basculer entre la <strong>Visionneuse</strong>, <strong>l’Éditeur de pages</strong> et <strong>les fichiers actifs</strong>."
|
topBar = "La barre supérieure permet de basculer entre la <strong>Visionneuse</strong>, <strong>l’Éditeur de pages</strong> et <strong>les fichiers actifs</strong>."
|
||||||
wrapUp = "Voilà les nouveautés de la V2. Ouvrez le menu <strong>Visites guidées</strong> à tout moment pour rejouer ceci, le parcours des outils ou le parcours Admin."
|
wrapUp = "Voilà les nouveautés de la V2. Ouvrez le menu <strong>Visites guidées</strong> à tout moment pour rejouer ceci, le parcours des outils ou le parcours Admin."
|
||||||
|
|
||||||
|
[onboarding.freeTrial]
|
||||||
|
afterTrialWithPayment = "Your Pro subscription will start automatically when the trial ends."
|
||||||
|
afterTrialWithoutPayment = "After your trial ends, you'll continue with our free tier. Add a payment method to keep Pro access."
|
||||||
|
body = "You have full access to Stirling PDF Pro features during your trial. Enjoy unlimited conversions, larger file sizes, and priority processing."
|
||||||
|
daysRemaining = "{{days}} days remaining"
|
||||||
|
daysRemainingSingular = "{{days}} day remaining"
|
||||||
|
title = "Your 30-Day Pro Trial"
|
||||||
|
trialEnds = "Trial ends {{date}}"
|
||||||
|
|
||||||
[overlay-pdfs]
|
[overlay-pdfs]
|
||||||
desc = "Superposer un PDF sur un autre"
|
desc = "Superposer un PDF sur un autre"
|
||||||
header = "Incrustation de PDF"
|
header = "Incrustation de PDF"
|
||||||
@@ -4401,6 +4481,31 @@ dismiss = "Fermer"
|
|||||||
apply = "Appliquer les modifications"
|
apply = "Appliquer les modifications"
|
||||||
download = "Télécharger le PDF"
|
download = "Télécharger le PDF"
|
||||||
|
|
||||||
|
[viewer.comments]
|
||||||
|
addComment = "Add comment"
|
||||||
|
addCommentPlaceholder = "Add comment..."
|
||||||
|
addLink = "Add link"
|
||||||
|
addReplyPlaceholder = "Add reply..."
|
||||||
|
deleteAnnotationAndComment = "Delete annotation & comment"
|
||||||
|
deleteDescription = "This annotation has a comment attached. You can remove just the comment from the sidebar while keeping the annotation, or delete everything."
|
||||||
|
deleteTitle = "Remove annotation from comments?"
|
||||||
|
goToLink = "Go to link"
|
||||||
|
hint = "Place comments with the Comment, Insert Text, or Replace Text tools. They will appear here by page."
|
||||||
|
locateAnnotation = "Locate in document"
|
||||||
|
moreActions = "More actions"
|
||||||
|
nComments = "{{count}} comments"
|
||||||
|
oneComment = "1 comment"
|
||||||
|
pageLabel = "Page {{page}}"
|
||||||
|
placeholder = "Type your comment..."
|
||||||
|
removeCommentOnly = "Remove comment only"
|
||||||
|
saveReply = "Save reply"
|
||||||
|
send = "Send"
|
||||||
|
title = "Comments"
|
||||||
|
typeComment = "Comment"
|
||||||
|
typeInsertText = "Insert Text"
|
||||||
|
typeReplaceText = "Replace Text"
|
||||||
|
viewComment = "View comment"
|
||||||
|
|
||||||
[rightRail]
|
[rightRail]
|
||||||
closeSelected = "Fermer les fichiers sélectionnés"
|
closeSelected = "Fermer les fichiers sélectionnés"
|
||||||
selectAll = "Tout sélectionner"
|
selectAll = "Tout sélectionner"
|
||||||
@@ -4433,6 +4538,12 @@ exitRedaction = "Quitter le mode de caviardage"
|
|||||||
save = "Enregistrer"
|
save = "Enregistrer"
|
||||||
downloadAll = "Tout télécharger"
|
downloadAll = "Tout télécharger"
|
||||||
saveAll = "Tout enregistrer"
|
saveAll = "Tout enregistrer"
|
||||||
|
readAloud = "Read Aloud"
|
||||||
|
readAloudLanguage = "Language"
|
||||||
|
readAloudSpeed = "Speed"
|
||||||
|
saveAs = "Save As"
|
||||||
|
selectLanguage = "Select language"
|
||||||
|
toggleComments = "Comments"
|
||||||
|
|
||||||
[textAlign]
|
[textAlign]
|
||||||
left = "Gauche"
|
left = "Gauche"
|
||||||
@@ -4980,6 +5091,9 @@ selectPlan = "Choisir un plan"
|
|||||||
showComparison = "Comparer toutes les fonctionnalités"
|
showComparison = "Comparer toutes les fonctionnalités"
|
||||||
upgrade = "Mettre à niveau"
|
upgrade = "Mettre à niveau"
|
||||||
withServer = "+ Plan Server"
|
withServer = "+ Plan Server"
|
||||||
|
purchase = "Purchase"
|
||||||
|
selectCredits = "Select Credit Amount"
|
||||||
|
totalCost = "Total Cost"
|
||||||
|
|
||||||
[plan.activePlan]
|
[plan.activePlan]
|
||||||
subtitle = "Détails de votre abonnement actuel"
|
subtitle = "Détails de votre abonnement actuel"
|
||||||
@@ -5069,6 +5183,30 @@ successMessage = "Votre licence a été activée avec succès. Vous pouvez maint
|
|||||||
name = "Team"
|
name = "Team"
|
||||||
siteLicense = "Licence site"
|
siteLicense = "Licence site"
|
||||||
|
|
||||||
|
[plan.api]
|
||||||
|
large = "5,000 Credits"
|
||||||
|
medium = "1,000 Credits"
|
||||||
|
small = "500 Credits"
|
||||||
|
xsmall = "100 Credits"
|
||||||
|
|
||||||
|
[plan.apiPackages]
|
||||||
|
subtitle = "Purchase API credits for your applications"
|
||||||
|
title = "API Credit Packages"
|
||||||
|
|
||||||
|
[plan.trial]
|
||||||
|
badge = "Trial"
|
||||||
|
continueWithFree = "Continue with Free"
|
||||||
|
daysRemaining = "Your trial ends in {{days}} days"
|
||||||
|
endDate = "Expires: {{date}}"
|
||||||
|
expired = "Your Trial Has Ended"
|
||||||
|
expiredMessage = "Your 30-day Pro trial has expired. Subscribe to Pro to continue accessing premium features, or continue with our free tier."
|
||||||
|
freeTierLimitations = "Free tier includes basic PDF tools with usage limits."
|
||||||
|
message = ""
|
||||||
|
subscribe = "Subscribe to Pro"
|
||||||
|
subscribeToPro = "Subscribe to Pro"
|
||||||
|
subscriptionScheduled = "Subscription scheduled - starts {{date}}"
|
||||||
|
title = "Free Trial Active"
|
||||||
|
|
||||||
[credits]
|
[credits]
|
||||||
enableOverageBilling = "Activer la facturation de dépassement"
|
enableOverageBilling = "Activer la facturation de dépassement"
|
||||||
maybeLater = "Peut-être plus tard"
|
maybeLater = "Peut-être plus tard"
|
||||||
@@ -5631,6 +5769,32 @@ text = "Faites pivoter les pages de votre PDF dans le sens horaire ou antihorair
|
|||||||
[rotate.tooltip.header]
|
[rotate.tooltip.header]
|
||||||
title = "Aperçu des paramètres de rotation"
|
title = "Aperçu des paramètres de rotation"
|
||||||
|
|
||||||
|
[timestampPdf]
|
||||||
|
completed = "PDF horodaté avec succès"
|
||||||
|
desc = "Ajoutez un horodatage RFC 3161 à votre PDF via un serveur d'autorité d'horodatage (TSA) de confiance."
|
||||||
|
filenamePrefix = "horodaté"
|
||||||
|
results = "Résultats de l'horodatage"
|
||||||
|
submit = "Appliquer l'horodatage"
|
||||||
|
title = "Horodater le PDF"
|
||||||
|
|
||||||
|
[timestampPdf.error]
|
||||||
|
failed = "Une erreur est survenue lors de l'horodatage du PDF."
|
||||||
|
generic = "Échec de l'horodatage"
|
||||||
|
|
||||||
|
[timestampPdf.files]
|
||||||
|
placeholder = "Sélectionnez un fichier PDF dans la vue principale pour commencer"
|
||||||
|
|
||||||
|
[timestampPdf.options]
|
||||||
|
note = "Seul le hash SHA-256 de votre document est envoyé au serveur TSA ; le fichier PDF lui-même n'est jamais envoyé au serveur TSA."
|
||||||
|
title = "Serveur d'horodatage (TSA)"
|
||||||
|
|
||||||
|
[timestampPdf.options.tsaUrl]
|
||||||
|
desc = "Choisissez une autorité d'horodatage de confiance"
|
||||||
|
label = "Sélectionner un serveur TSA"
|
||||||
|
|
||||||
|
[timestampPdf.steps]
|
||||||
|
settings = "Paramètres"
|
||||||
|
|
||||||
[sanitize]
|
[sanitize]
|
||||||
completed = "Assainissement effectué avec succès"
|
completed = "Assainissement effectué avec succès"
|
||||||
desc = "Supprimer les éléments potentiellement nuisibles des fichiers PDF."
|
desc = "Supprimer les éléments potentiellement nuisibles des fichiers PDF."
|
||||||
@@ -5772,10 +5936,13 @@ logout = "Se déconnecter"
|
|||||||
server = "Serveur"
|
server = "Serveur"
|
||||||
title = "Mode de connexion"
|
title = "Mode de connexion"
|
||||||
user = "Connecté en tant que"
|
user = "Connecté en tant que"
|
||||||
|
localDescription = "You are using the local backend without an account. Some tools requiring cloud processing or a self-hosted server are unavailable."
|
||||||
|
signIn = "Sign In"
|
||||||
|
|
||||||
[settings.connection.mode]
|
[settings.connection.mode]
|
||||||
saas = "Stirling Cloud"
|
saas = "Stirling Cloud"
|
||||||
selfhosted = "Auto-hébergé"
|
selfhosted = "Auto-hébergé"
|
||||||
|
local = "Local Only"
|
||||||
|
|
||||||
[settings.planBilling]
|
[settings.planBilling]
|
||||||
currentPlan = "Offre actuelle"
|
currentPlan = "Offre actuelle"
|
||||||
@@ -5931,6 +6098,9 @@ title = "Politiques et confidentialité"
|
|||||||
[settings.preferences]
|
[settings.preferences]
|
||||||
title = "Préférences"
|
title = "Préférences"
|
||||||
|
|
||||||
|
[settings.search]
|
||||||
|
placeholder = "Rechercher dans les paramètres…"
|
||||||
|
|
||||||
[settings.security]
|
[settings.security]
|
||||||
description = "Mettez à jour votre mot de passe pour sécuriser votre compte."
|
description = "Mettez à jour votre mot de passe pour sécuriser votre compte."
|
||||||
title = "Sécurité"
|
title = "Sécurité"
|
||||||
@@ -5988,6 +6158,7 @@ sso = "Authentification unique"
|
|||||||
submit = "Se connecter"
|
submit = "Se connecter"
|
||||||
subtitle = "Saisissez vos identifiants pour continuer"
|
subtitle = "Saisissez vos identifiants pour continuer"
|
||||||
title = "Se connecter"
|
title = "Se connecter"
|
||||||
|
skipSignIn = "Continue without signing in"
|
||||||
|
|
||||||
[setup.login.email]
|
[setup.login.email]
|
||||||
label = "Email"
|
label = "Email"
|
||||||
@@ -6023,6 +6194,13 @@ title = "Se connecter à Stirling"
|
|||||||
link = "ou connectez-vous à un compte auto-hébergé"
|
link = "ou connectez-vous à un compte auto-hébergé"
|
||||||
subtitle = "Saisissez les identifiants du serveur"
|
subtitle = "Saisissez les identifiants du serveur"
|
||||||
title = "Se connecter au serveur"
|
title = "Se connecter au serveur"
|
||||||
|
switchToLocal = "Use local tools instead"
|
||||||
|
|
||||||
|
[setup.selfhosted.unreachable]
|
||||||
|
continueOffline = "Use local tools instead"
|
||||||
|
message = "Could not reach {{url}}. Check that the server is running and accessible."
|
||||||
|
retry = "Retry"
|
||||||
|
title = "Cannot connect to server"
|
||||||
|
|
||||||
[setup.server]
|
[setup.server]
|
||||||
subtitle = "Saisissez l’URL de votre serveur auto‑hébergé"
|
subtitle = "Saisissez l’URL de votre serveur auto‑hébergé"
|
||||||
@@ -6036,6 +6214,7 @@ emptyUrl = "Veuillez saisir une URL de serveur"
|
|||||||
invalidUrl = "Format d’URL invalide. Veuillez saisir une URL valide comme https://your-server.com"
|
invalidUrl = "Format d’URL invalide. Veuillez saisir une URL valide comme https://your-server.com"
|
||||||
testFailed = "Échec du test de connexion"
|
testFailed = "Échec du test de connexion"
|
||||||
unreachable = "Connexion au serveur impossible"
|
unreachable = "Connexion au serveur impossible"
|
||||||
|
configFetchError = "Failed to fetch server configuration: {{error}}"
|
||||||
|
|
||||||
[setup.server.error.securityDisabled]
|
[setup.server.error.securityDisabled]
|
||||||
body = "La connexion n'est pas activée sur ce serveur. Pour vous y connecter, vous devez activer l'authentification :"
|
body = "La connexion n'est pas activée sur ce serveur. Pour vous y connecter, vous devez activer l'authentification :"
|
||||||
@@ -6561,6 +6740,7 @@ showDetails = "Afficher les détails"
|
|||||||
unavailable = "Désactivé par l’administrateur du serveur :"
|
unavailable = "Désactivé par l’administrateur du serveur :"
|
||||||
unavailableDependency = "Indisponible - outil requis manquant sur le serveur :"
|
unavailableDependency = "Indisponible - outil requis manquant sur le serveur :"
|
||||||
unfavorite = "Retirer des favoris"
|
unfavorite = "Retirer des favoris"
|
||||||
|
selfHostedOffline = "Requires your Stirling-PDF server (currently offline):"
|
||||||
|
|
||||||
[toolPanel.modePrompt]
|
[toolPanel.modePrompt]
|
||||||
chooseFullscreen = "Utiliser le mode plein écran"
|
chooseFullscreen = "Utiliser le mode plein écran"
|
||||||
@@ -7534,3 +7714,37 @@ description = "Clé API Google pour Google Picker API depuis Google Cloud Consol
|
|||||||
[provider.googledrive.appId]
|
[provider.googledrive.appId]
|
||||||
label = "App ID"
|
label = "App ID"
|
||||||
description = "App ID Google Drive depuis Google Cloud Console"
|
description = "App ID Google Drive depuis Google Cloud Console"
|
||||||
|
|
||||||
|
[selfHosted.offline]
|
||||||
|
hideTools = "Hide unavailable tools ▴"
|
||||||
|
messageNoFallback = "Tools are unavailable until your server comes back online."
|
||||||
|
messageWithFallback = "Some tools require a server connection."
|
||||||
|
showTools = "View unavailable tools ▾"
|
||||||
|
title = "Your Stirling-PDF server is unreachable"
|
||||||
|
toolNotAvailableLocally = "Your Stirling-PDF server is offline and \"{{endpoint}}\" is not available on the local backend."
|
||||||
|
|
||||||
|
[connectionMode.status]
|
||||||
|
localOffline = "Offline mode running"
|
||||||
|
localOnline = "Offline mode running"
|
||||||
|
saas = "Connected to Stirling Cloud"
|
||||||
|
selfhostedChecking = "Connected to self-hosted server (checking...)"
|
||||||
|
selfhostedOffline = "Self-hosted server unreachable"
|
||||||
|
selfhostedOnline = "Connected to self-hosted server"
|
||||||
|
|
||||||
|
[localMode]
|
||||||
|
toolUnavailable = "This tool requires an account. Sign in to Stirling Cloud or connect to a self-hosted server to use it."
|
||||||
|
|
||||||
|
[localMode.banner]
|
||||||
|
message = "Sign in to unlock all tools."
|
||||||
|
signIn = "Sign In"
|
||||||
|
title = "Running locally"
|
||||||
|
|
||||||
|
[localMode.toolPicker]
|
||||||
|
message = "Sign in to unlock all tools."
|
||||||
|
signIn = "Sign In"
|
||||||
|
|
||||||
|
[tool]
|
||||||
|
endpointUnavailable = "This tool is unavailable on your server."
|
||||||
|
endpointUnavailableClickable = "Not available in this mode. Click to sign in."
|
||||||
|
invalidParams = "Fill in the required settings."
|
||||||
|
noFiles = "Add a file to get started."
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { Stack, Text, Select } from "@mantine/core";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import {
|
||||||
|
TimestampPdfParameters,
|
||||||
|
FALLBACK_TSA_PRESETS,
|
||||||
|
} from "@app/hooks/tools/timestampPdf/useTimestampPdfParameters";
|
||||||
|
import { useAppConfig } from "@app/contexts/AppConfigContext";
|
||||||
|
|
||||||
|
interface TimestampPdfSettingsProps {
|
||||||
|
parameters: TimestampPdfParameters;
|
||||||
|
onParameterChange: <K extends keyof TimestampPdfParameters>(
|
||||||
|
key: K,
|
||||||
|
value: TimestampPdfParameters[K]
|
||||||
|
) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TimestampPdfSettings = ({
|
||||||
|
parameters,
|
||||||
|
onParameterChange,
|
||||||
|
disabled = false,
|
||||||
|
}: TimestampPdfSettingsProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { config } = useAppConfig();
|
||||||
|
const defaultApplied = useRef(false);
|
||||||
|
|
||||||
|
// Use backend presets (single source of truth) with fallback (TASK-10)
|
||||||
|
const presets = config?.timestampTsaPresets ?? FALLBACK_TSA_PRESETS;
|
||||||
|
|
||||||
|
// Build dropdown: presets + admin custom URLs, deduplicated (TASK-9)
|
||||||
|
const presetUrls = new Set(presets.map((p) => p.url));
|
||||||
|
const adminCustomUrls = (config?.timestampCustomTsaUrls ?? []).filter(
|
||||||
|
(url) => !presetUrls.has(url)
|
||||||
|
);
|
||||||
|
const selectData = [
|
||||||
|
...presets.map((preset) => ({ value: preset.url, label: preset.label })),
|
||||||
|
...adminCustomUrls.map((url) => ({ value: url, label: url })),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apply admin default TSA URL on first config load (TASK-2)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!defaultApplied.current && config?.timestampDefaultTsaUrl) {
|
||||||
|
defaultApplied.current = true;
|
||||||
|
const adminDefault = config.timestampDefaultTsaUrl;
|
||||||
|
if (adminDefault && adminDefault !== parameters.tsaUrl) {
|
||||||
|
onParameterChange("tsaUrl", adminDefault);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [config?.timestampDefaultTsaUrl]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text size="sm" fw={500}>
|
||||||
|
{t("timestampPdf.options.title", "Timestamp Server (TSA)")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={t("timestampPdf.options.tsaUrl.label", "Select a TSA server")}
|
||||||
|
description={t(
|
||||||
|
"timestampPdf.options.tsaUrl.desc",
|
||||||
|
"Pick a trusted Time Stamp Authority"
|
||||||
|
)}
|
||||||
|
data={selectData}
|
||||||
|
value={parameters.tsaUrl}
|
||||||
|
onChange={(value) => onParameterChange("tsaUrl", value ?? presets[0].url)}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Text size="xs" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"timestampPdf.options.note",
|
||||||
|
"Only a SHA-256 hash of your document is sent to the TSA server; the PDF file itself is never sent to the TSA server."
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TimestampPdfSettings;
|
||||||
@@ -43,6 +43,7 @@ import FormFill from "@app/tools/formFill/FormFill";
|
|||||||
import RemoveCertificateSign from "@app/tools/RemoveCertificateSign";
|
import RemoveCertificateSign from "@app/tools/RemoveCertificateSign";
|
||||||
import RemoveImage from "@app/tools/RemoveImage";
|
import RemoveImage from "@app/tools/RemoveImage";
|
||||||
import CertSign from "@app/tools/CertSign";
|
import CertSign from "@app/tools/CertSign";
|
||||||
|
import TimestampPdf from "@app/tools/TimestampPdf";
|
||||||
import BookletImposition from "@app/tools/BookletImposition";
|
import BookletImposition from "@app/tools/BookletImposition";
|
||||||
import Flatten from "@app/tools/Flatten";
|
import Flatten from "@app/tools/Flatten";
|
||||||
import Rotate from "@app/tools/Rotate";
|
import Rotate from "@app/tools/Rotate";
|
||||||
@@ -69,6 +70,7 @@ import { convertOperationConfig } from "@app/hooks/tools/convert/useConvertOpera
|
|||||||
import { removeCertificateSignOperationConfig } from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
import { removeCertificateSignOperationConfig } from "@app/hooks/tools/removeCertificateSign/useRemoveCertificateSignOperation";
|
||||||
import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
|
import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
|
||||||
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
|
import { certSignOperationConfig } from "@app/hooks/tools/certSign/useCertSignOperation";
|
||||||
|
import { timestampPdfOperationConfig } from "@app/hooks/tools/timestampPdf/useTimestampPdfOperation";
|
||||||
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
|
import { bookletImpositionOperationConfig } from "@app/hooks/tools/bookletImposition/useBookletImpositionOperation";
|
||||||
import { mergeOperationConfig } from '@app/hooks/tools/merge/useMergeOperation';
|
import { mergeOperationConfig } from '@app/hooks/tools/merge/useMergeOperation';
|
||||||
import { editTableOfContentsOperationConfig } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation';
|
import { editTableOfContentsOperationConfig } from '@app/hooks/tools/editTableOfContents/useEditTableOfContentsOperation';
|
||||||
@@ -213,6 +215,19 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
|
|||||||
operationConfig: certSignOperationConfig,
|
operationConfig: certSignOperationConfig,
|
||||||
automationSettings: CertSignAutomationSettings,
|
automationSettings: CertSignAutomationSettings,
|
||||||
},
|
},
|
||||||
|
timestampPdf: {
|
||||||
|
icon: <LocalIcon icon="schedule-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
|
name: t("home.timestampPdf.title", "Timestamp PDF"),
|
||||||
|
component: TimestampPdf,
|
||||||
|
description: t("home.timestampPdf.desc", "Add an RFC 3161 document timestamp to prove when your PDF existed"),
|
||||||
|
categoryId: ToolCategoryId.STANDARD_TOOLS,
|
||||||
|
subcategoryId: SubcategoryId.SIGNING,
|
||||||
|
maxFiles: -1,
|
||||||
|
endpoints: ["timestamp-pdf"],
|
||||||
|
operationConfig: timestampPdfOperationConfig,
|
||||||
|
automationSettings: null,
|
||||||
|
synonyms: getSynonyms(t, "timestampPdf"),
|
||||||
|
},
|
||||||
sign: {
|
sign: {
|
||||||
icon: <LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />,
|
icon: <LocalIcon icon="signature-rounded" width="1.5rem" height="1.5rem" />,
|
||||||
name: t("home.sign.title", "Sign"),
|
name: t("home.sign.title", "Sign"),
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ToolType, useToolOperation } from '@app/hooks/tools/shared/useToolOperation';
|
||||||
|
import { createStandardErrorHandler } from '@app/utils/toolErrorHandler';
|
||||||
|
import { TimestampPdfParameters, defaultParameters } from '@app/hooks/tools/timestampPdf/useTimestampPdfParameters';
|
||||||
|
|
||||||
|
export const buildTimestampPdfFormData = (parameters: TimestampPdfParameters, file: File): FormData => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('fileInput', file);
|
||||||
|
|
||||||
|
formData.append('tsaUrl', parameters.tsaUrl);
|
||||||
|
|
||||||
|
return formData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const timestampPdfOperationConfig = {
|
||||||
|
toolType: ToolType.singleFile,
|
||||||
|
buildFormData: buildTimestampPdfFormData,
|
||||||
|
operationType: 'timestampPdf',
|
||||||
|
endpoint: '/api/v1/security/timestamp-pdf',
|
||||||
|
multiFileEndpoint: false,
|
||||||
|
defaultParameters,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const useTimestampPdfOperation = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return useToolOperation<TimestampPdfParameters>({
|
||||||
|
...timestampPdfOperationConfig,
|
||||||
|
getErrorMessage: createStandardErrorHandler(
|
||||||
|
t('timestampPdf.error.failed', 'An error occurred while timestamping the PDF.')
|
||||||
|
),
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { BaseParameters } from '@app/types/parameters';
|
||||||
|
import { useBaseParameters, BaseParametersHook } from '@app/hooks/tools/shared/useBaseParameters';
|
||||||
|
|
||||||
|
// Fallback presets used only when AppConfig hasn't loaded yet.
|
||||||
|
// The real presets are served by the backend via /api/v1/config/app-config
|
||||||
|
// (field: timestampTsaPresets) — single source of truth.
|
||||||
|
export const FALLBACK_TSA_PRESETS = [
|
||||||
|
{ label: 'DigiCert', url: 'http://timestamp.digicert.com' },
|
||||||
|
{ label: 'Sectigo', url: 'http://timestamp.sectigo.com' },
|
||||||
|
{ label: 'SSL.com', url: 'http://ts.ssl.com' },
|
||||||
|
{ label: 'FreeTSA', url: 'https://freetsa.org/tsr' },
|
||||||
|
{ label: 'MeSign', url: 'http://tsa.mesign.com' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface TimestampPdfParameters extends BaseParameters {
|
||||||
|
tsaUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultParameters: TimestampPdfParameters = {
|
||||||
|
tsaUrl: FALLBACK_TSA_PRESETS[0].url,
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimestampPdfParametersHook = BaseParametersHook<TimestampPdfParameters>;
|
||||||
|
|
||||||
|
export const useTimestampPdfParameters = (): TimestampPdfParametersHook => {
|
||||||
|
return useBaseParameters({
|
||||||
|
defaultParameters,
|
||||||
|
endpointName: 'timestamp-pdf',
|
||||||
|
validateFn: (params) => {
|
||||||
|
return params.tsaUrl.trim().length > 0;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { createToolFlow } from "@app/components/tools/shared/createToolFlow";
|
||||||
|
import TimestampPdfSettings from "@app/components/tools/timestampPdf/TimestampPdfSettings";
|
||||||
|
import { useTimestampPdfParameters } from "@app/hooks/tools/timestampPdf/useTimestampPdfParameters";
|
||||||
|
import { useTimestampPdfOperation } from "@app/hooks/tools/timestampPdf/useTimestampPdfOperation";
|
||||||
|
import { useBaseTool } from "@app/hooks/tools/shared/useBaseTool";
|
||||||
|
import { BaseToolProps, ToolComponent } from "@app/types/tool";
|
||||||
|
|
||||||
|
const TimestampPdf = (props: BaseToolProps) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const base = useBaseTool(
|
||||||
|
"timestampPdf",
|
||||||
|
useTimestampPdfParameters,
|
||||||
|
useTimestampPdfOperation,
|
||||||
|
props
|
||||||
|
);
|
||||||
|
|
||||||
|
return createToolFlow({
|
||||||
|
files: {
|
||||||
|
selectedFiles: base.selectedFiles,
|
||||||
|
isCollapsed: base.hasResults,
|
||||||
|
},
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
title: t("timestampPdf.steps.settings", "Settings"),
|
||||||
|
isCollapsed: base.settingsCollapsed,
|
||||||
|
onCollapsedClick: base.settingsCollapsed ? base.handleSettingsReset : undefined,
|
||||||
|
content: (
|
||||||
|
<TimestampPdfSettings
|
||||||
|
parameters={base.params.parameters}
|
||||||
|
onParameterChange={base.params.updateParameter}
|
||||||
|
disabled={base.endpointLoading}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
executeButton: {
|
||||||
|
text: t("timestampPdf.submit", "Apply Timestamp"),
|
||||||
|
isVisible: !base.hasResults,
|
||||||
|
loadingText: t("loading"),
|
||||||
|
onClick: base.handleExecute,
|
||||||
|
disabled:
|
||||||
|
!base.params.validateParameters() ||
|
||||||
|
!base.hasFiles ||
|
||||||
|
!base.endpointEnabled,
|
||||||
|
},
|
||||||
|
review: {
|
||||||
|
isVisible: base.hasResults,
|
||||||
|
operation: base.operation,
|
||||||
|
title: t("timestampPdf.results", "Timestamp Results"),
|
||||||
|
onFileClick: base.handleThumbnailClick,
|
||||||
|
onUndo: base.handleUndo,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
TimestampPdf.tool = () => useTimestampPdfOperation;
|
||||||
|
|
||||||
|
export default TimestampPdf as ToolComponent;
|
||||||
@@ -50,6 +50,9 @@ export interface AppConfig {
|
|||||||
googleDriveClientId?: string;
|
googleDriveClientId?: string;
|
||||||
googleDriveApiKey?: string;
|
googleDriveApiKey?: string;
|
||||||
googleDriveAppId?: string;
|
googleDriveAppId?: string;
|
||||||
|
timestampDefaultTsaUrl?: string;
|
||||||
|
timestampCustomTsaUrls?: string[];
|
||||||
|
timestampTsaPresets?: { label: string; url: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
|
export type AppConfigBootstrapMode = 'blocking' | 'non-blocking';
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ export const CORE_REGULAR_TOOL_IDS = [
|
|||||||
'overlayPdfs',
|
'overlayPdfs',
|
||||||
'getPdfInfo',
|
'getPdfInfo',
|
||||||
'validateSignature',
|
'validateSignature',
|
||||||
|
'timestampPdf',
|
||||||
'replaceColor',
|
'replaceColor',
|
||||||
'showJS',
|
'showJS',
|
||||||
'bookletImposition',
|
'bookletImposition',
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export const TOOL_CREDIT_COSTS: Record<ToolId, number> = {
|
|||||||
convert: CREDIT_COSTS.LARGE,
|
convert: CREDIT_COSTS.LARGE,
|
||||||
ocr: CREDIT_COSTS.LARGE,
|
ocr: CREDIT_COSTS.LARGE,
|
||||||
certSign: CREDIT_COSTS.LARGE,
|
certSign: CREDIT_COSTS.LARGE,
|
||||||
|
timestampPdf: CREDIT_COSTS.LARGE,
|
||||||
|
|
||||||
// Extra large operations (10 credits)
|
// Extra large operations (10 credits)
|
||||||
automate: CREDIT_COSTS.XLARGE,
|
automate: CREDIT_COSTS.XLARGE,
|
||||||
|
|||||||
Reference in New Issue
Block a user