using System.Net; using System.Security.Cryptography; using GB5Shared.DigitalSignature; using GB5Shared.Signatory; using GB5Shared.Attachment; using GB5Shared.DTO.ECM; using GB5Shared.DTO.Framework.Login; using GB5Shared.EventLogPublish; using GB5Shared.Export.HybridReport; using GB5Shared.QRCode; using GB5Shared.Storage; using GB5Shared.Telemetry; using Newtonsoft.Json; using TMSDAL.CustomCode.Certificate; using TMSDAL.DTO.CertRenewalAlert; using static GB5Shared.GB5Constant.Constant; namespace TMSBLL.Certificate { public class CertificatePdfPipeline : ICertificatePdfPipeline { private readonly ISignatoryBLL _SignatoryBLL; private readonly IDigitalSignatureService _DigitalSignatureService; private readonly IAttachmentStorageResolver _StorageResolver; private readonly IAttachmentUploadService _AttachmentUploadService; private readonly ITrainingCertificateDAL _CertificateDAL; private readonly EventLogPublish _EventLog; public CertificatePdfPipeline( ISignatoryBLL signatoryBLL, IDigitalSignatureService digitalSignatureService, IAttachmentStorageResolver storageResolver, IAttachmentUploadService attachmentUploadService, ITrainingCertificateDAL certificateDAL, EventLogPublish eventLog) { _SignatoryBLL = signatoryBLL; _DigitalSignatureService = digitalSignatureService; _StorageResolver = storageResolver; _AttachmentUploadService = attachmentUploadService; _CertificateDAL = certificateDAL; _EventLog = eventLog; } // Minimal local shape — only the fields this pipeline needs from // ISignatoryBLL.GetActiveSignatoryByModule's JSON, avoiding a FrameworkDAL reference. private sealed class SignatoryLookup { public int SignatoryId { get; set; } = -1; public string? SignatoryName { get; set; } public string? SignatoryDesignation { get; set; } public string? SignatureImageStoragePath { get; set; } public string? PfxThumbprint { get; set; } } public async Task IssueAsync(TrainingCertificateDTO dto, LoginDTO loginDTO, CancellationToken ct) { try { GB5Trace.Step("resolve-certificate-signatory", new { dto.TrainingCertificateId, CertificateSigningConfig.SignatoryModuleId }); var signatoryJson = await _SignatoryBLL.GetActiveSignatoryByModule(CertificateSigningConfig.SignatoryModuleId, loginDTO, ct).ConfigureAwait(false); var signatory = string.IsNullOrWhiteSpace(signatoryJson) ? null : JsonConvert.DeserializeObject(signatoryJson); if (signatory == null || signatory.SignatoryId == 0) throw new InvalidOperationException( "No active signatory configured for the Training module (MSIGNATORY) — configure one before issuing signed certificates."); string verificationToken = GenerateVerificationToken(); string verifyUrl = BuildVerifyUrl(loginDTO, verificationToken); GB5Trace.Step("generate-certificate-qr", new { dto.TrainingCertificateId }); byte[] qrPng = QrCodeGenerator.GenerateQrPng(verifyUrl); byte[]? signatureImageBytes = null; if (!string.IsNullOrWhiteSpace(signatory.SignatureImageStoragePath)) { try { var storageProvider = await _StorageResolver.ResolveAsync(loginDTO, ct).ConfigureAwait(false); using var sigStream = await storageProvider.GetStreamAsync(signatory.SignatureImageStoragePath, ct).ConfigureAwait(false); using var sigMs = new MemoryStream(); await sigStream.CopyToAsync(sigMs, ct).ConfigureAwait(false); signatureImageBytes = sigMs.ToArray(); } catch (FileNotFoundException ex) { // Missing signature image must not block certificate issuance — the PDF // still renders (without the image) and can be crypto-signed if a // PfxThumbprint is configured. GB5Trace.Step("skip-certificate-signature-image-missing", new { dto.TrainingCertificateId, signatory.SignatureImageStoragePath, ex.Message }); } } GB5Trace.Step("render-certificate-html", new { dto.TrainingCertificateId }); string html = BuildCertificateHtml(dto, signatory, qrPng, signatureImageBytes); byte[] pdfBytes = await PdfRenderer.RenderHtmlToPdfAsync(html).ConfigureAwait(false); DetachedSignatureResult? signatureResult = null; if (!string.IsNullOrWhiteSpace(signatory.PfxThumbprint)) { try { GB5Trace.Step("sign-certificate-hash", new { dto.TrainingCertificateId, signatory.PfxThumbprint }); signatureResult = await DetachedDocumentSigner.SignAsync(pdfBytes, signatory.PfxThumbprint, _DigitalSignatureService, ct).ConfigureAwait(false); } catch (InvalidOperationException ex) { // Missing/inaccessible signing certificate must not block certificate // issuance — the PDF still renders and uploads, just without the embedded // cryptographic signature, until the cert is provisioned and this is retried. GB5Trace.Step("skip-certificate-crypto-signature-cert-unavailable", new { dto.TrainingCertificateId, signatory.PfxThumbprint, ex.Message }); } } else { GB5Trace.Step("skip-certificate-crypto-signature-no-thumbprint", new { dto.TrainingCertificateId }); } GB5Trace.Step("ensure-certificate-document-set", new { dto.TrainingCertificateId }); int documentSetDetailId = await _AttachmentUploadService.EnsureDocumentSetDetailAsync( documentSetCode: "TRNCERTSET", documentSetName: "Training Certificate Documents", documentTypeCode: "TRNCERT", documentTypeName: "Training Certificate", moduleId: CertificateSigningConfig.SignatoryModuleId, login: loginDTO, ct: ct).ConfigureAwait(false); using var uploadStream = new MemoryStream(pdfBytes); var uploadResponse = await _AttachmentUploadService.UploadAsync( new AttachmentUploadRequest { ObjectTypeId = EntityConstant.OBJECTTRAININGCERTIFICATE, ObjectId = dto.TrainingCertificateId, DocumentSetDetailId = documentSetDetailId, RowGuid = Guid.NewGuid() }, uploadStream, $"Certificate_{dto.CertificateNumber}.pdf", "application/pdf", loginDTO, ct).ConfigureAwait(false); await _CertificateDAL.SetVerificationTokenAndSignatory(dto.TrainingCertificateId, verificationToken, signatory.SignatoryId, loginDTO, ct).ConfigureAwait(false); if (signatureResult != null) { await _CertificateDAL.SaveCertificateSignature(new TrainingCertificateSignatureDTO { TrainingCertificateId = dto.TrainingCertificateId, AttachmentId = uploadResponse.AttachmentId, PdfSha256Hash = signatureResult.Sha256Hash, SignatureBase64 = signatureResult.SignatureBase64, SignatureAlgorithm = "RSA-SHA256", SigningThumbprint = signatory.PfxThumbprint ?? string.Empty, SignedOn = DateTime.UtcNow, TenantId = loginDTO.ClientId }, loginDTO, ct).ConfigureAwait(false); } await _EventLog.PublishEventLogAsync( "Training Certificate Issued", new { dto.TrainingCertificateId, uploadResponse.AttachmentId }, EventTypeConstant.TMSCERTIFICATEISSUEDEVENTTYPEID, dto.TrainingCertificateId, loginDTO, ct: ct).ConfigureAwait(false); } catch (Exception ex) { // Non-fatal by design: certificate ISSUANCE (the DB row) has already committed by // the time this pipeline runs — a rendering/signing failure must not roll that back, // just leave the certificate without a downloadable/verifiable PDF until retried. GB5Trace.MarkFailed("certificate-pdf-pipeline-failed", ex); throw; } } private static string GenerateVerificationToken() { // 256 bits of entropy, URL-safe — brute-forcing this is infeasible even without // additional rate-limiting on the verify endpoint. var bytes = RandomNumberGenerator.GetBytes(32); return Convert.ToBase64String(bytes).Replace('+', '-').Replace('/', '_').TrimEnd('='); } private static string BuildVerifyUrl(LoginDTO loginDTO, string token) { // Derived from the current request's own host (LoginDTO.RequestUrl, set by // BaseEndpoint) rather than a hardcoded config value — this is a plain string // property, not an ASP.NET type, so it's fine to read from BLL. Every current caller // (SaveCertificate/RenewCertificate/RegenerateCertificate) runs through an HTTP // endpoint with a Login header, so RequestUrl is always populated in practice — this // guards only a future caller (e.g. a scheduled renewal job) that builds LoginDTO by // hand without going through BaseEndpoint.HandleAsync. string baseUrl = ""; if (!string.IsNullOrWhiteSpace(loginDTO.RequestUrl) && Uri.TryCreate(loginDTO.RequestUrl, UriKind.Absolute, out var uri)) { string authority = uri.GetLeftPart(UriPartial.Authority); // RequestUrl is scheme://host/{gatewayPrefix}/{ThisEndpoint's own route}, e.g. // https://api-dev.goodbookserp.in/tms/TrainingCertificate/SaveTrainingCertificate. // The gateway prefix (if any — set from X-GB5-Gateway-Prefix, see // GatewayPrefixMiddleware) is everything before the TMS module's own route roots; // stripping from the first "/TrainingCertificate/" or "/Certificate/" segment // isolates it without needing a dedicated LoginDTO field. string path = uri.AbsolutePath; int cut = path.IndexOf("/TrainingCertificate/", StringComparison.OrdinalIgnoreCase); if (cut < 0) cut = path.IndexOf("/Certificate/", StringComparison.OrdinalIgnoreCase); string gatewayPrefix = cut > 0 ? path[..cut] : ""; baseUrl = authority + gatewayPrefix; } else { GB5Trace.Step("certificate-verify-url-missing-authority", new { loginDTO.DatabaseName, Reason = "LoginDTO.RequestUrl was empty or not absolute — QR will encode a host-relative link." }); } // A QR-scanned verify link carries no Login header/session — the tenant DB must // travel in the URL itself, since VerifyCertificate has no other way to know which // tenant's TTRAININGCERTIFICATE holds this token. return $"{baseUrl}/Certificate/VerifyCertificate?token={Uri.EscapeDataString(token)}" + $"&db={Uri.EscapeDataString(loginDTO.DatabaseName ?? string.Empty)}"; } private static string BuildCertificateHtml(TrainingCertificateDTO dto, SignatoryLookup signatory, byte[] qrPng, byte[]? signatureImageBytes) { string qrDataUri = $"data:image/png;base64,{Convert.ToBase64String(qrPng)}"; string? signatureDataUri = signatureImageBytes != null ? $"data:image/png;base64,{Convert.ToBase64String(signatureImageBytes)}" : null; string employeeName = WebUtility.HtmlEncode(dto.EmployeeName ?? ""); string certificateTitle = WebUtility.HtmlEncode(dto.CertificateTitle ?? dto.ProgrammeTitle ?? "Certificate of Completion"); string programmeTitle = WebUtility.HtmlEncode(dto.ProgrammeTitle ?? ""); string certificateNumber = WebUtility.HtmlEncode(dto.CertificateNumber ?? ""); string signatoryName = WebUtility.HtmlEncode(signatory.SignatoryName ?? ""); string signatoryDesignation = WebUtility.HtmlEncode(signatory.SignatoryDesignation ?? ""); return $@"

{certificateTitle}

This is to certify that
{employeeName}
has successfully completed
{programmeTitle}
Issued on {dto.IssuedOn:dd MMMM yyyy}
Certificate No: {certificateNumber}
"; } } }