From 8bbfbd63d7b04ddd4d798d617797bb2a9dbf65a4 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Tue, 24 Mar 2026 18:00:33 +0100 Subject: [PATCH] feat(security): add RFC 3161 PDF timestamp tool (#5855) Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> --- .../SPDF/config/EndpointConfiguration.java | 2 + .../common/model/ApplicationProperties.java | 7 + .../controller/api/misc/ConfigController.java | 8 + .../api/security/TimestampController.java | 264 ++++++++++++++++ .../api/security/TimestampPdfRequest.java | 24 ++ .../src/main/resources/settings.yml.template | 5 +- .../api/security/TimestampControllerTest.java | 288 ++++++++++++++++++ .../public/locales/en-GB/translation.toml | 31 ++ .../public/locales/fr-FR/translation.toml | 214 +++++++++++++ .../timestampPdf/TimestampPdfSettings.tsx | 80 +++++ .../core/data/useTranslatedToolRegistry.tsx | 15 + .../timestampPdf/useTimestampPdfOperation.ts | 33 ++ .../timestampPdf/useTimestampPdfParameters.ts | 33 ++ frontend/src/core/tools/TimestampPdf.tsx | 60 ++++ frontend/src/core/types/appConfig.ts | 3 + frontend/src/core/types/toolId.ts | 1 + frontend/src/proprietary/utils/creditCosts.ts | 1 + 17 files changed, 1068 insertions(+), 1 deletion(-) create mode 100644 app/core/src/main/java/stirling/software/SPDF/controller/api/security/TimestampController.java create mode 100644 app/core/src/main/java/stirling/software/SPDF/model/api/security/TimestampPdfRequest.java create mode 100644 app/core/src/test/java/stirling/software/SPDF/controller/api/security/TimestampControllerTest.java create mode 100644 frontend/src/core/components/tools/timestampPdf/TimestampPdfSettings.tsx create mode 100644 frontend/src/core/hooks/tools/timestampPdf/useTimestampPdfOperation.ts create mode 100644 frontend/src/core/hooks/tools/timestampPdf/useTimestampPdfParameters.ts create mode 100644 frontend/src/core/tools/TimestampPdf.tsx diff --git a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java index f611ce7e09..9d3b92cd52 100644 --- a/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java +++ b/app/common/src/main/java/stirling/software/SPDF/config/EndpointConfiguration.java @@ -356,6 +356,7 @@ public class EndpointConfiguration { addEndpointToGroup("Security", "cert-sign"); addEndpointToGroup("Security", "remove-cert-sign"); addEndpointToGroup("Security", "sanitize-pdf"); + addEndpointToGroup("Security", "timestamp-pdf"); addEndpointToGroup("Security", "auto-redact"); addEndpointToGroup("Security", "validate-signature"); addEndpointToGroup("Security", "add-stamp"); @@ -472,6 +473,7 @@ public class EndpointConfiguration { addEndpointToGroup("Java", "auto-rename"); addEndpointToGroup("Java", "auto-split-pdf"); addEndpointToGroup("Java", "sanitize-pdf"); + addEndpointToGroup("Java", "timestamp-pdf"); addEndpointToGroup("Java", "crop"); addEndpointToGroup("Java", "get-info-on-pdf"); addEndpointToGroup("Java", "pdf-to-single-page"); diff --git a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java index 04c16198aa..82346d92fc 100644 --- a/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java +++ b/app/common/src/main/java/stirling/software/common/model/ApplicationProperties.java @@ -251,6 +251,7 @@ public class ApplicationProperties { private String customGlobalAPIKey; private Jwt jwt = new Jwt(); private Validation validation = new Validation(); + private Timestamp timestamp = new Timestamp(); private String xFrameOptions = "DENY"; public Boolean isAltLogin() { @@ -569,6 +570,12 @@ public class ApplicationProperties { private boolean hardFail = false; } } + + @Data + public static class Timestamp { + private String defaultTsaUrl = "http://timestamp.digicert.com"; + private List customTsaUrls = new ArrayList<>(); + } } @Data diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java index 727f6c9e78..c1cf56aec8 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/misc/ConfigController.java @@ -17,6 +17,7 @@ import lombok.extern.slf4j.Slf4j; import stirling.software.SPDF.config.EndpointConfiguration; import stirling.software.SPDF.config.EndpointConfiguration.EndpointAvailability; import stirling.software.SPDF.config.InitialSetup; +import stirling.software.SPDF.controller.api.security.TimestampController; import stirling.software.common.annotations.api.ConfigApi; import stirling.software.common.configuration.AppConfig; import stirling.software.common.model.ApplicationProperties; @@ -245,6 +246,13 @@ public class ConfigController { // Premium/Enterprise settings 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 configData.put( "serverCertificateEnabled", diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TimestampController.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TimestampController.java new file mode 100644 index 0000000000..f4259e682c --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/security/TimestampController.java @@ -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> 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 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 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 allowedUrls = new HashSet<>(ALLOWED_TSA_PRESET_URLS); + if (tsConfig.getDefaultTsaUrl() != null + && !tsConfig.getDefaultTsaUrl().isBlank() + && isValidTsaUrlProtocol(tsConfig.getDefaultTsaUrl())) { + allowedUrls.add(tsConfig.getDefaultTsaUrl()); + } + List 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 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 ""; + } + } +} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/api/security/TimestampPdfRequest.java b/app/core/src/main/java/stirling/software/SPDF/model/api/security/TimestampPdfRequest.java new file mode 100644 index 0000000000..8d4c1484b7 --- /dev/null +++ b/app/core/src/main/java/stirling/software/SPDF/model/api/security/TimestampPdfRequest.java @@ -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; +} diff --git a/app/core/src/main/resources/settings.yml.template b/app/core/src/main/resources/settings.yml.template index de1f881f76..43eb518a9c 100644 --- a/app/core/src/main/resources/settings.yml.template +++ b/app/core/src/main/resources/settings.yml.template @@ -65,7 +65,7 @@ security: enableKeyRotation: true # Set to 'true' to enable key pair rotation enableKeyCleanup: true # Set to 'true' to enable key pair cleanup tokenExpiryMinutes: 1440 # JWT access token lifetime in minutes for web clients (1 day). - desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days). + desktopTokenExpiryMinutes: 43200 # JWT access token lifetime in minutes for desktop clients (30 days). allowedClockSkewSeconds: 60 # Allowed JWT validation clock skew in seconds to tolerate small client/server time drift. refreshGraceMinutes: 15 # Allow refresh using an expired access token only within this many minutes after expiry. validation: # PDF signature validation settings @@ -84,6 +84,9 @@ security: 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) + 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 premium: diff --git a/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TimestampControllerTest.java b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TimestampControllerTest.java new file mode 100644 index 0000000000..00c11bb232 --- /dev/null +++ b/app/core/src/test/java/stirling/software/SPDF/controller/api/security/TimestampControllerTest.java @@ -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 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); + } + } + } +} diff --git a/frontend/public/locales/en-GB/translation.toml b/frontend/public/locales/en-GB/translation.toml index 0f29d5cef4..70d1c41fcd 100644 --- a/frontend/public/locales/en-GB/translation.toml +++ b/frontend/public/locales/en-GB/translation.toml @@ -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" 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] 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" @@ -5718,6 +5723,32 @@ text = "Rotate your PDF pages clockwise or anticlockwise in 90-degree increments [rotate.tooltip.header] 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] completed = "Sanitisation completed successfully" desc = "Remove potentially harmful elements from PDF files." diff --git a/frontend/public/locales/fr-FR/translation.toml b/frontend/public/locales/fr-FR/translation.toml index f2eeb353ae..fac8d10387 100644 --- a/frontend/public/locales/fr-FR/translation.toml +++ b/frontend/public/locales/fr-FR/translation.toml @@ -142,6 +142,10 @@ welcome = "Bienvenue" white = "Blanc" WorkInProgess = "En cours de développement, merci de nous remonter les problèmes que vous pourriez constater!" 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] accountSettings = "Paramètres du compte" @@ -1557,6 +1561,13 @@ underline = "Souligner" undo = "Annuler" unsupportedType = "Ce type d’annotation n’est pas entièrement pris en charge pour l’édition." width = "Largeur" +annotationStyle = "Annotation style" +comment = "Comment" +comments = "Comments" +insertText = "Insert Text" +lineArrow = "Arrow" +polyline = "Polyline" +replaceText = "Replace Text" [app] description = "L’alternative gratuite à Adobe Acrobat (10M+ téléchargements)" @@ -2574,10 +2585,22 @@ title = "Réglage de la qualité" [compressPdfs] tags = "compresser,réduire,taille,squish,small,tiny" +[config] +plan = "Plan" + [config.account.overview] 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" 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] 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é" 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] chartAriaLabel = "Utilisation des crédits : inclus {{includedUsed}} sur {{includedTotal}}, achetés {{purchasedUsed}} sur {{purchasedTotal}}" copyKeyAriaLabel = "Copier la clé API" @@ -2614,6 +2663,7 @@ refreshAriaLabel = "Actualiser la clé API" schemaLink = "Référence du schéma API" totalCredits = "Crédits totaux" usage = "Incluez cette clé dans l’en-tête X-API-KEY pour toutes les requêtes API." +creditsRemaining = "Credits Remaining" [config.apiKeys.alert] apiKeyErrorTitle = "Erreur de clé API" @@ -2728,6 +2778,15 @@ webOptions = "Options Web vers PDF" wordDoc = "Document Word" wordDocExt = "Document Word (.docx)" 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] ebookOptions = "Options eBook vers PDF" @@ -3713,6 +3772,11 @@ desc = "Recherche et affiche tout JavaScript injecté dans un PDF." tags = "javascript,code,script" 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] desc = "Ajoutez une signature au PDF avec un dessin, du texte ou une image." tags = "signature,autographe" @@ -3920,6 +3984,13 @@ username = "Nom d’utilisateur" verifyingMfa = "Vérification..." verifyMfa = "Vérifier le code" 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] alt = "Modifier des PDF" @@ -4280,6 +4351,15 @@ rightRail = "Le rail de droite contient des actions rapides pou topBar = "La barre supérieure permet de basculer entre la Visionneuse, l’Éditeur de pages et les fichiers actifs." wrapUp = "Voilà les nouveautés de la V2. Ouvrez le menu Visites guidées à 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] desc = "Superposer un PDF sur un autre" header = "Incrustation de PDF" @@ -4401,6 +4481,31 @@ dismiss = "Fermer" apply = "Appliquer les modifications" 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] closeSelected = "Fermer les fichiers sélectionnés" selectAll = "Tout sélectionner" @@ -4433,6 +4538,12 @@ exitRedaction = "Quitter le mode de caviardage" save = "Enregistrer" downloadAll = "Tout télécharger" saveAll = "Tout enregistrer" +readAloud = "Read Aloud" +readAloudLanguage = "Language" +readAloudSpeed = "Speed" +saveAs = "Save As" +selectLanguage = "Select language" +toggleComments = "Comments" [textAlign] left = "Gauche" @@ -4980,6 +5091,9 @@ selectPlan = "Choisir un plan" showComparison = "Comparer toutes les fonctionnalités" upgrade = "Mettre à niveau" withServer = "+ Plan Server" +purchase = "Purchase" +selectCredits = "Select Credit Amount" +totalCost = "Total Cost" [plan.activePlan] 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" 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] enableOverageBilling = "Activer la facturation de dépassement" 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] 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] completed = "Assainissement effectué avec succès" desc = "Supprimer les éléments potentiellement nuisibles des fichiers PDF." @@ -5772,10 +5936,13 @@ logout = "Se déconnecter" server = "Serveur" title = "Mode de connexion" 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] saas = "Stirling Cloud" selfhosted = "Auto-hébergé" +local = "Local Only" [settings.planBilling] currentPlan = "Offre actuelle" @@ -5931,6 +6098,9 @@ title = "Politiques et confidentialité" [settings.preferences] title = "Préférences" +[settings.search] +placeholder = "Rechercher dans les paramètres…" + [settings.security] description = "Mettez à jour votre mot de passe pour sécuriser votre compte." title = "Sécurité" @@ -5988,6 +6158,7 @@ sso = "Authentification unique" submit = "Se connecter" subtitle = "Saisissez vos identifiants pour continuer" title = "Se connecter" +skipSignIn = "Continue without signing in" [setup.login.email] label = "Email" @@ -6023,6 +6194,13 @@ title = "Se connecter à Stirling" link = "ou connectez-vous à un compte auto-hébergé" subtitle = "Saisissez les identifiants du 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] 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" testFailed = "Échec du test de connexion" unreachable = "Connexion au serveur impossible" +configFetchError = "Failed to fetch server configuration: {{error}}" [setup.server.error.securityDisabled] 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 :" unavailableDependency = "Indisponible - outil requis manquant sur le serveur :" unfavorite = "Retirer des favoris" +selfHostedOffline = "Requires your Stirling-PDF server (currently offline):" [toolPanel.modePrompt] 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] label = "App ID" 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." diff --git a/frontend/src/core/components/tools/timestampPdf/TimestampPdfSettings.tsx b/frontend/src/core/components/tools/timestampPdf/TimestampPdfSettings.tsx new file mode 100644 index 0000000000..55eb923961 --- /dev/null +++ b/frontend/src/core/components/tools/timestampPdf/TimestampPdfSettings.tsx @@ -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: ( + 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 ( + + + {t("timestampPdf.options.title", "Timestamp Server (TSA)")} + + +