Refactor to use streams for PDF handling
Refactored `IPdfDigitalSignatureService` to replace `byte[]` with `Stream` for input and output, improving memory efficiency and scalability. Updated `PdfDigitalSignatureService` implementation to support stream-based operations, including signing, checking, and extracting PDFs. Modified `SignPdfAsync` to handle multiple signatures sequentially using streams and ensured proper disposal of intermediate streams. Updated `CheckPdfSignatureAsync` and `ExtractOriginalPdfAsync` to use streams, reducing memory overhead. Refactored `PdfSignatureController` to use streams for file handling, enabling efficient processing of large files. Introduced helper methods for stream handling and enhanced cancellation support. Improved code maintainability by consolidating common logic and reducing duplication.
This commit is contained in:
@@ -11,37 +11,37 @@ public interface IPdfDigitalSignatureService
|
|||||||
/// Creates a digital signature for the provided PDF bytes and returns
|
/// Creates a digital signature for the provided PDF bytes and returns
|
||||||
/// a new signed PDF document.
|
/// a new signed PDF document.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pdfBytes">Source PDF content as a byte array.</param>
|
/// <param name="inputPdf">Source PDF content stream.</param>
|
||||||
|
/// <param name="outputPdf">Destination stream for signed PDF output.</param>
|
||||||
/// <param name="options">Optional signing options that map to PDF signature metadata fields.</param>
|
/// <param name="options">Optional signing options that map to PDF signature metadata fields.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
|
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
|
||||||
/// <returns>The signed PDF as a byte array.</returns>
|
Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, CancellationToken cancellationToken = default);
|
||||||
Task<byte[]> SignPdfAsync(byte[] pdfBytes, PdfSignOptionsDto? options = null, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Applies multiple signatures to the provided PDF in sequence and returns
|
/// Applies multiple signatures to the provided PDF in sequence and returns
|
||||||
/// the final signed PDF document.
|
/// the final signed PDF document.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pdfBytes">Source PDF content as a byte array.</param>
|
/// <param name="inputPdf">Source PDF content stream.</param>
|
||||||
|
/// <param name="outputPdf">Destination stream for signed PDF output.</param>
|
||||||
/// <param name="optionsList">Ordered signing options; one signature is applied per item.</param>
|
/// <param name="optionsList">Ordered signing options; one signature is applied per item.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
|
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
|
||||||
/// <returns>The signed PDF as a byte array.</returns>
|
Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default);
|
||||||
Task<byte[]> SignPdfAsync(byte[] pdfBytes, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks whether the provided PDF contains signature field(s) and whether
|
/// Checks whether the provided PDF contains signature field(s) and whether
|
||||||
/// those signatures are cryptographically valid.
|
/// those signatures are cryptographically valid.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pdfBytes">PDF content to inspect.</param>
|
/// <param name="inputPdf">PDF content stream to inspect.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
|
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
|
||||||
/// <returns>A structured result containing signature presence and per-signature details.</returns>
|
/// <returns>A structured result containing signature presence and per-signature details.</returns>
|
||||||
Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
|
Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Extracts the original signed revision from a signed PDF.
|
/// Extracts the original signed revision from a signed PDF.
|
||||||
/// For multi-revision files this returns the first signed revision bytes.
|
/// For multi-revision files this returns the first signed revision bytes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pdfBytes">Signed PDF bytes.</param>
|
/// <param name="inputPdf">Signed PDF content stream.</param>
|
||||||
|
/// <param name="outputPdf">Destination stream for original revision output.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
|
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
|
||||||
/// <returns>PDF bytes of the original signed revision.</returns>
|
Task ExtractOriginalPdfAsync(Stream inputPdf, Stream outputPdf, CancellationToken cancellationToken = default);
|
||||||
Task<byte[]> ExtractOriginalPdfAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,79 +18,121 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
|
|||||||
{
|
{
|
||||||
private static readonly Regex SignatureReasonMetaRegex = new(@"\|\s*SFMETA:v1;doc=(?<doc>[0-9a-fA-F-]{36});sha256=(?<sha>[A-Fa-f0-9]{64})$", RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
private static readonly Regex SignatureReasonMetaRegex = new(@"\|\s*SFMETA:v1;doc=(?<doc>[0-9a-fA-F-]{36});sha256=(?<sha>[A-Fa-f0-9]{64})$", RegexOptions.CultureInvariant | RegexOptions.Compiled);
|
||||||
|
|
||||||
public Task<byte[]> SignPdfAsync(byte[] pdfBytes, PdfSignOptionsDto? optionsModel = null, CancellationToken cancellationToken = default)
|
public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? optionsModel = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(pdfBytes);
|
ArgumentNullException.ThrowIfNull(inputPdf);
|
||||||
var pkcs11Options = options.Value;
|
ArgumentNullException.ThrowIfNull(outputPdf);
|
||||||
var request = optionsModel ?? new PdfSignOptionsDto();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
using var inputPdf = new MemoryStream(pdfBytes);
|
var (workingInput, ownsWorkingInput) = await EnsureSeekableInputAsync(inputPdf, cancellationToken);
|
||||||
using var signer = new PdfDocumentSigner(inputPdf);
|
|
||||||
|
|
||||||
var digestCalculator = new DigestCalculator(HashAlgorithmType.SHA256);
|
try
|
||||||
var signerInfo = new ExternalSignerInfo(PdfSignatureType.Pkcs7, 32768, digestCalculator);
|
|
||||||
var sourceHashHex = Convert.ToHexString(SHA256.HashData(pdfBytes));
|
|
||||||
var uuid = ResolveUuid(request.UUID);
|
|
||||||
var reasonWithMeta = BuildReasonWithMeta(request.Reason ?? pkcs11Options.DefaultReason, uuid, sourceHashHex);
|
|
||||||
var signatureFieldInfo = new PdfSignatureFieldInfo(1)
|
|
||||||
{
|
{
|
||||||
Name = $"Sig_{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"
|
workingInput.Position = 0;
|
||||||
};
|
var sourceHashHex = Convert.ToHexString(await SHA256.HashDataAsync(workingInput, cancellationToken));
|
||||||
|
workingInput.Position = 0;
|
||||||
|
|
||||||
var builder = new PdfDeferredSignatureBuilder(signerInfo, signatureFieldInfo)
|
var pkcs11Options = options.Value;
|
||||||
|
var request = optionsModel ?? new PdfSignOptionsDto();
|
||||||
|
var uuid = ResolveUuid(request.UUID);
|
||||||
|
var reasonWithMeta = BuildReasonWithMeta(request.Reason ?? pkcs11Options.DefaultReason, uuid, sourceHashHex);
|
||||||
|
|
||||||
|
using var signer = new PdfDocumentSigner(workingInput);
|
||||||
|
|
||||||
|
var digestCalculator = new DigestCalculator(HashAlgorithmType.SHA256);
|
||||||
|
var signerInfo = new ExternalSignerInfo(PdfSignatureType.Pkcs7, 32768, digestCalculator);
|
||||||
|
var signatureFieldInfo = new PdfSignatureFieldInfo(1)
|
||||||
|
{
|
||||||
|
Name = $"Sig_{DateTimeOffset.UtcNow:yyyyMMddHHmmss}"
|
||||||
|
};
|
||||||
|
|
||||||
|
var builder = new PdfDeferredSignatureBuilder(signerInfo, signatureFieldInfo)
|
||||||
|
{
|
||||||
|
Name = request.Name ?? pkcs11Options.CertificateLabel,
|
||||||
|
Reason = reasonWithMeta,
|
||||||
|
Location = request.Location,
|
||||||
|
ContactInfo = BuildContactInfo(request.Email, request.Phone),
|
||||||
|
SigningTime = pkcs11Options.UseUtcSigningTime ? DateTimeOffset.UtcNow : DateTimeOffset.Now,
|
||||||
|
ApplicationName = pkcs11Options.ApplicationName,
|
||||||
|
CertificationLevel = ToDevExpressCertificationLevel(request.CertificationLevel)
|
||||||
|
?? ToDevExpressCertificationLevel(pkcs11Options.DefaultCertificationLevel)
|
||||||
|
?? PdfCertificationLevel.NoCertification
|
||||||
|
};
|
||||||
|
|
||||||
|
var deferredSigner = signer.SignDeferred(builder);
|
||||||
|
var externalSigner = new Pkcs11ExternalSigner(pkcs11Options);
|
||||||
|
deferredSigner.Sign(outputPdf, externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid));
|
||||||
|
}
|
||||||
|
finally
|
||||||
{
|
{
|
||||||
Name = request.Name ?? pkcs11Options.CertificateLabel,
|
if (ownsWorkingInput)
|
||||||
Reason = reasonWithMeta,
|
await workingInput.DisposeAsync();
|
||||||
Location = request.Location,
|
}
|
||||||
ContactInfo = BuildContactInfo(request.Email, request.Phone),
|
|
||||||
SigningTime = pkcs11Options.UseUtcSigningTime ? DateTimeOffset.UtcNow : DateTimeOffset.Now,
|
|
||||||
ApplicationName = pkcs11Options.ApplicationName,
|
|
||||||
CertificationLevel = ToDevExpressCertificationLevel(request.CertificationLevel)
|
|
||||||
?? ToDevExpressCertificationLevel(pkcs11Options.DefaultCertificationLevel)
|
|
||||||
?? PdfCertificationLevel.NoCertification
|
|
||||||
};
|
|
||||||
|
|
||||||
var deferredSigner = signer.SignDeferred(builder);
|
|
||||||
var externalSigner = new Pkcs11ExternalSigner(pkcs11Options);
|
|
||||||
|
|
||||||
using var outputPdf = new MemoryStream();
|
|
||||||
deferredSigner.Sign(outputPdf, externalSigner.BuildSignature(deferredSigner.HashValue, digestCalculator.AlgorithmOid));
|
|
||||||
return Task.FromResult(outputPdf.ToArray());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<byte[]> SignPdfAsync(byte[] pdfBytes, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default)
|
public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(pdfBytes);
|
ArgumentNullException.ThrowIfNull(inputPdf);
|
||||||
|
ArgumentNullException.ThrowIfNull(outputPdf);
|
||||||
ArgumentNullException.ThrowIfNull(optionsList);
|
ArgumentNullException.ThrowIfNull(optionsList);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
if (optionsList.Count == 0)
|
if (optionsList.Count == 0)
|
||||||
return pdfBytes;
|
|
||||||
|
|
||||||
var currentPdf = pdfBytes;
|
|
||||||
|
|
||||||
foreach (var signOptions in optionsList)
|
|
||||||
{
|
{
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
await inputPdf.CopyToAsync(outputPdf, cancellationToken);
|
||||||
currentPdf = await SignPdfAsync(currentPdf, signOptions, cancellationToken);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
return currentPdf;
|
var (currentInput, ownsCurrentInput) = await EnsureSeekableInputAsync(inputPdf, cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
for (var i = 0; i < optionsList.Count; i++)
|
||||||
|
{
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
var isLast = i == optionsList.Count - 1;
|
||||||
|
|
||||||
|
if (isLast)
|
||||||
|
{
|
||||||
|
await SignPdfAsync(currentInput, outputPdf, optionsList[i], cancellationToken);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var intermediateOutput = new MemoryStream();
|
||||||
|
await SignPdfAsync(currentInput, intermediateOutput, optionsList[i], cancellationToken);
|
||||||
|
|
||||||
|
if (ownsCurrentInput)
|
||||||
|
await currentInput.DisposeAsync();
|
||||||
|
|
||||||
|
intermediateOutput.Position = 0;
|
||||||
|
currentInput = intermediateOutput;
|
||||||
|
ownsCurrentInput = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (ownsCurrentInput)
|
||||||
|
await currentInput.DisposeAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
public async Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(pdfBytes);
|
ArgumentNullException.ThrowIfNull(inputPdf);
|
||||||
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
using var inputPdf = new MemoryStream(pdfBytes);
|
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
|
||||||
using var signer = new PdfDocumentSigner(inputPdf);
|
|
||||||
|
using var pdfStream = new MemoryStream(pdfBytes);
|
||||||
|
using var signer = new PdfDocumentSigner(pdfStream);
|
||||||
var signatures = signer.GetSignatureInfo();
|
var signatures = signer.GetSignatureInfo();
|
||||||
var byteRanges = ParseByteRanges(pdfBytes);
|
var byteRanges = ParseByteRanges(pdfBytes);
|
||||||
|
|
||||||
if (signatures is null || signatures.Count == 0)
|
if (signatures is null || signatures.Count == 0)
|
||||||
{
|
{
|
||||||
return Task.FromResult(new PdfSignatureCheckResultDto(
|
return new PdfSignatureCheckResultDto(
|
||||||
Signed: false,
|
Signed: false,
|
||||||
Signatures: []));
|
Signatures: []);
|
||||||
}
|
}
|
||||||
|
|
||||||
var items = new List<PdfSignatureCheckItemDto>(signatures.Count);
|
var items = new List<PdfSignatureCheckItemDto>(signatures.Count);
|
||||||
@@ -177,19 +219,25 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(new PdfSignatureCheckResultDto(
|
return new PdfSignatureCheckResultDto(
|
||||||
Signed: true,
|
Signed: true,
|
||||||
Signatures: items));
|
Signatures: items);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<byte[]> ExtractOriginalPdfAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
public async Task ExtractOriginalPdfAsync(Stream inputPdf, Stream outputPdf, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(pdfBytes);
|
ArgumentNullException.ThrowIfNull(inputPdf);
|
||||||
|
ArgumentNullException.ThrowIfNull(outputPdf);
|
||||||
cancellationToken.ThrowIfCancellationRequested();
|
cancellationToken.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
|
||||||
|
|
||||||
var byteRanges = ParseByteRanges(pdfBytes);
|
var byteRanges = ParseByteRanges(pdfBytes);
|
||||||
if (byteRanges.Count == 0)
|
if (byteRanges.Count == 0)
|
||||||
return Task.FromResult(pdfBytes);
|
{
|
||||||
|
await outputPdf.WriteAsync(pdfBytes, cancellationToken);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var revisionLength = byteRanges
|
var revisionLength = byteRanges
|
||||||
.Select(GetSignedRevisionLength)
|
.Select(GetSignedRevisionLength)
|
||||||
@@ -200,9 +248,34 @@ public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options)
|
|||||||
if (revisionLength > pdfBytes.LongLength)
|
if (revisionLength > pdfBytes.LongLength)
|
||||||
throw new InvalidOperationException("Invalid ByteRange in signed PDF.");
|
throw new InvalidOperationException("Invalid ByteRange in signed PDF.");
|
||||||
|
|
||||||
var originalPdf = new byte[revisionLength];
|
await outputPdf.WriteAsync(pdfBytes.AsMemory(0, (int)revisionLength), cancellationToken);
|
||||||
Buffer.BlockCopy(pdfBytes, 0, originalPdf, 0, (int)revisionLength);
|
}
|
||||||
return Task.FromResult(originalPdf);
|
|
||||||
|
private static async Task<(Stream Stream, bool OwnsStream)> EnsureSeekableInputAsync(Stream input, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (input.CanSeek)
|
||||||
|
{
|
||||||
|
input.Position = 0;
|
||||||
|
return (input, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var buffered = new MemoryStream();
|
||||||
|
await input.CopyToAsync(buffered, cancellationToken);
|
||||||
|
buffered.Position = 0;
|
||||||
|
return (buffered, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<byte[]> ReadAllBytesAsync(Stream input, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (input is MemoryStream memory && memory.TryGetBuffer(out var buffer))
|
||||||
|
return buffer.AsSpan(0, (int)memory.Length).ToArray();
|
||||||
|
|
||||||
|
if (input.CanSeek)
|
||||||
|
input.Position = 0;
|
||||||
|
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await input.CopyToAsync(ms, cancellationToken);
|
||||||
|
return ms.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PdfSignatureCertificateInfoDto BuildCertificateInfo(X509Certificate2? certificate, DateTimeOffset now)
|
private static PdfSignatureCertificateInfoDto BuildCertificateInfo(X509Certificate2? certificate, DateTimeOffset now)
|
||||||
|
|||||||
@@ -39,14 +39,6 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService
|
|||||||
if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
|
if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
|
||||||
return BadRequest("Only PDF files are supported.");
|
return BadRequest("Only PDF files are supported.");
|
||||||
|
|
||||||
byte[] sourcePdf;
|
|
||||||
await using (var input = file.OpenReadStream())
|
|
||||||
using (var ms = new MemoryStream())
|
|
||||||
{
|
|
||||||
await input.CopyToAsync(ms, cancel);
|
|
||||||
sourcePdf = ms.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
var signOptions = new PdfSignOptionsDto
|
var signOptions = new PdfSignOptionsDto
|
||||||
{
|
{
|
||||||
UUID = request.UUID,
|
UUID = request.UUID,
|
||||||
@@ -58,9 +50,12 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService
|
|||||||
CertificationLevel = PdfSignatureCertificationLevel.FormFillingAndAnnotations
|
CertificationLevel = PdfSignatureCertificationLevel.FormFillingAndAnnotations
|
||||||
};
|
};
|
||||||
|
|
||||||
byte[] signedPdf = await signatureService.SignPdfAsync(sourcePdf, signOptions, cancel);
|
await using var input = file.OpenReadStream();
|
||||||
|
await using var output = new MemoryStream();
|
||||||
|
await signatureService.SignPdfAsync(input, output, signOptions, cancel);
|
||||||
|
output.Position = 0;
|
||||||
var fileName = Path.GetFileNameWithoutExtension(file.FileName);
|
var fileName = Path.GetFileNameWithoutExtension(file.FileName);
|
||||||
return File(signedPdf, "application/pdf", $"{fileName}-signed.pdf");
|
return File(output, "application/pdf", $"{fileName}-signed.pdf");
|
||||||
}
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
@@ -76,15 +71,8 @@ public class PdfSignatureController(IPdfDigitalSignatureService signatureService
|
|||||||
if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
|
if (!file.FileName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase))
|
||||||
return BadRequest("Only PDF files are supported.");
|
return BadRequest("Only PDF files are supported.");
|
||||||
|
|
||||||
byte[] sourcePdf;
|
await using var input = file.OpenReadStream();
|
||||||
await using (var input = file.OpenReadStream())
|
var result = await signatureService.CheckPdfSignatureAsync(input, cancel);
|
||||||
using (var ms = new MemoryStream())
|
|
||||||
{
|
|
||||||
await input.CopyToAsync(ms, cancel);
|
|
||||||
sourcePdf = ms.ToArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
var result = await signatureService.CheckPdfSignatureAsync(sourcePdf, cancel);
|
|
||||||
return Ok(result);
|
return Ok(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user