diff --git a/EnvelopeGenerator.Application/Common/Dto/PdfSignOptionsDto.cs b/EnvelopeGenerator.Application/Common/Dto/PdfSignOptionsDto.cs index 58832ca3..6ec8fa26 100644 --- a/EnvelopeGenerator.Application/Common/Dto/PdfSignOptionsDto.cs +++ b/EnvelopeGenerator.Application/Common/Dto/PdfSignOptionsDto.cs @@ -12,4 +12,5 @@ public sealed record PdfSignOptionsDto public string? Phone { get; set; } public string? ApplicationName { get; set; } public PdfSignatureCertificationLevel? CertificationLevel { get; set; } + public PdfVisualSignatureOptionsDto? VisualSignature { get; set; } } diff --git a/EnvelopeGenerator.Application/Common/Dto/PdfVisualSignatureOptionsDto.cs b/EnvelopeGenerator.Application/Common/Dto/PdfVisualSignatureOptionsDto.cs new file mode 100644 index 00000000..68591c13 --- /dev/null +++ b/EnvelopeGenerator.Application/Common/Dto/PdfVisualSignatureOptionsDto.cs @@ -0,0 +1,10 @@ +namespace EnvelopeGenerator.Application.Common.Dto; + +public sealed record PdfVisualSignatureOptionsDto( + byte[] ImageBytes, + int PageNumber, + double Left, + double Bottom, + double Width, + double Height, + bool ShowLabels = false); diff --git a/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs b/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs index c469359d..c3b97ad4 100644 --- a/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs +++ b/EnvelopeGenerator.Infrastructure.Crypt/Services/PdfDigitalSignatureService.cs @@ -38,14 +38,19 @@ public sealed class PdfDigitalSignatureService(IOptions options) var reasonWithMeta = BuildReasonWithMeta(request.Reason ?? pkcs11Options.DefaultReason, uuid, sourceHashHex); using var signer = new PdfDocumentSigner(workingInput); + var signaturePageNumber = ResolveSignaturePageNumber(request.VisualSignature?.PageNumber, signer.PageCount); var digestCalculator = new DigestCalculator(HashAlgorithmType.SHA256); var signerInfo = new ExternalSignerInfo(PdfSignatureType.Pkcs7, 32768, digestCalculator); - var signatureFieldInfo = new PdfSignatureFieldInfo(1) + var signatureFieldInfo = new PdfSignatureFieldInfo(signaturePageNumber) { Name = $"Sig_{DateTimeOffset.UtcNow:yyyyMMddHHmmss}" }; + var signatureBounds = CreateSignatureBounds(request.VisualSignature); + if (signatureBounds is not null) + signatureFieldInfo.SignatureBounds = signatureBounds; + var builder = new PdfDeferredSignatureBuilder(signerInfo, signatureFieldInfo) { Name = request.Name ?? pkcs11Options.CertificateLabel, @@ -59,6 +64,21 @@ public sealed class PdfDigitalSignatureService(IOptions options) ?? PdfCertificationLevel.NoCertification }; + if(request.VisualSignature is PdfVisualSignatureOptionsDto visualSignature) + { + builder.SetSignatureAppearance(new PdfSignatureAppearance + { + AppearanceType = PdfSignatureAppearanceType.Name, + ShowLabels = visualSignature.ShowLabels, + ShowName = true, + ShowDate = false, + ShowReason = false, + ShowLocation = false, + ShowDistinguishedName = false + }); + builder.SetImageData(visualSignature.ImageBytes); + } + var deferredSigner = signer.SignDeferred(builder); var externalSigner = new Pkcs11ExternalSigner(pkcs11Options); deferredSigner.Sign(outputPdf, externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid)); @@ -535,6 +555,37 @@ public sealed class PdfDigitalSignatureService(IOptions options) : Guid.NewGuid().ToString(); } + private static int ResolveSignaturePageNumber(int? requestedPageNumber, int pageCount) + { + var pageNumber = requestedPageNumber ?? 1; + if (pageNumber < 1) + throw new ArgumentOutOfRangeException(nameof(requestedPageNumber), "SignaturePageNumber must be greater than or equal to 1."); + + if (pageCount > 0 && pageNumber > pageCount) + throw new ArgumentOutOfRangeException(nameof(requestedPageNumber), $"SignaturePageNumber {pageNumber} exceeds page count {pageCount}."); + + return pageNumber; + } + + private static PdfRectangle? CreateSignatureBounds(PdfVisualSignatureOptionsDto? visualOptions) + { + if (visualOptions is null) + return null; + + var left = visualOptions.Left; + var bottom = visualOptions.Bottom; + var width = visualOptions.Width; + var height = visualOptions.Height; + + if (left < 0 || bottom < 0) + throw new ArgumentOutOfRangeException(nameof(visualOptions), "VisualSignature.Left and VisualSignature.Bottom cannot be negative."); + + if (width <= 0 || height <= 0) + throw new ArgumentOutOfRangeException(nameof(visualOptions), "VisualSignature.Width and VisualSignature.Height must be greater than zero."); + + return new PdfRectangle(left, bottom, left + width, bottom + height); + } + private static string BuildReasonWithMeta(string? businessReason, string uuid, string sourceHashHex) { var trimmedReason = string.IsNullOrWhiteSpace(businessReason) ? null : businessReason.Trim(); diff --git a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfSignatureController.cs b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfSignatureController.cs index cec1609e..f4c1b634 100644 --- a/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfSignatureController.cs +++ b/EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfSignatureController.cs @@ -6,38 +6,102 @@ using Microsoft.AspNetCore.Mvc; namespace EnvelopeGenerator.Server.Controllers; +/// +/// +/// +/// [ApiController] [Route("api/[controller]")] public class PdfSignatureController(IPdfDigitalSignatureService signatureService) : ControllerBase { - public sealed class SignPdfForm + /// + /// + /// + public class CheckPdfSignatureForm { - public IFormFile? File { get; set; } - public string? UUID { get; set; } - public string? Name { get; set; } - public string? Reason { get; set; } - public string? Location { get; set; } - public string? Email { get; set; } - public string? Phone { get; set; } + /// + /// + /// + public virtual required IFormFile File { get; init; } } - public sealed class CheckPdfSignatureForm + /// + /// + /// + public sealed class SignPdfForm : CheckPdfSignatureForm { - public IFormFile? File { get; set; } + /// + /// + /// + public required IFormFile SignatureImageFile { get; init; } + + /// + /// + /// + public required string? UUID { get; init; } + + /// + /// + /// + public required string? Name { get; init; } + + /// + /// + /// + public required string? Reason { get; init; } + + /// + /// + /// + public required string? Location { get; init; } + + /// + /// + /// + public required string? Email { get; init; } + + /// + /// + /// + public required string? Phone { get; init; } + + /// + /// + /// + public required VSignature Signature { get; init; } + + /// + /// + /// + /// + /// + /// + /// + /// + /// + public record VSignature( + int PageNumber = 1, + double Left = 100, + double Bottom = 100, + double Width = 220, + double Height = 80, + bool ShowLabels = true); } + /// + /// + /// + /// + /// + /// [AllowAnonymous] [HttpPost("sign")] [Consumes("multipart/form-data")] public async Task Sign([FromForm] SignPdfForm request, CancellationToken cancel) { - var file = request.File; - - if (file is null || file.Length == 0) - return BadRequest("PDF file is required."); - - if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) - return BadRequest("Only PDF files are supported."); + await using var signatureImageInput = request.SignatureImageFile.OpenReadStream(); + using var signatureImageBuffer = new MemoryStream(); + await signatureImageInput.CopyToAsync(signatureImageBuffer, cancel); var signOptions = new PdfSignOptionsDto { @@ -47,31 +111,37 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService Location = request.Location, Email = request.Email, Phone = request.Phone, - CertificationLevel = PdfSignatureCertificationLevel.FormFillingAndAnnotations + CertificationLevel = PdfSignatureCertificationLevel.FormFillingAndAnnotations, + VisualSignature = new PdfVisualSignatureOptionsDto( + ImageBytes: signatureImageBuffer.ToArray(), + PageNumber: request.Signature.PageNumber, + Left: request.Signature.Left, + Bottom: request.Signature.Bottom, + Width: request.Signature.Width, + Height: request.Signature.Height, + ShowLabels: request.Signature.ShowLabels) }; - await using var input = file.OpenReadStream(); - await using var output = new MemoryStream(); + await using var input = request.File.OpenReadStream(); + var output = new MemoryStream(); await signatureService.SignPdfAsync(input, output, signOptions, cancel); output.Position = 0; - var fileName = Path.GetFileNameWithoutExtension(file.FileName); + var fileName = Path.GetFileNameWithoutExtension(request.File.FileName); return File(output, "application/pdf", $"{fileName}-signed.pdf"); } + /// + /// + /// + /// + /// + /// [AllowAnonymous] [HttpPost("is-signed")] [Consumes("multipart/form-data")] public async Task IsSigned([FromForm] CheckPdfSignatureForm request, CancellationToken cancel) { - var file = request.File; - - if (file is null || file.Length == 0) - return BadRequest("PDF file is required."); - - if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)) - return BadRequest("Only PDF files are supported."); - - await using var input = file.OpenReadStream(); + await using var input = request.File.OpenReadStream(); var result = await signatureService.CheckPdfSignatureAsync(input, cancel); return Ok(result); }