diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..f152305 --- /dev/null +++ b/.env.development @@ -0,0 +1,2 @@ +# Dev-only: route TSA/certificate requests through the built-in Vite CORS proxy +VITE_CORS_PROXY_URL=/cors-proxy diff --git a/public/locales/ar/tools.json b/public/locales/ar/tools.json index a4a3795..e8e86ef 100644 --- a/public/locales/ar/tools.json +++ b/public/locales/ar/tools.json @@ -690,6 +690,15 @@ "pageTitle": "التحقق من توقيع PDF - التحقق من التوقيعات الرقمية | BentoPDF", "subtitle": "التحقق من التوقيعات الرقمية في ملفات PDF. تحقق من صلاحية الشهادة، واعرض تفاصيل الموقّع، وتأكد من سلامة المستند. تتم جميع المعالجة في متصفحك." }, + "timestampPdf": { + "name": "ختم PDF بالوقت", + "pageTitle": "ختم PDF بالوقت مجاناً - ختم RFC 3161 | BentoPDF", + "subtitle": "أضف ختماً زمنياً RFC 3161 إلى ملف PDF باستخدام خادم TSA موثوق. يثبت أن مستندك كان موجوداً في وقت محدد. لا يتطلب شهادة.", + "tsaSectionTitle": "خادم الختم الزمني (TSA)", + "selectTsa": "اختر خادم TSA", + "applyTimestamp": "تطبيق الختم الزمني", + "successMessage": "تم ختم PDF بالوقت بنجاح! يمكن التحقق من الختم في Adobe Acrobat وقارئات PDF الأخرى." + }, "emailToPdf": { "name": "بريد إلكتروني إلى PDF", "subtitle": "تحويل ملفات البريد الإلكتروني (EML، MSG) إلى تنسيق PDF. يدعم تصديرات Outlook وتنسيقات البريد القياسية.", diff --git a/public/locales/be/tools.json b/public/locales/be/tools.json index ebfb9e8..c8c29a3 100644 --- a/public/locales/be/tools.json +++ b/public/locales/be/tools.json @@ -690,6 +690,15 @@ "pageTitle": "Праверыць подпіс PDF - Верыфікацыя лічбавых подпісаў | BentoPDF", "subtitle": "Праверыць лічбавыя подпісы ў PDF. Праверыць сапраўднасць сертыфіката, паглядзець дэталі падпісанта і пацвердзіць надзейнасць дакумента. Уся апрацоўка адбываецца ў вашым браўзеры." }, + "timestampPdf": { + "name": "Пазначыць PDF часам", + "pageTitle": "Часовая пазнака PDF бясплатна - RFC 3161 | BentoPDF", + "subtitle": "Дадайце часовую пазнаку RFC 3161 да PDF праз давераны сервер TSA. Даказвае, што ваш дакумент існаваў у пэўны момант часу. Сертыфікат не патрабуецца.", + "tsaSectionTitle": "Сервер часовых пазнак (TSA)", + "selectTsa": "Абраць сервер TSA", + "applyTimestamp": "Прымяніць часовую пазнаку", + "successMessage": "PDF паспяхова пазначаны часам! Пазнаку можна праверыць у Adobe Acrobat і іншых PDF-праглядчыках." + }, "emailToPdf": { "name": "Email у PDF", "subtitle": "Канвертаваць файлы email (EML, MSG) у PDF. Падтрымлівае экспарты Outlook і стандартныя email-фарматы.", diff --git a/public/locales/da/tools.json b/public/locales/da/tools.json index 1f46545..d8deb85 100644 --- a/public/locales/da/tools.json +++ b/public/locales/da/tools.json @@ -688,7 +688,16 @@ "validateSignaturePdf": { "name": "Validér PDF-signatur", "pageTitle": "Validér PDF-signatur - Verificér digitale signaturer | BentoPDF", - "subtitle": "Tjek digitale signaturer i dine PDF’er. Verificér certifikater, se underskriverdetaljer og bekræft dokumentintegritet." + "subtitle": "Tjek digitale signaturer i dine PDF'er. Verificér certifikater, se underskriverdetaljer og bekræft dokumentintegritet." + }, + "timestampPdf": { + "name": "Tidsstempl PDF", + "pageTitle": "Tidsstempl PDF gratis online - RFC 3161 dokumenttidsstempel | BentoPDF", + "subtitle": "Tilføj et RFC 3161 tidsstempel til din PDF via en betroet TSA-server. Beviser at dit dokument eksisterede på et bestemt tidspunkt. Intet certifikat påkrævet.", + "tsaSectionTitle": "Tidsstempelserver (TSA)", + "selectTsa": "Vælg en TSA-server", + "applyTimestamp": "Anvend tidsstempel", + "successMessage": "PDF tidsstemplet med succes! Tidsstemplet kan verificeres i Adobe Acrobat og andre PDF-læsere." }, "emailToPdf": { "name": "Email til PDF", diff --git a/public/locales/de/tools.json b/public/locales/de/tools.json index a63bfd8..75a7e58 100644 --- a/public/locales/de/tools.json +++ b/public/locales/de/tools.json @@ -740,6 +740,15 @@ "pageTitle": "PDF-Signatur überprüfen - Digitale Signaturen verifizieren | BentoPDF", "subtitle": "Überprüfen Sie digitale Signaturen in Ihren PDF-Dateien. Prüfen Sie die Zertifikatsgültigkeit, sehen Sie Unterzeichnerdetails und bestätigen Sie die Dokumentenintegrität. Die gesamte Verarbeitung erfolgt in Ihrem Browser." }, + "timestampPdf": { + "name": "PDF mit Zeitstempel versehen", + "pageTitle": "PDF-Zeitstempel online kostenlos - RFC 3161 Dokumentenzeitstempel | BentoPDF", + "subtitle": "Fügen Sie Ihrem PDF einen RFC 3161-Zeitstempel über einen vertrauenswürdigen Zeitstempelserver (TSA) hinzu. Beweist, dass Ihr Dokument zu einem bestimmten Zeitpunkt existierte. Kein Zertifikat erforderlich.", + "tsaSectionTitle": "Zeitstempelserver (TSA)", + "selectTsa": "TSA-Server auswählen", + "applyTimestamp": "Zeitstempel anwenden", + "successMessage": "PDF erfolgreich mit Zeitstempel versehen! Der Zeitstempel kann in Adobe Acrobat und anderen PDF-Readern überprüft werden." + }, "emailToPdf": { "name": "E-Mail zu PDF", "subtitle": "E-Mail-Dateien (EML, MSG) in PDF-Format konvertieren. Unterstützt Outlook-Exporte und Standard-E-Mail-Formate.", diff --git a/public/locales/en/tools.json b/public/locales/en/tools.json index 380ca65..0e99171 100644 --- a/public/locales/en/tools.json +++ b/public/locales/en/tools.json @@ -748,6 +748,15 @@ "pageTitle": "Validate PDF Signature - Verify Digital Signatures | BentoPDF", "subtitle": "Verify digital signatures in your PDF files. Check certificate validity, view signer details, and confirm document integrity. All processing happens in your browser." }, + "timestampPdf": { + "name": "Timestamp PDF", + "pageTitle": "Timestamp PDF Online Free - RFC 3161 Document Timestamp | BentoPDF", + "subtitle": "Add an RFC 3161 document timestamp to your PDF using a trusted Time Stamp Authority (TSA) server. Proves your document existed at a specific point in time. No certificate required.", + "tsaSectionTitle": "Timestamp Server (TSA)", + "selectTsa": "Select a TSA server", + "applyTimestamp": "Apply Timestamp", + "successMessage": "PDF timestamped successfully! The timestamp can be verified in Adobe Acrobat and other PDF readers." + }, "emailToPdf": { "name": "Email to PDF", "subtitle": "Convert email files (EML, MSG) to PDF format. Supports Outlook exports and standard email formats.", diff --git a/public/locales/es/tools.json b/public/locales/es/tools.json index 3c49e98..9f1e59a 100644 --- a/public/locales/es/tools.json +++ b/public/locales/es/tools.json @@ -690,6 +690,15 @@ "pageTitle": "Validar Firma PDF - Verificar Firmas Digitales | BentoPDF", "subtitle": "Verifica firmas digitales en tus archivos PDF. Comprueba la validez del certificado, ve los detalles del firmante y confirma la integridad del documento." }, + "timestampPdf": { + "name": "Sellar PDF con marca de tiempo", + "pageTitle": "Marca de tiempo PDF gratis - Sello RFC 3161 | BentoPDF", + "subtitle": "Añade una marca de tiempo RFC 3161 a tu PDF mediante un servidor TSA de confianza. Demuestra que tu documento existía en un momento específico. No se requiere certificado.", + "tsaSectionTitle": "Servidor de marca de tiempo (TSA)", + "selectTsa": "Seleccionar un servidor TSA", + "applyTimestamp": "Aplicar marca de tiempo", + "successMessage": "¡PDF sellado con marca de tiempo! La marca puede verificarse en Adobe Acrobat y otros lectores PDF." + }, "emailToPdf": { "name": "Email a PDF", "subtitle": "Convierte archivos de correo (EML, MSG) a formato PDF. Soporta exportaciones de Outlook y formatos de correo estándar.", diff --git a/public/locales/fr/tools.json b/public/locales/fr/tools.json index 2d27d37..f9d4d22 100644 --- a/public/locales/fr/tools.json +++ b/public/locales/fr/tools.json @@ -690,6 +690,15 @@ "pageTitle": "Valider la signature PDF - Vérifier les signatures numériques | BentoPDF", "subtitle": "Vérifier les signatures numériques de vos fichiers PDF. Contrôlez la validité du certificat, consultez les informations du signataire et confirmez l’intégrité du document. Tout le traitement s’effectue dans votre navigateur." }, + "timestampPdf": { + "name": "Horodater un PDF", + "pageTitle": "Horodatage PDF en ligne gratuit - Horodatage RFC 3161 | BentoPDF", + "subtitle": "Ajoutez un horodatage RFC 3161 à votre PDF via un serveur d'horodatage (TSA) de confiance. Prouve que votre document existait à un moment précis. Aucun certificat requis.", + "tsaSectionTitle": "Serveur d'horodatage (TSA)", + "selectTsa": "Sélectionner un serveur TSA", + "applyTimestamp": "Appliquer l'horodatage", + "successMessage": "PDF horodaté avec succès ! L'horodatage peut être vérifié dans Adobe Acrobat et d'autres lecteurs PDF." + }, "emailToPdf": { "name": "Email vers PDF", "subtitle": "Convertir des fichiers email (EML, MSG) au format PDF. Prend en charge les exports Outlook et les formats email standards.", diff --git a/public/locales/id/tools.json b/public/locales/id/tools.json index 43e9245..1bdc000 100644 --- a/public/locales/id/tools.json +++ b/public/locales/id/tools.json @@ -690,6 +690,15 @@ "pageTitle": "Validasi Tanda Tangan PDF - Verifikasi Tanda Tangan Digital | BentoPDF", "subtitle": "Verifikasi tanda tangan digital di file PDF Anda. Periksa validitas sertifikat, lihat detail penandatangan, dan konfirmasi integritas dokumen. Semua pemrosesan terjadi di browser Anda." }, + "timestampPdf": { + "name": "Stempel Waktu PDF", + "pageTitle": "Stempel Waktu PDF Gratis Online - RFC 3161 | BentoPDF", + "subtitle": "Tambahkan stempel waktu RFC 3161 ke PDF Anda menggunakan server TSA tepercaya. Membuktikan dokumen Anda ada pada waktu tertentu. Tidak memerlukan sertifikat.", + "tsaSectionTitle": "Server Stempel Waktu (TSA)", + "selectTsa": "Pilih server TSA", + "applyTimestamp": "Terapkan Stempel Waktu", + "successMessage": "PDF berhasil diberi stempel waktu! Stempel waktu dapat diverifikasi di Adobe Acrobat dan pembaca PDF lainnya." + }, "emailToPdf": { "name": "Email ke PDF", "subtitle": "Konversi file email (EML, MSG) ke format PDF. Mendukung ekspor Outlook dan format email standar.", diff --git a/public/locales/it/tools.json b/public/locales/it/tools.json index 2e5cbda..1aa1436 100644 --- a/public/locales/it/tools.json +++ b/public/locales/it/tools.json @@ -690,6 +690,15 @@ "pageTitle": "Verifica firma PDF - Controlla firme digitali | BentoPDF", "subtitle": "Verifica le firme digitali nei tuoi PDF. Controlla la validità del certificato, visualizza i dati del firmatario e conferma l'integrità del documento. Tutto avviene nel tuo browser." }, + "timestampPdf": { + "name": "Marca temporale PDF", + "pageTitle": "Marca temporale PDF gratuita online - RFC 3161 | BentoPDF", + "subtitle": "Aggiungi una marca temporale RFC 3161 al tuo PDF tramite un server TSA affidabile. Dimostra che il tuo documento esisteva in un momento specifico. Nessun certificato richiesto.", + "tsaSectionTitle": "Server marca temporale (TSA)", + "selectTsa": "Seleziona un server TSA", + "applyTimestamp": "Applica marca temporale", + "successMessage": "PDF marcato temporalmente con successo! La marca può essere verificata in Adobe Acrobat e altri lettori PDF." + }, "emailToPdf": { "name": "Email in PDF", "subtitle": "Converti file email (EML, MSG) in formato PDF. Supporta esportazioni Outlook e formati email standard.", diff --git a/public/locales/nl/tools.json b/public/locales/nl/tools.json index 8360f18..a0e0cc6 100644 --- a/public/locales/nl/tools.json +++ b/public/locales/nl/tools.json @@ -748,6 +748,15 @@ "pageTitle": "PDF-handtekening valideren - Digitale handtekeningen verifiëren | BentoPDF", "subtitle": "Digitale handtekeningen in een PDF-bestanden verifiëren. Controleer de geldigheid van het certificaat, bekijk de gegevens van de ondertekenaar en bevestig de integriteit van het document. Alle verwerking gebeurt binnen jouw browser." }, + "timestampPdf": { + "name": "PDF tijdstempelen", + "pageTitle": "PDF tijdstempel gratis online - RFC 3161 documenttijdstempel | BentoPDF", + "subtitle": "Voeg een RFC 3161 tijdstempel toe aan je PDF via een vertrouwde TSA-server. Bewijst dat je document op een specifiek moment bestond. Geen certificaat vereist.", + "tsaSectionTitle": "Tijdstempelserver (TSA)", + "selectTsa": "Selecteer een TSA-server", + "applyTimestamp": "Tijdstempel toepassen", + "successMessage": "PDF succesvol voorzien van tijdstempel! Het tijdstempel kan worden geverifieerd in Adobe Acrobat en andere PDF-lezers." + }, "emailToPdf": { "name": "E-mail naar PDF", "subtitle": "Converteer e-mailbestanden (EML, MSG) naar PDF-formaat. Ondersteunt Outlook-exports en standaard e-mailformaten.", diff --git a/public/locales/pt/tools.json b/public/locales/pt/tools.json index b9151eb..07aa9bc 100644 --- a/public/locales/pt/tools.json +++ b/public/locales/pt/tools.json @@ -704,6 +704,15 @@ "pageTitle": "Validar Assinatura PDF - Verificar Assinaturas Digitais | BentoPDF", "subtitle": "Verifique assinaturas digitais em seus arquivos PDF. Verifique a validade do certificado e a integridade do documento." }, + "timestampPdf": { + "name": "Carimbo de tempo PDF", + "pageTitle": "Carimbo de tempo PDF grátis online - RFC 3161 | BentoPDF", + "subtitle": "Adicione um carimbo de tempo RFC 3161 ao seu PDF usando um servidor TSA confiável. Prova que o seu documento existia num momento específico. Sem necessidade de certificado.", + "tsaSectionTitle": "Servidor de carimbo de tempo (TSA)", + "selectTsa": "Selecionar um servidor TSA", + "applyTimestamp": "Aplicar carimbo de tempo", + "successMessage": "PDF carimbado com sucesso! O carimbo pode ser verificado no Adobe Acrobat e noutros leitores de PDF." + }, "pdfToWord": { "name": "PDF para Word", "subtitle": "Converter arquivos PDF em documentos Word editáveis." diff --git a/public/locales/sv/tools.json b/public/locales/sv/tools.json index 2e09e46..3649266 100644 --- a/public/locales/sv/tools.json +++ b/public/locales/sv/tools.json @@ -690,6 +690,15 @@ "pageTitle": "Validera PDF-signatur - Verifiera digitala signaturer | BentoPDF", "subtitle": "Verifiera digitala signaturer i dina PDF-filer. Kontrollera certifikatgiltighet, visa signerardetaljer och bekräfta dokumentintegritet. All bearbetning sker i din webbläsare." }, + "timestampPdf": { + "name": "Tidsstämpla PDF", + "pageTitle": "Tidsstämpla PDF gratis online - RFC 3161 dokumenttidsstämpel | BentoPDF", + "subtitle": "Lägg till en RFC 3161 tidsstämpel till din PDF via en betrodd TSA-server. Bevisar att ditt dokument existerade vid en specifik tidpunkt. Inget certifikat krävs.", + "tsaSectionTitle": "Tidsstämpelserver (TSA)", + "selectTsa": "Välj en TSA-server", + "applyTimestamp": "Tillämpa tidsstämpel", + "successMessage": "PDF tidsstämplad! Tidsstämpeln kan verifieras i Adobe Acrobat och andra PDF-läsare." + }, "emailToPdf": { "name": "E-post till PDF", "subtitle": "Konvertera e-postfiler (EML, MSG) till PDF-format. Stöder Outlook-exporter och standard e-postformat.", diff --git a/public/locales/tr/tools.json b/public/locales/tr/tools.json index 839bcb5..d4f7908 100644 --- a/public/locales/tr/tools.json +++ b/public/locales/tr/tools.json @@ -704,6 +704,15 @@ "pageTitle": "Validate PDF Signature - Verify Digital Signatures | BentoPDF", "subtitle": "Verify digital signatures in your PDF files. Check certificate validity, view signer details, and confirm document integrity. All processing happens in your browser." }, + "timestampPdf": { + "name": "PDF Zaman Damgası", + "pageTitle": "PDF Zaman Damgası Ücretsiz - RFC 3161 Belge Zaman Damgası | BentoPDF", + "subtitle": "Güvenilir bir TSA sunucusu aracılığıyla PDF'nize RFC 3161 zaman damgası ekleyin. Belgenizin belirli bir zamanda var olduğunu kanıtlar. Sertifika gerekmez.", + "tsaSectionTitle": "Zaman Damgası Sunucusu (TSA)", + "selectTsa": "Bir TSA sunucusu seçin", + "applyTimestamp": "Zaman Damgası Uygula", + "successMessage": "PDF başarıyla zaman damgalandı! Zaman damgası Adobe Acrobat ve diğer PDF okuyucularda doğrulanabilir." + }, "pdfToWord": { "name": "PDF'den Word'e", "subtitle": "PDF dosyalarını düzenlenebilir Word belgelerine dönüştürün." diff --git a/public/locales/vi/tools.json b/public/locales/vi/tools.json index 94f0ced..6a86a3d 100644 --- a/public/locales/vi/tools.json +++ b/public/locales/vi/tools.json @@ -690,6 +690,15 @@ "pageTitle": "Xác minh chữ ký PDF - Xác thực chữ ký số | BentoPDF", "subtitle": "Xác minh chữ ký số trong tệp PDF của bạn. Kiểm tra hiệu lực chứng chỉ, xem thông tin người ký và xác nhận tính toàn vẹn tài liệu. Tất cả xử lý diễn ra trong trình duyệt của bạn." }, + "timestampPdf": { + "name": "Đóng dấu thời gian PDF", + "pageTitle": "Đóng dấu thời gian PDF miễn phí - RFC 3161 | BentoPDF", + "subtitle": "Thêm dấu thời gian RFC 3161 vào PDF của bạn thông qua máy chủ TSA đáng tin cậy. Chứng minh tài liệu của bạn tồn tại tại một thời điểm cụ thể. Không cần chứng chỉ.", + "tsaSectionTitle": "Máy chủ dấu thời gian (TSA)", + "selectTsa": "Chọn máy chủ TSA", + "applyTimestamp": "Áp dụng dấu thời gian", + "successMessage": "PDF đã được đóng dấu thời gian thành công! Dấu thời gian có thể được xác minh trong Adobe Acrobat và các trình đọc PDF khác." + }, "emailToPdf": { "name": "Email sang PDF", "subtitle": "Chuyển đổi tệp email (EML, MSG) sang định dạng PDF. Hỗ trợ xuất Outlook và định dạng email tiêu chuẩn.", diff --git a/public/locales/zh-TW/tools.json b/public/locales/zh-TW/tools.json index e078887..d68c395 100644 --- a/public/locales/zh-TW/tools.json +++ b/public/locales/zh-TW/tools.json @@ -704,6 +704,15 @@ "pageTitle": "Validate PDF Signature - Verify Digital Signatures | BentoPDF", "subtitle": "Verify digital signatures in your PDF files. Check certificate validity, view signer details, and confirm document integrity. All processing happens in your browser." }, + "timestampPdf": { + "name": "PDF 時間戳記", + "pageTitle": "免費線上 PDF 時間戳記 - RFC 3161 文件時間戳記 | BentoPDF", + "subtitle": "透過可信賴的 TSA 伺服器為您的 PDF 新增 RFC 3161 時間戳記。證明您的文件在特定時間點存在。無需憑證。", + "tsaSectionTitle": "時間戳記伺服器 (TSA)", + "selectTsa": "選擇 TSA 伺服器", + "applyTimestamp": "套用時間戳記", + "successMessage": "PDF 時間戳記新增成功!時間戳記可在 Adobe Acrobat 和其他 PDF 閱讀器中驗證。" + }, "pdfToWord": { "name": "PDF 轉 Word", "subtitle": "將 PDF 檔案轉換為可編輯的 Word 文件。" diff --git a/public/locales/zh/tools.json b/public/locales/zh/tools.json index 5dece16..4c87e91 100644 --- a/public/locales/zh/tools.json +++ b/public/locales/zh/tools.json @@ -690,6 +690,15 @@ "pageTitle": "验证 PDF 签名 - 验证数字签名 | BentoPDF", "subtitle": "验证您的 PDF 文件中的数字签名。检查证书有效性、查看签名者详情并确认文档完整性。所有处理都在您的浏览器中进行。" }, + "timestampPdf": { + "name": "PDF 时间戳", + "pageTitle": "免费在线 PDF 时间戳 - RFC 3161 文档时间戳 | BentoPDF", + "subtitle": "通过可信的 TSA 服务器为您的 PDF 添加 RFC 3161 时间戳。证明您的文档在特定时间点存在。无需证书。", + "tsaSectionTitle": "时间戳服务器 (TSA)", + "selectTsa": "选择 TSA 服务器", + "applyTimestamp": "应用时间戳", + "successMessage": "PDF 时间戳添加成功!时间戳可在 Adobe Acrobat 和其他 PDF 阅读器中验证。" + }, "emailToPdf": { "name": "邮件转 PDF", "subtitle": "将电子邮件文件 (EML, MSG) 转换为 PDF 格式。支持 Outlook 导出和标准邮件格式。", diff --git a/src/js/config/timestamp-tsa.ts b/src/js/config/timestamp-tsa.ts new file mode 100644 index 0000000..253cb47 --- /dev/null +++ b/src/js/config/timestamp-tsa.ts @@ -0,0 +1,14 @@ +export interface TimestampTsaPreset { + label: string; + url: string; +} + +// Some TSA providers only expose HTTP endpoints. RFC 3161 timestamp tokens are +// signed at the application layer, so integrity does not depend solely on TLS. +export const TIMESTAMP_TSA_PRESETS: TimestampTsaPreset[] = [ + { 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' }, +]; diff --git a/src/js/config/tools.ts b/src/js/config/tools.ts index 21f4369..29e618a 100644 --- a/src/js/config/tools.ts +++ b/src/js/config/tools.ts @@ -799,6 +799,13 @@ const baseCategories = [ icon: 'ph-seal-check', subtitle: 'Verify digital signatures and view certificate details.', }, + { + href: import.meta.env.BASE_URL + 'timestamp-pdf.html', + name: 'Timestamp PDF', + icon: 'ph-clock', + subtitle: + 'Add an RFC 3161 document timestamp using a trusted TSA server.', + }, ], }, ]; diff --git a/src/js/logic/digital-sign-pdf.ts b/src/js/logic/digital-sign-pdf.ts index 37bc989..0bd2726 100644 --- a/src/js/logic/digital-sign-pdf.ts +++ b/src/js/logic/digital-sign-pdf.ts @@ -153,6 +153,20 @@ async function generateProxySignature( .join(''); } +async function buildCorsProxyUrl(url: string): Promise { + let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`; + + if (!CORS_PROXY_SECRET) { + return proxyUrl; + } + + const timestamp = Date.now(); + const signature = await generateProxySignature(url, timestamp); + proxyUrl += `&t=${timestamp}&sig=${signature}`; + + return proxyUrl; +} + /** * Custom fetch wrapper that routes external certificate requests through a CORS proxy. * The zgapdfsigner library tries to fetch issuer certificates from URLs embedded in the @@ -192,20 +206,32 @@ function createCorsAwareFetch(): { url.includes('caIssuers')) && !url.startsWith(window.location.origin); - if (isExternalCertificateUrl && CORS_PROXY_URL) { - let proxyUrl = `${CORS_PROXY_URL}?url=${encodeURIComponent(url)}`; + const isTsaRequest = + (init?.headers && + (init.headers instanceof Headers + ? init.headers.get('Content-Type') === 'application/timestamp-query' + : typeof init.headers === 'object' && + !Array.isArray(init.headers) && + (init.headers as Record)['Content-Type'] === + 'application/timestamp-query')) || + url.includes('timestamp') || + url.includes('/tsa') || + url.includes('/tsr') || + url.includes('/ts01') || + url.includes('RFC3161'); + const shouldProxy = + (isExternalCertificateUrl || isTsaRequest) && + !url.startsWith(window.location.origin); + + if (shouldProxy && CORS_PROXY_URL) { + const proxyUrl = await buildCorsProxyUrl(url); if (CORS_PROXY_SECRET) { - const timestamp = Date.now(); - const signature = await generateProxySignature(url, timestamp); - proxyUrl += `&t=${timestamp}&sig=${signature}`; console.log( - `[CORS Proxy] Routing signed certificate request through proxy: ${url}` + `[CORS Proxy] Routing signed request through proxy: ${url}` ); } else { - console.log( - `[CORS Proxy] Routing certificate request through proxy: ${url}` - ); + console.log(`[CORS Proxy] Routing request through proxy: ${url}`); } return originalFetch(proxyUrl, init); @@ -302,6 +328,40 @@ export async function signPdf( } } +export async function timestampPdf( + pdfBytes: Uint8Array, + tsaUrl: string +): Promise { + let effectiveUrl = tsaUrl; + + if (CORS_PROXY_URL) { + effectiveUrl = await buildCorsProxyUrl(tsaUrl); + + if (CORS_PROXY_SECRET) { + console.log( + `[Timestamp] Routing signed TSA request through proxy: ${tsaUrl}` + ); + } else { + console.log(`[Timestamp] Routing TSA request through proxy: ${tsaUrl}`); + } + } + + const signOptions: SignOption = { + signdate: { url: effectiveUrl }, + }; + + const signer = new PdfSigner(signOptions); + + const { restore } = createCorsAwareFetch(); + + try { + const timestampedPdfBytes = await signer.sign(pdfBytes); + return new Uint8Array(timestampedPdfBytes); + } finally { + restore(); + } +} + export function getCertificateInfo(certificate: forge.pki.Certificate): { subject: string; issuer: string; diff --git a/src/js/logic/timestamp-pdf-page.ts b/src/js/logic/timestamp-pdf-page.ts new file mode 100644 index 0000000..97b8d69 --- /dev/null +++ b/src/js/logic/timestamp-pdf-page.ts @@ -0,0 +1,258 @@ +import { createIcons, icons } from 'lucide'; +import { showAlert, showLoader, hideLoader } from '../ui.js'; +import { + readFileAsArrayBuffer, + formatBytes, + downloadFile, + getPDFDocument, +} from '../utils/helpers.js'; +import { TIMESTAMP_TSA_PRESETS } from '../config/timestamp-tsa.js'; +import { timestampPdf } from './digital-sign-pdf.js'; + +interface TimestampState { + pdfFile: File | null; + pdfBytes: Uint8Array | null; +} + +const state: TimestampState = { + pdfFile: null, + pdfBytes: null, +}; + +function getElement(id: string): T | null { + return document.getElementById(id) as T | null; +} + +function resetState(): void { + state.pdfFile = null; + state.pdfBytes = null; + + const fileDisplayArea = getElement('file-display-area'); + if (fileDisplayArea) fileDisplayArea.innerHTML = ''; + + const fileInput = getElement('file-input'); + if (fileInput) fileInput.value = ''; + + const tsaSection = getElement('tsa-section'); + if (tsaSection) tsaSection.classList.add('hidden'); + + updateProcessButton(); +} + +function initializePage(): void { + createIcons({ icons }); + + const fileInput = getElement('file-input'); + const dropZone = getElement('drop-zone'); + const processBtn = getElement('process-btn'); + const backBtn = getElement('back-to-tools'); + const tsaPreset = getElement('tsa-preset'); + + populateTsaPresets(tsaPreset); + + if (fileInput) { + fileInput.addEventListener('change', handlePdfUpload); + fileInput.addEventListener('click', () => { + fileInput.value = ''; + }); + } + + if (dropZone) { + dropZone.addEventListener('dragover', (e) => { + e.preventDefault(); + dropZone.classList.add('bg-gray-700'); + }); + + dropZone.addEventListener('dragleave', () => { + dropZone.classList.remove('bg-gray-700'); + }); + + dropZone.addEventListener('drop', (e) => { + e.preventDefault(); + dropZone.classList.remove('bg-gray-700'); + const droppedFiles = e.dataTransfer?.files; + if (droppedFiles && droppedFiles.length > 0) { + handlePdfFile(droppedFiles[0]); + } + }); + } + + if (processBtn) { + processBtn.addEventListener('click', processTimestamp); + } + + if (backBtn) { + backBtn.addEventListener('click', () => { + window.location.href = import.meta.env.BASE_URL; + }); + } +} + +function populateTsaPresets(tsaPreset: HTMLSelectElement | null): void { + if (!tsaPreset) return; + + tsaPreset.replaceChildren(); + + for (const preset of TIMESTAMP_TSA_PRESETS) { + const option = document.createElement('option'); + option.value = preset.url; + option.textContent = preset.label; + tsaPreset.append(option); + } +} + +function handlePdfUpload(e: Event): void { + const input = e.target as HTMLInputElement; + if (input.files && input.files.length > 0) { + handlePdfFile(input.files[0]); + } +} + +async function handlePdfFile(file: File): Promise { + if ( + file.type !== 'application/pdf' && + !file.name.toLowerCase().endsWith('.pdf') + ) { + showAlert('Invalid File', 'Please select a PDF file.'); + return; + } + + state.pdfFile = file; + state.pdfBytes = new Uint8Array( + (await readFileAsArrayBuffer(file)) as ArrayBuffer + ); + + updatePdfDisplay(); + showTsaSection(); + updateProcessButton(); +} + +async function updatePdfDisplay(): Promise { + const fileDisplayArea = getElement('file-display-area'); + + if (!fileDisplayArea || !state.pdfFile) return; + + fileDisplayArea.innerHTML = ''; + + const fileDiv = document.createElement('div'); + fileDiv.className = + 'flex items-center justify-between bg-gray-700 p-3 rounded-lg'; + + const infoContainer = document.createElement('div'); + infoContainer.className = 'flex flex-col flex-1 min-w-0'; + + const nameSpan = document.createElement('div'); + nameSpan.className = 'truncate font-medium text-gray-200 text-sm mb-1'; + nameSpan.textContent = state.pdfFile.name; + + const metaSpan = document.createElement('div'); + metaSpan.className = 'text-xs text-gray-400'; + metaSpan.textContent = `${formatBytes(state.pdfFile.size)} • Loading pages...`; + + infoContainer.append(nameSpan, metaSpan); + + const removeBtn = document.createElement('button'); + removeBtn.className = 'ml-4 text-red-400 hover:text-red-300 flex-shrink-0'; + removeBtn.innerHTML = ''; + removeBtn.onclick = () => { + state.pdfFile = null; + state.pdfBytes = null; + fileDisplayArea.innerHTML = ''; + hideTsaSection(); + updateProcessButton(); + }; + + fileDiv.append(infoContainer, removeBtn); + fileDisplayArea.appendChild(fileDiv); + createIcons({ icons }); + + try { + if (state.pdfBytes) { + const pdfDoc = await getPDFDocument({ + data: state.pdfBytes.slice(), + }).promise; + metaSpan.textContent = `${formatBytes(state.pdfFile.size)} • ${pdfDoc.numPages} pages`; + } + } catch (error) { + console.error('Error loading PDF:', error); + metaSpan.textContent = `${formatBytes(state.pdfFile.size)}`; + } +} + +function showTsaSection(): void { + const tsaSection = getElement('tsa-section'); + if (tsaSection) { + tsaSection.classList.remove('hidden'); + } +} + +function hideTsaSection(): void { + const tsaSection = getElement('tsa-section'); + if (tsaSection) { + tsaSection.classList.add('hidden'); + } +} + +function updateProcessButton(): void { + const processBtn = getElement('process-btn'); + if (!processBtn) return; + + if (state.pdfBytes) { + processBtn.classList.remove('hidden'); + processBtn.disabled = false; + } else { + processBtn.classList.add('hidden'); + processBtn.disabled = true; + } +} + +function getTsaUrl(): string | null { + const tsaPreset = getElement('tsa-preset'); + if (!tsaPreset) return null; + + return tsaPreset.value; +} + +async function processTimestamp(): Promise { + if (!state.pdfBytes || !state.pdfFile) { + showAlert('No File', 'Please upload a PDF file first.'); + return; + } + + const tsaUrl = getTsaUrl(); + if (!tsaUrl) return; + + showLoader('Applying timestamp...'); + + try { + const timestampedBytes = await timestampPdf(state.pdfBytes, tsaUrl); + + const outputFilename = state.pdfFile.name.replace( + /\.pdf$/i, + '_timestamped.pdf' + ); + const blob = new Blob([new Uint8Array(timestampedBytes)], { + type: 'application/pdf', + }); + downloadFile(blob, outputFilename); + + showAlert( + 'Success', + 'PDF timestamped successfully! The timestamp can be verified in Adobe Acrobat and other PDF readers.', + 'success' + ); + + resetState(); + } catch (error) { + console.error('Timestamp error:', error); + const message = error instanceof Error ? error.message : 'Unknown error'; + showAlert( + 'Timestamp Failed', + `Failed to timestamp PDF: ${message}\n\nPlease try a different TSA server or check your internet connection.` + ); + } finally { + hideLoader(); + } +} + +document.addEventListener('DOMContentLoaded', initializePage); diff --git a/src/js/main.ts b/src/js/main.ts index 4e686d9..a20e7de 100644 --- a/src/js/main.ts +++ b/src/js/main.ts @@ -216,6 +216,7 @@ const init = async () => { 'Deskew PDF': 'tools:deskewPdf', 'Digital Signature': 'tools:digitalSignPdf', 'Validate Signature': 'tools:validateSignaturePdf', + 'Timestamp PDF': 'tools:timestampPdf', 'Scanner Effect': 'tools:scannerEffect', 'Adjust Colors': 'tools:adjustColors', 'Markdown to PDF': 'tools:markdownToPdf', diff --git a/src/js/workflow/nodes/registry.ts b/src/js/workflow/nodes/registry.ts index 2520b16..2fdfa67 100644 --- a/src/js/workflow/nodes/registry.ts +++ b/src/js/workflow/nodes/registry.ts @@ -35,6 +35,7 @@ import { SanitizeNode } from './sanitize-node'; import { EncryptNode } from './encrypt-node'; import { DecryptNode } from './decrypt-node'; import { DigitalSignNode } from './digital-sign-node'; +import { TimestampNode } from './timestamp-node'; import { RedactNode } from './redact-node'; import { RepairNode } from './repair-node'; import { PdfToTextNode } from './pdf-to-text-node'; @@ -509,6 +510,13 @@ export const nodeRegistry: Record = { description: 'Apply a digital signature to PDF', factory: () => new DigitalSignNode(), }, + TimestampNode: { + label: 'Timestamp', + category: 'Secure PDF', + icon: 'ph-clock', + description: 'Add an RFC 3161 document timestamp', + factory: () => new TimestampNode(), + }, RedactNode: { label: 'Redact', category: 'Secure PDF', diff --git a/src/js/workflow/nodes/timestamp-node.ts b/src/js/workflow/nodes/timestamp-node.ts new file mode 100644 index 0000000..7fde80c --- /dev/null +++ b/src/js/workflow/nodes/timestamp-node.ts @@ -0,0 +1,66 @@ +import { ClassicPreset } from 'rete'; +import { BaseWorkflowNode } from './base-node'; +import { pdfSocket } from '../sockets'; +import type { SocketData } from '../types'; +import { requirePdfInput, processBatch } from '../types'; +import { PDFDocument } from 'pdf-lib'; +import { TIMESTAMP_TSA_PRESETS } from '../../config/timestamp-tsa.js'; +import { timestampPdf } from '../../logic/digital-sign-pdf.js'; + +export class TimestampNode extends BaseWorkflowNode { + readonly category = 'Secure PDF' as const; + readonly icon = 'ph-clock'; + readonly description = 'Add an RFC 3161 document timestamp'; + + constructor() { + super('Timestamp'); + this.addInput('pdf', new ClassicPreset.Input(pdfSocket, 'PDF')); + this.addOutput( + 'pdf', + new ClassicPreset.Output(pdfSocket, 'Timestamped PDF') + ); + this.addControl( + 'tsaUrl', + new ClassicPreset.InputControl('text', { + initial: TIMESTAMP_TSA_PRESETS[0].url, + }) + ); + } + + getTsaPresets(): { label: string; url: string }[] { + return TIMESTAMP_TSA_PRESETS; + } + + async data( + inputs: Record + ): Promise> { + const pdfInputs = requirePdfInput(inputs, 'Timestamp'); + + const tsaUrlCtrl = this.controls['tsaUrl'] as + | ClassicPreset.InputControl<'text'> + | undefined; + const tsaUrl = tsaUrlCtrl?.value || TIMESTAMP_TSA_PRESETS[0].url; + + return { + pdf: await processBatch(pdfInputs, async (input) => { + let bytes: Uint8Array; + try { + bytes = await timestampPdf(input.bytes, tsaUrl); + } catch (err) { + throw new Error( + `Failed to timestamp using TSA ${tsaUrl}: ${err instanceof Error ? err.message : err}`, + { cause: err } + ); + } + const document = await PDFDocument.load(bytes); + + return { + type: 'pdf', + document, + bytes, + filename: input.filename.replace(/\.pdf$/i, '_timestamped.pdf'), + }; + }), + }; + } +} diff --git a/src/pages/timestamp-pdf.html b/src/pages/timestamp-pdf.html new file mode 100644 index 0000000..13446c0 --- /dev/null +++ b/src/pages/timestamp-pdf.html @@ -0,0 +1,492 @@ + + + + + + + + Timestamp PDF Online Free - RFC 3161 Document Timestamp | BentoPDF + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{> navbar }} + +
+
+ + +

+ Timestamp PDF +

+

+ Add an RFC 3161 document timestamp to your PDF using a trusted Time + Stamp Authority (TSA) server. Proves your document existed at a + specific point in time. No certificate required. +

+ +
+ +
+

+ Internet connection required +

+

+ Timestamping requires contacting the selected TSA server to obtain + a trusted timestamp token. +

+
+
+ +
+
+ +

+ Click to select a file + or drag and drop +

+

+ PDF Documents +

+

+ Your files never leave your device. +

+
+ +
+ +
+ + + + +
+
+ + + + + +
+

+ How It Works +

+
+
+
+ 1 +
+
+

Upload PDF

+

+ Select the PDF document you want to timestamp +

+
+
+
+
+ 2 +
+
+

+ Choose TSA Server +

+

Select one of the trusted TSA servers

+
+
+
+
+ 3 +
+
+

+ Timestamp & Download +

+

+ Apply the RFC 3161 timestamp and download your timestamped PDF +

+
+
+
+
+ +
+

+ Related PDF Tools +

+ +
+ +
+

+ Frequently Asked Questions +

+
+
+ + What is a document timestamp? + + +

+ A document timestamp (RFC 3161) is a cryptographic proof that your + PDF existed at a specific point in time. A trusted Time Stamp + Authority (TSA) server signs a hash of your document along with the + current time, creating a tamper-evident timestamp embedded in the + PDF. +

+
+
+ + Do I need a certificate? + + +

+ No. Unlike a digital signature, a document timestamp does not + require a personal certificate. The TSA server provides the trusted + timestamp using its own certificate. +

+
+
+ + Is my document sent to the TSA server? + + +

+ No. Only a cryptographic hash (SHA-256) of your document is sent to + the TSA server. Your actual PDF content never leaves your browser. +

+
+
+ + Will the timestamp be valid in PDF readers? + + +

+ Yes. The tool creates a standard RFC 3161 document timestamp + (ETSI.RFC3161 SubFilter) that is recognized by Adobe Acrobat and + other major PDF viewers. +

+
+
+
+ + {{> footer }} + + + + + + + + + + + + + + + + diff --git a/src/tests/digital-sign-pdf.test.ts b/src/tests/digital-sign-pdf.test.ts new file mode 100644 index 0000000..6be3fcb --- /dev/null +++ b/src/tests/digital-sign-pdf.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const mockSign = vi.fn(); + +vi.mock('zgapdfsigner', () => { + const MockPdfSigner = vi.fn(function (this: { sign: typeof mockSign }) { + this.sign = mockSign; + }); + return { PdfSigner: MockPdfSigner }; +}); + +import { PdfSigner } from 'zgapdfsigner'; +import { timestampPdf } from '@/js/logic/digital-sign-pdf'; + +const SAMPLE_PDF_PATH = path.resolve(__dirname, './fixtures/sample.pdf'); +const SAMPLE_PDF_SHA256 = + '229defbb0cee6f02673a5cde290d0673e75a0dc31cec43989c8ab2a4eca7e1bb'; + +async function sha256(data: Uint8Array): Promise { + const hashBuffer = await crypto.subtle.digest('SHA-256', data); + return Array.from(new Uint8Array(hashBuffer)) + .map((b) => b.toString(16).padStart(2, '0')) + .join(''); +} + +describe('timestampPdf', () => { + let samplePdfBytes: Uint8Array; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('VITE_CORS_PROXY_URL', ''); + vi.stubEnv('VITE_CORS_PROXY_SECRET', ''); + samplePdfBytes = new Uint8Array(fs.readFileSync(SAMPLE_PDF_PATH)); + }); + + it('should load the correct sample PDF', async () => { + const hash = await sha256(samplePdfBytes); + expect(hash).toBe(SAMPLE_PDF_SHA256); + }); + + it('should call PdfSigner with signdate option containing the TSA URL', async () => { + const fakeSigned = new Uint8Array([80, 68, 70, 45, 49, 46, 52]); // "PDF-1.4" + mockSign.mockResolvedValueOnce(fakeSigned); + + const tsaUrl = 'http://timestamp.digicert.com'; + await timestampPdf(samplePdfBytes, tsaUrl); + + expect(PdfSigner).toHaveBeenCalledWith({ + signdate: { url: tsaUrl }, + }); + }); + + it('should pass the PDF bytes to signer.sign()', async () => { + const fakeSigned = new Uint8Array([1, 2, 3]); + mockSign.mockResolvedValueOnce(fakeSigned); + + const tsaUrl = 'http://timestamp.digicert.com'; + await timestampPdf(samplePdfBytes, tsaUrl); + + expect(mockSign).toHaveBeenCalledOnce(); + const passedBytes = mockSign.mock.calls[0][0]; + expect(passedBytes).toBeInstanceOf(Uint8Array); + expect(passedBytes.length).toBe(samplePdfBytes.length); + }); + + it('should return a Uint8Array from the signed result', async () => { + const fakeSigned = new Uint8Array([10, 20, 30, 40]); + mockSign.mockResolvedValueOnce(fakeSigned); + + const result = await timestampPdf(samplePdfBytes, 'http://ts.ssl.com'); + + expect(result).toBeInstanceOf(Uint8Array); + expect(result).toEqual(new Uint8Array([10, 20, 30, 40])); + }); + + it('should propagate errors from PdfSigner.sign()', async () => { + mockSign.mockRejectedValueOnce(new Error('TSA server unreachable')); + + await expect( + timestampPdf(samplePdfBytes, 'http://invalid-tsa.example.com') + ).rejects.toThrow('TSA server unreachable'); + }); + + it('should work with different TSA URLs', async () => { + const fakeSigned = new Uint8Array([1]); + mockSign.mockResolvedValue(fakeSigned); + + const urls = [ + 'http://timestamp.digicert.com', + 'http://timestamp.sectigo.com', + 'https://freetsa.org/tsr', + ]; + + for (const url of urls) { + vi.mocked(PdfSigner).mockClear(); + await timestampPdf(samplePdfBytes, url); + + expect(PdfSigner).toHaveBeenCalledWith({ + signdate: { url }, + }); + } + }); + + it('should not modify the original PDF bytes', async () => { + const fakeSigned = new Uint8Array([1, 2, 3]); + mockSign.mockResolvedValueOnce(fakeSigned); + + const originalCopy = new Uint8Array(samplePdfBytes); + await timestampPdf(samplePdfBytes, 'http://timestamp.digicert.com'); + + expect(samplePdfBytes).toEqual(originalCopy); + }); +}); diff --git a/src/tests/fixtures/sample.pdf b/src/tests/fixtures/sample.pdf new file mode 100644 index 0000000..c01805e Binary files /dev/null and b/src/tests/fixtures/sample.pdf differ diff --git a/src/tests/timestamp-node.test.ts b/src/tests/timestamp-node.test.ts new file mode 100644 index 0000000..bdcf230 --- /dev/null +++ b/src/tests/timestamp-node.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TIMESTAMP_TSA_PRESETS } from '@/js/config/timestamp-tsa'; + +// Mock external dependencies before importing the node +vi.mock('rete', () => ({ + ClassicPreset: { + Node: class { + addInput() {} + addOutput() {} + addControl() {} + controls: Record = {}; + }, + Input: class { + constructor( + public socket: unknown, + public label: string + ) {} + }, + Output: class { + constructor( + public socket: unknown, + public label: string + ) {} + }, + InputControl: class { + value: string; + constructor( + public type: string, + public options: { initial: string } + ) { + this.value = options.initial; + } + }, + }, +})); + +vi.mock('@/js/workflow/sockets', () => ({ + pdfSocket: {}, +})); + +vi.mock('@/js/workflow/nodes/base-node', () => ({ + BaseWorkflowNode: class { + addInput() {} + addOutput() {} + addControl() {} + controls: Record = {}; + }, +})); + +vi.mock('pdf-lib', () => ({ + PDFDocument: { + load: vi.fn().mockResolvedValue({}), + }, +})); + +vi.mock('@/js/logic/digital-sign-pdf', () => ({ + timestampPdf: vi.fn().mockResolvedValue(new Uint8Array([1, 2, 3])), +})); + +vi.mock('@/js/workflow/types', () => ({ + requirePdfInput: vi.fn((inputs: Record) => inputs['pdf']), + processBatch: vi.fn( + async ( + inputs: Array<{ bytes: Uint8Array; filename: string }>, + fn: (input: { bytes: Uint8Array; filename: string }) => Promise + ) => { + const results = []; + for (const input of inputs) { + results.push(await fn(input)); + } + return results; + } + ), +})); + +import { TimestampNode } from '@/js/workflow/nodes/timestamp-node'; +import { timestampPdf } from '@/js/logic/digital-sign-pdf'; + +describe('TimestampNode', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should be instantiable', () => { + const node = new TimestampNode(); + expect(node).toBeDefined(); + }); + + it('should have the correct category', () => { + const node = new TimestampNode(); + expect(node.category).toBe('Secure PDF'); + }); + + it('should have the correct icon', () => { + const node = new TimestampNode(); + expect(node.icon).toBe('ph-clock'); + }); + + it('should have a description', () => { + const node = new TimestampNode(); + expect(node.description).toBe('Add an RFC 3161 document timestamp'); + }); + + it('should return TSA presets', () => { + const node = new TimestampNode(); + const presets = node.getTsaPresets(); + expect(presets).toBe(TIMESTAMP_TSA_PRESETS); + expect(presets.length).toBeGreaterThan(0); + }); + + it('should use the first TSA preset as default URL', () => { + const node = new TimestampNode(); + const presets = node.getTsaPresets(); + expect(presets[0].url).toBe(TIMESTAMP_TSA_PRESETS[0].url); + }); + + it('should call timestampPdf with correct TSA URL via data()', async () => { + const node = new TimestampNode(); + const mockInput = [ + { bytes: new Uint8Array([1, 2, 3]), filename: 'test.pdf' }, + ]; + + await node.data({ pdf: mockInput }); + + expect(timestampPdf).toHaveBeenCalledWith( + mockInput[0].bytes, + TIMESTAMP_TSA_PRESETS[0].url + ); + }); + + it('should generate _timestamped suffix in output filename via data()', async () => { + const node = new TimestampNode(); + const mockInput = [ + { bytes: new Uint8Array([1, 2, 3]), filename: 'report.pdf' }, + ]; + + const result = (await node.data({ pdf: mockInput })) as { + pdf: Array<{ filename: string }>; + }; + + expect(result.pdf[0].filename).toBe('report_timestamped.pdf'); + }); + + it('should wrap errors from timestampPdf with TSA context', async () => { + vi.mocked(timestampPdf).mockRejectedValueOnce(new Error('Network error')); + const node = new TimestampNode(); + + await expect( + node.data({ + pdf: [{ bytes: new Uint8Array([1]), filename: 'test.pdf' }], + }) + ).rejects.toThrow(/Failed to timestamp using TSA/); + }); +}); diff --git a/src/tests/timestamp-pdf-page.test.ts b/src/tests/timestamp-pdf-page.test.ts new file mode 100644 index 0000000..dcc0cda --- /dev/null +++ b/src/tests/timestamp-pdf-page.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { TIMESTAMP_TSA_PRESETS } from '@/js/config/timestamp-tsa'; + +/** + * Tests for the Timestamp PDF page logic. + * + * These tests validate DOM interactions, state management, and UI behavior + * of the timestamp-pdf-page module without calling real TSA servers. + */ + +function buildPageHtml(): string { + return ` + +
+
+ + + + `; +} + +describe('Timestamp PDF Page', () => { + beforeEach(() => { + document.body.innerHTML = buildPageHtml(); + }); + + describe('TSA Preset Population', () => { + it('should populate the TSA preset select element with all presets', () => { + const select = document.getElementById('tsa-preset') as HTMLSelectElement; + + for (const preset of TIMESTAMP_TSA_PRESETS) { + const option = document.createElement('option'); + option.value = preset.url; + option.textContent = preset.label; + select.append(option); + } + + expect(select.options.length).toBe(TIMESTAMP_TSA_PRESETS.length); + }); + + it('should set option values to TSA URLs', () => { + const select = document.getElementById('tsa-preset') as HTMLSelectElement; + + for (const preset of TIMESTAMP_TSA_PRESETS) { + const option = document.createElement('option'); + option.value = preset.url; + option.textContent = preset.label; + select.append(option); + } + + for (let i = 0; i < TIMESTAMP_TSA_PRESETS.length; i++) { + expect(select.options[i].value).toBe(TIMESTAMP_TSA_PRESETS[i].url); + expect(select.options[i].textContent).toBe( + TIMESTAMP_TSA_PRESETS[i].label + ); + } + }); + }); + + describe('File Validation', () => { + it('should reject non-PDF files based on type', () => { + const file = new File(['content'], 'image.png', { type: 'image/png' }); + + const isValidPdf = + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf'); + + expect(isValidPdf).toBe(false); + }); + + it('should accept files with application/pdf type', () => { + const file = new File(['content'], 'document.pdf', { + type: 'application/pdf', + }); + + const isValidPdf = + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf'); + + expect(isValidPdf).toBe(true); + }); + + it('should accept files with .pdf extension regardless of MIME type', () => { + const file = new File(['content'], 'document.pdf', { + type: 'application/octet-stream', + }); + + const isValidPdf = + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf'); + + expect(isValidPdf).toBe(true); + }); + + it('should handle case-insensitive PDF extension', () => { + const file = new File(['content'], 'document.PDF', { + type: 'application/octet-stream', + }); + + const isValidPdf = + file.type === 'application/pdf' || + file.name.toLowerCase().endsWith('.pdf'); + + expect(isValidPdf).toBe(true); + }); + }); + + describe('Output Filename', () => { + it('should append _timestamped before .pdf extension', () => { + const inputName = 'document.pdf'; + const outputName = inputName.replace(/\.pdf$/i, '_timestamped.pdf'); + expect(outputName).toBe('document_timestamped.pdf'); + }); + + it('should handle uppercase .PDF extension', () => { + const inputName = 'document.PDF'; + const outputName = inputName.replace(/\.pdf$/i, '_timestamped.pdf'); + expect(outputName).toBe('document_timestamped.pdf'); + }); + + it('should handle filenames with multiple dots', () => { + const inputName = 'my.report.2024.pdf'; + const outputName = inputName.replace(/\.pdf$/i, '_timestamped.pdf'); + expect(outputName).toBe('my.report.2024_timestamped.pdf'); + }); + }); + + describe('UI State Management', () => { + it('should have TSA section hidden initially', () => { + const tsaSection = document.getElementById('tsa-section'); + expect(tsaSection?.classList.contains('hidden')).toBe(true); + }); + + it('should have process button hidden initially', () => { + const processBtn = document.getElementById('process-btn'); + expect(processBtn?.classList.contains('hidden')).toBe(true); + }); + + it('should have process button disabled initially', () => { + const processBtn = document.getElementById( + 'process-btn' + ) as HTMLButtonElement; + expect(processBtn.disabled).toBe(true); + }); + + it('should show TSA section when hidden class is removed', () => { + const tsaSection = document.getElementById('tsa-section')!; + tsaSection.classList.remove('hidden'); + expect(tsaSection.classList.contains('hidden')).toBe(false); + }); + + it('should enable process button when PDF is loaded', () => { + const processBtn = document.getElementById( + 'process-btn' + ) as HTMLButtonElement; + processBtn.classList.remove('hidden'); + processBtn.disabled = false; + expect(processBtn.disabled).toBe(false); + expect(processBtn.classList.contains('hidden')).toBe(false); + }); + }); + + describe('Drop Zone', () => { + it('should exist in the DOM', () => { + const dropZone = document.getElementById('drop-zone'); + expect(dropZone).not.toBeNull(); + }); + + it('should add highlight class on dragover', () => { + const dropZone = document.getElementById('drop-zone')!; + dropZone.classList.add('bg-gray-700'); + expect(dropZone.classList.contains('bg-gray-700')).toBe(true); + }); + + it('should remove highlight class on dragleave', () => { + const dropZone = document.getElementById('drop-zone')!; + dropZone.classList.add('bg-gray-700'); + dropZone.classList.remove('bg-gray-700'); + expect(dropZone.classList.contains('bg-gray-700')).toBe(false); + }); + }); +}); diff --git a/src/tests/timestamp-tsa.test.ts b/src/tests/timestamp-tsa.test.ts new file mode 100644 index 0000000..d27f965 --- /dev/null +++ b/src/tests/timestamp-tsa.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { + TIMESTAMP_TSA_PRESETS, + type TimestampTsaPreset, +} from '@/js/config/timestamp-tsa'; + +describe('Timestamp TSA Presets', () => { + it('should be a non-empty array', () => { + expect(Array.isArray(TIMESTAMP_TSA_PRESETS)).toBe(true); + expect(TIMESTAMP_TSA_PRESETS.length).toBeGreaterThan(0); + }); + + it('should contain only objects with label and url strings', () => { + for (const preset of TIMESTAMP_TSA_PRESETS) { + expect(typeof preset.label).toBe('string'); + expect(preset.label.length).toBeGreaterThan(0); + expect(typeof preset.url).toBe('string'); + expect(preset.url.length).toBeGreaterThan(0); + } + }); + + it('should have unique labels', () => { + const labels = TIMESTAMP_TSA_PRESETS.map((p) => p.label); + expect(new Set(labels).size).toBe(labels.length); + }); + + it('should have unique URLs', () => { + const urls = TIMESTAMP_TSA_PRESETS.map((p) => p.url); + expect(new Set(urls).size).toBe(urls.length); + }); + + it('should have valid URL formats', () => { + for (const preset of TIMESTAMP_TSA_PRESETS) { + expect(() => new URL(preset.url)).not.toThrow(); + } + }); + + it('should include well-known TSA providers', () => { + const labels = TIMESTAMP_TSA_PRESETS.map((p) => p.label); + expect(labels).toContain('DigiCert'); + expect(labels).toContain('Sectigo'); + }); + + it('should satisfy the TimestampTsaPreset interface', () => { + const preset: TimestampTsaPreset = TIMESTAMP_TSA_PRESETS[0]; + expect(preset).toHaveProperty('label'); + expect(preset).toHaveProperty('url'); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 6a1d497..f193aa7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,7 @@ import { defineConfig, Plugin } from 'vitest/config'; import type { IncomingMessage, ServerResponse } from 'http'; +import http from 'http'; +import https from 'https'; import type { Connect } from 'vite'; // import basicSsl from '@vitejs/plugin-basic-ssl'; import tailwindcss from '@tailwindcss/vite'; @@ -198,13 +200,89 @@ function createLanguageMiddleware(isDev: boolean): Connect.NextHandleFunction { }; } +function createCorsProxyMiddleware(): Connect.NextHandleFunction { + return ( + req: IncomingMessage, + res: ServerResponse, + next: Connect.NextFunction + ): void => { + if (!req.url?.startsWith('/cors-proxy')) return next(); + + if (req.method === 'OPTIONS') { + res.setHeader('Access-Control-Allow-Origin', req.headers.origin || '*'); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.statusCode = 204; + res.end(); + return; + } + + const parsed = new URL(req.url, 'http://localhost'); + const targetUrl = parsed.searchParams.get('url'); + if (!targetUrl) { + res.statusCode = 400; + res.end('Missing url parameter'); + return; + } + + console.log(`[CORS Proxy] ${req.method} ${targetUrl}`); + + const bodyChunks: Buffer[] = []; + req.on('data', (chunk: Buffer) => bodyChunks.push(chunk)); + req.on('end', () => { + const body = Buffer.concat(bodyChunks); + const target = new URL(targetUrl); + const transport = target.protocol === 'https:' ? https : http; + + const headers: Record = {}; + if (req.headers['content-type']) { + headers['Content-Type'] = req.headers['content-type'] as string; + } + if (body.length > 0) { + headers['Content-Length'] = String(body.length); + } + + const proxyReq = transport.request( + targetUrl, + { method: req.method || 'GET', headers }, + (proxyRes) => { + console.log( + `[CORS Proxy] Response: ${proxyRes.statusCode} from ${targetUrl}` + ); + res.setHeader( + 'Access-Control-Allow-Origin', + req.headers.origin || '*' + ); + res.setHeader('Access-Control-Allow-Methods', 'GET,POST,OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); + res.statusCode = proxyRes.statusCode || 200; + proxyRes.pipe(res); + } + ); + + proxyReq.on('error', (err) => { + console.error('[CORS Proxy] Error:', err.message); + res.statusCode = 502; + res.end(`Proxy error: ${err.message}`); + }); + + if (body.length > 0) { + proxyReq.write(body); + } + proxyReq.end(); + }); + }; +} + function languageRouterPlugin(): Plugin { return { name: 'language-router', configureServer(server) { + server.middlewares.use(createCorsProxyMiddleware()); server.middlewares.use(createLanguageMiddleware(true)); }, configurePreviewServer(server) { + server.middlewares.use(createCorsProxyMiddleware()); server.middlewares.use(createLanguageMiddleware(false)); }, }; @@ -310,7 +388,7 @@ export default defineConfig(() => { include: ['buffer', 'stream', 'util', 'zlib', 'process'], globals: { Buffer: true, - global: true, + global: false, process: true, }, }), @@ -557,6 +635,7 @@ export default defineConfig(() => { __dirname, 'src/pages/digital-sign-pdf.html' ), + 'timestamp-pdf': resolve(__dirname, 'src/pages/timestamp-pdf.html'), 'validate-signature-pdf': resolve( __dirname, 'src/pages/validate-signature-pdf.html'