Compare commits
108 Commits
940d129c11
...
feat/serve
| Author | SHA1 | Date | |
|---|---|---|---|
| 04cea1ca4b | |||
| 29108594d9 | |||
| 51c0f51738 | |||
| 816bbce9c6 | |||
| 5194d638ac | |||
| 6b3622b366 | |||
| 4f8fa9afd8 | |||
| 7a36316b7e | |||
| 68d9dbdcdf | |||
| 7f3d1afcb9 | |||
| 955ac57a1a | |||
| b8a96330c0 | |||
| 8f2744afc7 | |||
| c8ddfac8d0 | |||
| 1942d78155 | |||
| f361898ba4 | |||
| 7766e2ea25 | |||
| 63c60a91b2 | |||
| a5d2c477f6 | |||
| 3a71d57619 | |||
| 73b6c2712c | |||
| 200972c370 | |||
| 9f7519941a | |||
| 66e58b4a1a | |||
| de9d8c6f37 | |||
| 47f48ba12a | |||
| 60241c9c6d | |||
| 2a939b4cad | |||
| e89d471c6f | |||
| ece676fbcf | |||
| fac4ec28a4 | |||
| a3b11ed63f | |||
| 5627483589 | |||
| bf23e88cf8 | |||
| 9435561e78 | |||
| db1e91a3f6 | |||
| 0c664c0995 | |||
| 5db2f6f1e3 | |||
| a535e20b0e | |||
| bb35a68b1f | |||
| 715add7f51 | |||
| 621e22701a | |||
| 5ef32a2b0c | |||
| 54d16eb3b8 | |||
| ae3264253d | |||
| 4cbfe52d5b | |||
| 27c6b017cd | |||
| 8378c33465 | |||
| c7c4e1194d | |||
| 3b367eff7f | |||
| d0c3d79fbc | |||
| e81c7896a1 | |||
| 127e2b253e | |||
| 6d0b78072e | |||
| 3721ea9a1f | |||
| f86588551c | |||
| 0eaa9ff08f | |||
| ec79d7622e | |||
| f558d03bdf | |||
| db79053c62 | |||
| 85dd85aa92 | |||
| c7bc9fbcca | |||
| 1e8e5c96c4 | |||
| 61d04ca51a | |||
| af674a811d | |||
| 123889451f | |||
| 3b5075fe44 | |||
| 48302a374f | |||
| b2c245d6bd | |||
| e9c0ec139a | |||
| 819bdcfcfb | |||
| ad7686b7c2 | |||
| ade542ded0 | |||
| 01c1283622 | |||
| d969dbe843 | |||
| 41779a4f58 | |||
| bf739c1360 | |||
| 0f167d731e | |||
| 021d7cfe29 | |||
| e2322b41cd | |||
| 618451f0c1 | |||
| 96c7cd6e8a | |||
| 4047ba7c6b | |||
| 8386b85592 | |||
| 640f0b95fe | |||
| 181f7f4dec | |||
| 95961a296f | |||
| db613358d5 | |||
| 7a126050cf | |||
| 7081c5f64e | |||
| 12ec5b28fa | |||
| 925e60a45c | |||
| 897860b2bf | |||
| 8933cff558 | |||
| b0b9ba7212 | |||
| 33c75ea7e1 | |||
| 513efa0833 | |||
| 0c81d79a9b | |||
| 696cd1b623 | |||
| 2434f67627 | |||
| 776700b247 | |||
| 7a114f47a6 | |||
| ed44d841e7 | |||
| 4bd0cc2e94 | |||
| 8a4963472c | |||
| 4d3f72a3ed | |||
| 1ebd43e3b0 | |||
| 19aeda5aef |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -370,3 +370,4 @@ FodyWeavers.xsd
|
||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
|
||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
|
||||
/ignore/EnvelopeGenerator.Form
|
||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.Secrets.json
|
||||
|
||||
47
AGENTS.md
47
AGENTS.md
@@ -63,6 +63,36 @@ This section explains every markdown file in the repo: what it contains, why it
|
||||
|
||||
---
|
||||
|
||||
## Markdown Audit Snapshot (2026-10-05)
|
||||
|
||||
The following table summarizes repository markdown files by practical status: completed, partially complete, or removable candidates.
|
||||
|
||||
| File | Type | Status | Missing / Gap | Removal Recommendation |
|
||||
|---|---|---|---|---|
|
||||
| `AGENTS.md` | Agent onboarding | Partially complete | Route/migration statements can drift from real code over time. | Keep |
|
||||
| `COPILOT_CONTEXT.md` | Architecture reference | Partially complete | Some route/render details may be out of sync with other docs; needs regular reconciliation. | Keep |
|
||||
| `FORM_APPLICATION_CONTEXT.md` | Legacy-to-modern feature spec | Partially complete | Large sections still marked not implemented / needs implementation. | Keep |
|
||||
| `RECEIVER_PDF_VIEWER_CONTEXT.md` | Receiver PDF viewer deep-dive | Partially complete | Long migration plan needs periodic sync with active implementation. | Keep |
|
||||
| `SENDER_SAVE_EDIT_MIGRATION_PLAN.md` | Sender workflow implementation plan | Partially complete | No explicit implementation progress markers per phase. | Keep (until migration closes) |
|
||||
| `SENDER_PDF_TOOLBAR_PLAN_EN.md` | Sender PDF toolbar plan | Incomplete | Explicit pending item remains (`Toolbar visual polish`). | Keep (until done) |
|
||||
| `SENDER_EDITOR_WASM_PDF_EDITOR_IMPLEMENTATION_PLAN_en.md` | Sender editor WASM migration plan | Incomplete | Plan exists but completion/progress tracking is minimal. | Keep (until done) |
|
||||
| `EnvelopeGenerator.Server/EnvelopeGenerator.Server/README.md` | Deployment guide | Complete (operational) | No critical gap identified. | Keep |
|
||||
| `envelope-generator-ui/README.md` | Angular scaffold README | Complete but generic | Template text only; low project-specific value. | Removable or replace with project-specific summary |
|
||||
| `EnvelopeGenerator.Web/wwwroot/lib/typed.js@2.1.0/README.md` | Third-party vendor README | Complete (vendor) | Not project workflow documentation. | Keep with vendor assets |
|
||||
| `EnvelopeGenerator.Web/wwwroot/lib/jquery-validation/LICENSE.md` | Third-party license | Complete (legal) | No gap. | Keep |
|
||||
| `EnvelopeGenerator.Server/EnvelopeGenerator.Server/wwwroot/css/open-iconic/README.md` | Third-party vendor README | Complete (vendor) | Not project-specific guidance. | Keep with vendor assets |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/README.md` | Legacy PSPDFKit note | Outdated | Mentions deprecated PSPDFKit-based flow; may mislead if reused. | Removable/archive |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/typed.js@2.1.0/README.md` | Duplicate vendor README | Redundant | Duplicate copy under `ignore/`. | Removable/archive |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/bootstrap-icons/README.md` | Vendor README in archive area | Secondary | Archive-only utility, not active docs. | Removable/archive |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.Web/wwwroot/lib/jquery-validation/LICENSE.md` | Duplicate license in archive area | Redundant | Duplicate copy under `ignore/`. | Removable/archive (if legal policy allows) |
|
||||
| `ignore/EnvelopeGenerator/EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/README.md` | Duplicate Angular scaffold README | Redundant | Generic template + duplicate in archive path. | Removable/archive |
|
||||
|
||||
### Documentation Audit TODO
|
||||
|
||||
- [ ] Validate this markdown audit table against the current codebase and update statuses (`complete`, `partial`, `incomplete`, `removable`) when implementation changes land.
|
||||
|
||||
---
|
||||
|
||||
## Active Architecture (Post-Migration)
|
||||
|
||||
**Frontend:** Blazor Auto (Server+WASM hybrid)
|
||||
@@ -236,6 +266,23 @@ Manual testing workflow:
|
||||
3. Test sender login at `/sender/login`
|
||||
4. Test receiver flow at `/envelope/login/{envelopeKey}`
|
||||
|
||||
## Technical Debt TODOs
|
||||
|
||||
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor` to reduce complexity.
|
||||
- Move sender dashboard business/status logic into dedicated services and extension methods (for example effective status resolution, tab classification, receiver signed/rejected detection, and grid-layout filter sanitation).
|
||||
- Keep `EnvelopeSenderPage.razor` focused on UI composition/state orchestration; avoid embedding heavy domain logic directly in the Razor component.
|
||||
- Apply a modular page architecture across Server pages (starting with `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`): split large pages into smaller subcomponents/partial modules, eliminate duplicated code paths, and centralize shared UI/domain logic for maintainability.
|
||||
- Perform a senior-level page performance review across sender/receiver flows before further UX changes: verify route transitions, component mount/unmount behavior, loading-state correctness, duplicate API calls, and perceived latency (first meaningful paint and return-navigation responsiveness). Treat loading logic defects as architecture/performance issues first, not styling problems.
|
||||
- Audit `EnvelopeGenerator.Server/EnvelopeGenerator.Server.Client/Services/` for unnecessary request/payload remapping and remove redundant mappings where direct request DTO usage is safe; review client HTTP methods end-to-end for similar avoidable transformation layers.
|
||||
- Centralize dependency injection registrations into dedicated extension methods/modules (especially server + client service registrations in `Program.cs`) to reduce startup composition sprawl and improve maintainability.
|
||||
- Refactor `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/PdfRenderController.cs` + matching client service contract to remove JSON/base64 `byte[]` transport for PDF rendering; prefer binary streaming (`application/octet-stream` or multipart), avoid `data:` URL bloat where possible, and validate end-to-end memory/latency behavior with large PDFs.
|
||||
- Establish a DevExpress toast-based notification architecture using `DxToastProvider` + `IToastNotificationService` (tab/page-scoped provider strategy, standardized `ToastOptions` factory, and severity-to-style mapping) so sender/receiver flows show consistent, non-duplicated, actionable feedback.
|
||||
- Eliminate manual property-by-property request→domain mapping drift (example: `CreateEnvelopeCommandHandler`) by introducing a standardized mapping policy (AutoMapper/profile conventions or reflection/source-generator based mapper) and enforcing it across envelope workflows; this needs to be solved in the shared company Core library level so all services get the same guardrails by default.
|
||||
- P1 security workstream: remove dynamic SQL string composition/interpolation from active save/create/update paths (`string.Format`, `$"...{input}..."`, `ToSqlParam`) and migrate to parameterized queries only; treat this as a potential SQL injection vulnerability (stability + security risk), execute repo-wide audit, and add CI guardrails to block reintroduction.
|
||||
- Alternative UX design (Server component): in `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Components/Pages/EnvelopeSenderPage.razor`, enforce deterministic row double-click behavior as exactly one action (preview OR edit), and keep row action buttons/tooltips aligned with the same rule to eliminate ambiguous navigation.
|
||||
- Implement SPA-style language switching so the UI culture updates immediately without a full page reload.
|
||||
- Implement sender-scoped caching for "recent receiver suggestions" used during envelope creation (wizard/editor flows): cache key should include sender identity, support short TTL + invalidation hooks after successful envelope receiver writes, and prevent cross-sender suggestion leakage.
|
||||
|
||||
## Database
|
||||
|
||||
**SQL Server** (DD_ECM)
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
using DigitalData.Core.Exceptions;
|
||||
using FluentValidation;
|
||||
using MediatR;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Behaviors;
|
||||
|
||||
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
|
||||
where TRequest : notnull
|
||||
{
|
||||
private readonly IEnumerable<IValidator<TRequest>> _validators;
|
||||
|
||||
public ValidationBehavior(IEnumerable<IValidator<TRequest>> validators)
|
||||
{
|
||||
_validators = validators;
|
||||
}
|
||||
|
||||
public async Task<TResponse> Handle(
|
||||
TRequest request,
|
||||
RequestHandlerDelegate<TResponse> next,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_validators.Any())
|
||||
{
|
||||
var context = new ValidationContext<TRequest>(request);
|
||||
var failures = _validators
|
||||
.Select(v => v.Validate(context))
|
||||
.SelectMany(r => r.Errors)
|
||||
.Where(f => f is not null)
|
||||
.ToList();
|
||||
|
||||
if (failures.Count > 0)
|
||||
{
|
||||
var message = string.Join("; ", failures.Select(f => f.ErrorMessage).Distinct());
|
||||
throw new BadRequestException(message);
|
||||
}
|
||||
}
|
||||
|
||||
return await next();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using EnvelopeGenerator.Application.Common.Enums;
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Represents PKCS#11 signing configuration for PDF signing workflows.
|
||||
/// This options model maps to the <c>Crypt:Pkcs11</c> configuration section.
|
||||
/// </summary>
|
||||
public sealed class Pkcs11Options
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section path used for binding PKCS#11 options.
|
||||
/// </summary>
|
||||
public const string SectionName = "Crypt:Pkcs11";
|
||||
|
||||
/// <summary>
|
||||
/// Optional base directory that contains the <c>pkcs11</c> folder.
|
||||
/// If null/empty, runtime uses the application base directory.
|
||||
/// </summary>
|
||||
public string? AssetsRootPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional numeric slot identifier used to directly select the target token slot.
|
||||
/// </summary>
|
||||
public ulong? SlotId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional token label used as a fallback token selector when <see cref="SlotId"/> is not provided.
|
||||
/// </summary>
|
||||
public string? TokenLabel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional PKCS#11 proxy endpoint (for example <c>tcp://host:port</c>).
|
||||
/// </summary>
|
||||
public string? ProxySocket { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Resolved absolute PKCS#11 proxy module path used at runtime.
|
||||
/// Computed from <see cref="AssetsRootPath"/> and runtime defaults.
|
||||
/// </summary>
|
||||
public string ResolvedLibraryPath { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Resolved absolute pkcs11-tool path used at runtime.
|
||||
/// Computed from <see cref="AssetsRootPath"/> and runtime defaults.
|
||||
/// Can be null when tool fallback is not available.
|
||||
/// </summary>
|
||||
public string? ResolvedToolPath { get; private set; }
|
||||
|
||||
public void SetResolvedPaths(string resolvedLibraryPath, string? resolvedToolPath)
|
||||
{
|
||||
ResolvedLibraryPath = resolvedLibraryPath;
|
||||
ResolvedToolPath = resolvedToolPath;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// User PIN used to authenticate to the PKCS#11 token session.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public required string UserPin { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Label of the private key object used to create digital signatures.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public required string PrivateKeyLabel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Label of the certificate object returned/embedded with the signature.
|
||||
/// </summary>
|
||||
[Required]
|
||||
[MinLength(1)]
|
||||
public required string CertificateLabel { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Optional global default signing reason used when request-level options do not specify a value.
|
||||
/// </summary>
|
||||
public string? DefaultReason { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Application name embedded into PDF signature metadata.
|
||||
/// This value is controlled via app settings and is not client-overridable.
|
||||
/// </summary>
|
||||
public string? ApplicationName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Controls how fallback signing time is created when request-level options do not specify a value.
|
||||
/// <c>true</c> uses <see cref="DateTimeOffset.UtcNow"/>, <c>false</c> uses <see cref="DateTimeOffset.Now"/>.
|
||||
/// </summary>
|
||||
public bool UseUtcSigningTime { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Optional global default certification level used when request-level options do not specify a value.
|
||||
/// </summary>
|
||||
public PdfSignatureCertificationLevel? DefaultCertificationLevel { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
/// <summary>
|
||||
/// Data Transfer Object representing document metadata and elements without binary payload.
|
||||
/// </summary>
|
||||
public record DocumentDetailsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the document.
|
||||
/// </summary>
|
||||
public int Id { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the envelope ID to which the document belongs.
|
||||
/// </summary>
|
||||
public int EnvelopeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the date and time when the document was added.
|
||||
/// </summary>
|
||||
public DateTime AddedWhen { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of elements associated with the document for receiver interactions, if any.
|
||||
/// </summary>
|
||||
public IEnumerable<DocReceiverElementDto>? Elements { get; init; }
|
||||
}
|
||||
@@ -3,30 +3,25 @@
|
||||
/// <summary>
|
||||
/// Data Transfer Object representing a document within an envelope, including optional binary data and form elements.
|
||||
/// </summary>
|
||||
public class DocumentDto
|
||||
public record DocumentDto : DocumentDetailsDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the unique identifier of the document.
|
||||
///
|
||||
/// </summary>
|
||||
public int Id { get; set; }
|
||||
public DocumentDto() { }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the envelope ID to which the document belongs.
|
||||
///
|
||||
/// </summary>
|
||||
public int EnvelopeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the date and time when the document was added.
|
||||
/// </summary>
|
||||
public DateTime AddedWhen { get; set; }
|
||||
/// <param name="byteData"></param>
|
||||
/// <param name="details"></param>
|
||||
public DocumentDto(byte[] byteData, DocumentDetailsDto details) : base(details)
|
||||
{
|
||||
ByteData = byteData;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the binary data of the document, if available.
|
||||
/// </summary>
|
||||
public byte[]? ByteData { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the collection of elements associated with the document for receiver interactions, if any.
|
||||
/// </summary>
|
||||
public IEnumerable<DocReceiverElementDto>? Elements { get; set; }
|
||||
public byte[]? ByteData { get; init; }
|
||||
}
|
||||
@@ -86,6 +86,41 @@ public record EnvelopeDto : IEnvelope
|
||||
/// </summary>
|
||||
public int? CertificationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? FinalEmailToCreator { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? FinalEmailToReceivers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public bool? SendReminderEmails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? FirstReminderDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? ReminderIntervalDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? ExpiresWhenDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public int? ExpiresWarningWhenDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
|
||||
16
EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs
Normal file
16
EnvelopeGenerator.Application/Common/Dto/PdfPageImageDto.cs
Normal file
@@ -0,0 +1,16 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
public sealed class PdfPageImageDto
|
||||
{
|
||||
public int PageNumber { get; init; }
|
||||
|
||||
public double WidthPt { get; init; }
|
||||
|
||||
public double HeightPt { get; init; }
|
||||
|
||||
public int WidthPx { get; init; }
|
||||
|
||||
public int HeightPx { get; init; }
|
||||
|
||||
public string ImageDataUrl { get; init; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
public sealed class PdfRenderRequestDto
|
||||
{
|
||||
public byte[]? PdfBytes { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
using EnvelopeGenerator.Application.Common.Enums;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
public sealed record PdfSignOptionsDto
|
||||
{
|
||||
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 string? ApplicationName { get; set; }
|
||||
public PdfSignatureCertificationLevel? CertificationLevel { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
public sealed record PdfSignaturePrincipalDto(
|
||||
string? CommonName,
|
||||
string? Organization,
|
||||
string? Country,
|
||||
string? Full);
|
||||
|
||||
public sealed record PdfSignatureCertificateInfoDto(
|
||||
PdfSignaturePrincipalDto IssuedTo,
|
||||
PdfSignaturePrincipalDto IssuedBy,
|
||||
string? SerialNumber,
|
||||
DateTimeOffset? ValidFrom,
|
||||
DateTimeOffset? ValidTo,
|
||||
bool SelfSigned,
|
||||
bool Expired,
|
||||
bool NotYetValid,
|
||||
bool IsSigner,
|
||||
int? Version,
|
||||
string? KeyAlgorithm,
|
||||
string? SignatureAlgorithm,
|
||||
string? Pem,
|
||||
int? KeySizeBits);
|
||||
|
||||
public sealed record PdfSignatureCheckItemDto(
|
||||
bool CoversWholeDoc,
|
||||
string? Reason,
|
||||
string? BusinessReason,
|
||||
string? Location,
|
||||
string? DeclaredName,
|
||||
string? ContactInfo,
|
||||
string? Email,
|
||||
string? Phone,
|
||||
string? DigestAlgorithm,
|
||||
string? SignatureAlgorithm,
|
||||
string? SignerName,
|
||||
string? Issuer,
|
||||
string? SerialNumber,
|
||||
DateTimeOffset? ValidFrom,
|
||||
DateTimeOffset? ValidTo,
|
||||
string ExpiryCheckedAgainst,
|
||||
IReadOnlyList<PdfSignatureCertificateInfoDto> Certificates,
|
||||
bool Unchanged,
|
||||
string? FieldName,
|
||||
bool IsCryptographicallyValid,
|
||||
string? SigningTime,
|
||||
string? Filter,
|
||||
string? SubFilter,
|
||||
string? ApplicationName,
|
||||
string? UUID,
|
||||
string? OriginalDocumentSha256,
|
||||
string? MessageDigestHex,
|
||||
string? Error);
|
||||
|
||||
public sealed record PdfSignatureCheckResultDto(
|
||||
bool Signed,
|
||||
IReadOnlyList<PdfSignatureCheckItemDto> Signatures);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
public sealed record Pkcs11SelfTestStatusDto(
|
||||
bool Completed,
|
||||
bool Success,
|
||||
string Mode,
|
||||
string Summary,
|
||||
DateTimeOffset? CheckedAtUtc,
|
||||
string? InteropError,
|
||||
string? ToolError);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
|
||||
public class RecentReceiverSuggestionDto
|
||||
{
|
||||
public required string FullName { get; init; }
|
||||
|
||||
public required string EmailAddress { get; init; }
|
||||
|
||||
public required DateTime LastUsedAt { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace EnvelopeGenerator.Application.Common.Enums;
|
||||
|
||||
public enum PdfSignatureCertificationLevel
|
||||
{
|
||||
NoCertification = 0,
|
||||
NoChangesAllowed = 1,
|
||||
FormFillingAndSigning = 2,
|
||||
FormFillingAndAnnotations = 3
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
|
||||
public interface IPdfPageImageRenderer
|
||||
{
|
||||
Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Defines PDF digital-signing operations for the application boundary.
|
||||
/// </summary>
|
||||
public interface IPdfDigitalSignatureService
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a digital signature for the provided PDF bytes and returns
|
||||
/// a new signed PDF document.
|
||||
/// </summary>
|
||||
/// <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="cancellationToken">Cancellation token for cooperative cancellation.</param>
|
||||
Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Applies multiple signatures to the provided PDF in sequence and returns
|
||||
/// the final signed PDF document.
|
||||
/// </summary>
|
||||
/// <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="cancellationToken">Cancellation token for cooperative cancellation.</param>
|
||||
Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Checks whether the provided PDF contains signature field(s) and whether
|
||||
/// those signatures are cryptographically valid.
|
||||
/// </summary>
|
||||
/// <param name="inputPdf">PDF content stream to inspect.</param>
|
||||
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
|
||||
/// <returns>A structured result containing signature presence and per-signature details.</returns>
|
||||
Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the original signed revision from a signed PDF.
|
||||
/// For multi-revision files this returns the first signed revision bytes.
|
||||
/// </summary>
|
||||
/// <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>
|
||||
Task ExtractOriginalPdfAsync(Stream inputPdf, Stream outputPdf, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
|
||||
public interface IPkcs11SelfTestStatusProvider
|
||||
{
|
||||
Pkcs11SelfTestStatusDto GetStatus();
|
||||
}
|
||||
@@ -32,6 +32,7 @@ public class MappingProfile : Profile
|
||||
.ForMember(dest => dest.EnvelopeTypeTitle,
|
||||
opt => opt.MapFrom(src => src.Type != null ? src.Type.Title : null));
|
||||
CreateMap<Document, DocumentDto>();
|
||||
CreateMap<Document, DocumentDetailsDto>();
|
||||
CreateMap<History, HistoryDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
|
||||
CreateMap<History, HistoryCreateDto>().ForMember(dest => dest.ActionDate, opt => opt.MapFrom(src => src.ChangedWhen));
|
||||
CreateMap<EnvelopeReceiver, EnvelopeReceiverDto>();
|
||||
|
||||
@@ -10,6 +10,8 @@ using System.Reflection;
|
||||
using MediatR;
|
||||
using EnvelopeGenerator.Application.DocReceiverElements.Commands;
|
||||
using EnvelopeGenerator.Application.DocReceiverElements.Behaviors;
|
||||
using EnvelopeGenerator.Application.Common.Behaviors;
|
||||
using FluentValidation;
|
||||
|
||||
namespace EnvelopeGenerator.Application;
|
||||
|
||||
@@ -60,6 +62,9 @@ public static class DependencyInjection
|
||||
{
|
||||
cfg.RegisterServicesFromAssembly(Assembly.GetExecutingAssembly());
|
||||
|
||||
// Generic request validation
|
||||
cfg.AddOpenBehavior(typeof(ValidationBehavior<,>));
|
||||
|
||||
// Register SignCommand pipeline behaviors in execution order
|
||||
// 0. EnvelopeReceiverResolutionBehavior - Resolves EnvelopeReceiver from query parameters (executes FIRST)
|
||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, EnvelopeReceiverResolutionBehavior>();
|
||||
@@ -80,6 +85,8 @@ public static class DependencyInjection
|
||||
cfg.AddBehavior<IPipelineBehavior<SigningCommand, Unit>, SendSignedMailBehavior>();
|
||||
});
|
||||
|
||||
services.AddValidatorsFromAssembly(Assembly.GetExecutingAssembly());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using AutoMapper;
|
||||
using AutoMapper.QueryableExtensions;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Documents.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Represents a query to read document metadata/details without binary payload.
|
||||
/// </summary>
|
||||
/// <param name="EnvelopeId">The identifier of the envelope associated with the document.</param>
|
||||
/// <param name="UserId">The unique identifier of the sending user. Optional.</param>
|
||||
public record ReadDocumentDetailsQuery(int EnvelopeId, int? UserId = null) : IRequest<DocumentDetailsDto?>;
|
||||
|
||||
/// <summary>
|
||||
/// Handles queries for reading document details without loading BYTE_DATA.
|
||||
/// </summary>
|
||||
public class ReadDocumentDetailsQueryHandler : IRequestHandler<ReadDocumentDetailsQuery, DocumentDetailsDto?>
|
||||
{
|
||||
private readonly IRepository<Document> _repo;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public ReadDocumentDetailsQueryHandler(IRepository<Document> repo, IMapper mapper)
|
||||
{
|
||||
_repo = repo;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<DocumentDetailsDto?> Handle(ReadDocumentDetailsQuery query, CancellationToken cancel)
|
||||
{
|
||||
var docQuery = _repo.Query.AsNoTracking();
|
||||
|
||||
if (query.UserId is int userId)
|
||||
docQuery = docQuery.Where(doc => doc.Envelope.UserId == userId);
|
||||
|
||||
return await docQuery
|
||||
.Where(d => d.EnvelopeId == query.EnvelopeId)
|
||||
.ProjectTo<DocumentDetailsDto>(_mapper.ConfigurationProvider)
|
||||
.FirstOrDefaultAsync(cancel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Documents.Queries;
|
||||
|
||||
public class ReadDocumentDetailsQueryValidator : AbstractValidator<ReadDocumentDetailsQuery>
|
||||
{
|
||||
public ReadDocumentDetailsQueryValidator()
|
||||
{
|
||||
RuleFor(x => x.EnvelopeId)
|
||||
.GreaterThan(0)
|
||||
.WithMessage("EnvelopeId is required.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using FluentValidation;
|
||||
|
||||
namespace EnvelopeGenerator.Application.Documents.Queries;
|
||||
|
||||
public class ReadDocumentQueryValidator : AbstractValidator<ReadDocumentQuery>
|
||||
{
|
||||
public ReadDocumentQueryValidator()
|
||||
{
|
||||
RuleFor(x => x)
|
||||
.Must(x => (x.Id ?? 0) > 0 || (x.EnvelopeId ?? 0) > 0)
|
||||
.WithMessage("Id or EnvelopeId is required.");
|
||||
|
||||
RuleFor(x => x.Id)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.Id.HasValue)
|
||||
.WithMessage("Id must be greater than 0.");
|
||||
|
||||
RuleFor(x => x.EnvelopeId)
|
||||
.GreaterThan(0)
|
||||
.When(x => x.EnvelopeId.HasValue)
|
||||
.WithMessage("EnvelopeId must be greater than 0.");
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@
|
||||
<TargetFrameworks>net7.0;net8.0;net9.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
@@ -19,6 +20,8 @@
|
||||
<PackageReference Include="DigitalData.Core.Client" Version="2.1.0" />
|
||||
<PackageReference Include="DigitalData.Core.Exceptions" Version="1.1.0" />
|
||||
<PackageReference Include="DigitalData.EmailProfilerDispatcher" Version="3.1.1" />
|
||||
<PackageReference Include="FluentValidation" Version="11.11.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="11.11.0" />
|
||||
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
|
||||
<PackageReference Include="MediatR" Version="12.5.0" />
|
||||
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
|
||||
|
||||
@@ -16,5 +16,9 @@ public class MappingProfile : Profile
|
||||
public MappingProfile()
|
||||
{
|
||||
CreateMap<Receiver, ReceiverDto>();
|
||||
|
||||
CreateMap<EnvelopeReceiver, RecentReceiverSuggestionDto>()
|
||||
.ForMember(dest => dest.FullName, opt => opt.MapFrom(src => src.Name))
|
||||
.ForMember(dest => dest.EmailAddress, opt => opt.MapFrom(src => src.Receiver.EmailAddress));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
|
||||
|
||||
/// <summary>
|
||||
/// Reads recent receiver suggestions for envelope creation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sender scope is assigned via chain method to avoid exposing username mutation from API query binding.
|
||||
/// </remarks>
|
||||
public record ReadRecentReceiverSuggestionsQuery : IRequest<IReadOnlyList<RecentReceiverSuggestionDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// Sender username used for scoping suggestion results.
|
||||
/// </summary>
|
||||
internal string? Username { get; private init; }
|
||||
|
||||
/// <summary>
|
||||
/// Desired number of results.
|
||||
/// </summary>
|
||||
public int? Take { get; init; } = null;
|
||||
|
||||
/// <summary>
|
||||
/// Returns a new query instance scoped to the specified sender username.
|
||||
/// </summary>
|
||||
/// <param name="username">Sender username from authenticated claims.</param>
|
||||
/// <returns>Scoped query instance.</returns>
|
||||
public ReadRecentReceiverSuggestionsQuery ForSender(string username)
|
||||
{
|
||||
return this with { Username = username };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles <see cref="ReadRecentReceiverSuggestionsQuery"/>.
|
||||
/// </summary>
|
||||
public class ReadRecentReceiverSuggestionsQueryHandler(IRepository<EnvelopeReceiver> repository)
|
||||
: IRequestHandler<ReadRecentReceiverSuggestionsQuery, IReadOnlyList<RecentReceiverSuggestionDto>>
|
||||
{
|
||||
/// <summary>
|
||||
/// Retrieves distinct recent receiver suggestions grouped by receiver email.
|
||||
/// </summary>
|
||||
/// <param name="request">Suggestion query.</param>
|
||||
/// <param name="cancel">Cancellation token.</param>
|
||||
/// <returns>Ordered list of recent receiver suggestions.</returns>
|
||||
public async Task<IReadOnlyList<RecentReceiverSuggestionDto>> Handle(ReadRecentReceiverSuggestionsQuery request, CancellationToken cancel)
|
||||
{
|
||||
var query = repository.Query
|
||||
.AsNoTracking()
|
||||
.Where(er => er.Receiver != null);
|
||||
|
||||
if (request.Username is not null)
|
||||
query = query.Where(er => er.Envelope != null && er.Envelope.User.Username == request.Username);
|
||||
|
||||
// 1) For each email, compute the latest usage timestamp.
|
||||
var latestPerEmail = query
|
||||
.GroupBy(er => er.Receiver!.EmailAddress)
|
||||
.Select(g => new
|
||||
{
|
||||
EmailAddress = g.Key,
|
||||
LastUsedAt = g.Max(er => er.AddedWhen),
|
||||
});
|
||||
|
||||
// 2) Join back to rows that match the latest timestamp per email.
|
||||
var latestRows =
|
||||
from er in query
|
||||
join latest in latestPerEmail
|
||||
on new { EmailAddress = er.Receiver!.EmailAddress, LastUsedAt = er.AddedWhen }
|
||||
equals new { latest.EmailAddress, latest.LastUsedAt }
|
||||
select new
|
||||
{
|
||||
EmailAddress = er.Receiver!.EmailAddress,
|
||||
FullName = er.Name,
|
||||
LastUsedAt = er.AddedWhen,
|
||||
};
|
||||
|
||||
// 3) Tie-break: if multiple rows share the same latest timestamp for an email,
|
||||
// pick a deterministic name via Max(FullName), then order by most recent usage.
|
||||
var suggestions = latestRows
|
||||
.GroupBy(x => new { x.EmailAddress, x.LastUsedAt })
|
||||
.Select(g => new RecentReceiverSuggestionDto
|
||||
{
|
||||
EmailAddress = g.Key.EmailAddress,
|
||||
FullName = g.Max(x => x.FullName) ?? string.Empty,
|
||||
LastUsedAt = g.Key.LastUsedAt,
|
||||
})
|
||||
.OrderByDescending(x => x.LastUsedAt)
|
||||
.ThenBy(x => x.EmailAddress);
|
||||
|
||||
return request.Take is int take
|
||||
? await suggestions.Take(take).ToListAsync(cancel)
|
||||
: await suggestions.ToListAsync(cancel);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using MediatR;
|
||||
using AutoMapper;
|
||||
using DigitalData.Core.Abstraction.Application.Repository;
|
||||
using EnvelopeGenerator.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -18,17 +17,14 @@ public record ReadEnvelopeTypesQuery : IRequest<IEnumerable<EnvelopeTypeDto>>;
|
||||
public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQuery, IEnumerable<EnvelopeTypeDto>>
|
||||
{
|
||||
private readonly IRepository<EnvelopeType> _repository;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="repository"></param>
|
||||
/// <param name="mapper"></param>
|
||||
public ReadEnvelopeTypesQueryHandler(IRepository<EnvelopeType> repository, IMapper mapper)
|
||||
public ReadEnvelopeTypesQueryHandler(IRepository<EnvelopeType> repository)
|
||||
{
|
||||
_repository = repository;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -39,7 +35,28 @@ public class ReadEnvelopeTypesQueryHandler : IRequestHandler<ReadEnvelopeTypesQu
|
||||
/// <returns></returns>
|
||||
public async Task<IEnumerable<EnvelopeTypeDto>> Handle(ReadEnvelopeTypesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var types = await _repository.Query.ToListAsync(cancellationToken);
|
||||
return _mapper.Map<IEnumerable<EnvelopeTypeDto>>(types);
|
||||
// TODO: Temporary manual projection to avoid null-materialization crashes from legacy nullable DB columns in EnvelopeType.
|
||||
// Revisit and switch to AutoMapper query projection (ProjectTo) after schema nullability and mapping contracts are stabilized.
|
||||
return await _repository.Query
|
||||
.AsNoTracking()
|
||||
.Select(t => new EnvelopeTypeDto
|
||||
{
|
||||
Id = t.Id,
|
||||
Title = t.Title ?? string.Empty,
|
||||
Language = string.IsNullOrWhiteSpace(t.Language) ? "de-DE" : t.Language,
|
||||
ExpiresDays = t.ExpiresDays,
|
||||
CertificationType = (int?)t.CertificationType,
|
||||
UseAccessCode = t.UseAccessCode,
|
||||
FinalEmailToCreator = (int?)t.FinalEmailToCreator,
|
||||
FinalEmailToReceivers = (int?)t.FinalEmailToReceivers,
|
||||
AddedWhen = t.AddedWhen,
|
||||
ChangedWhen = t.ChangedWhen,
|
||||
ExpiresWarningDays = t.ExpiresWarningDays,
|
||||
SendReminderEmails = t.SendReminderEmails,
|
||||
FirstReminderDays = t.FirstReminderDays,
|
||||
ReminderIntervalDays = t.ReminderIntervalDays,
|
||||
ContractType = t.ContractType
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,66 @@ namespace EnvelopeGenerator.Application.Envelopes.Commands;
|
||||
/// </summary>
|
||||
public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||
{
|
||||
/// <summary>
|
||||
/// Optionale Umschlag-ID fuer Aktualisierungsvorgaenge.
|
||||
/// </summary>
|
||||
public int? EnvelopeId { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Kennung des ausgewaehlten Umschlagtyps (Vorlage).
|
||||
/// </summary>
|
||||
public int? EnvelopeTypeId { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Sprachcode fuer Umschlaginhalt und Benachrichtigungen.
|
||||
/// </summary>
|
||||
public string? Language { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gibt an, ob der Zugriffscode fuer Empfaenger aktiviert ist.
|
||||
/// </summary>
|
||||
public bool? UseAccessCode { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Zertifizierungstyp fuer den Signaturprozess.
|
||||
/// </summary>
|
||||
public int? CertificationType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Finale E-Mail-Einstellung fuer den Ersteller.
|
||||
/// </summary>
|
||||
public int? FinalEmailToCreator { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Finale E-Mail-Einstellung fuer die Empfaenger.
|
||||
/// </summary>
|
||||
public int? FinalEmailToReceivers { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gibt an, ob Erinnerungs-E-Mails aktiviert sind.
|
||||
/// </summary>
|
||||
public bool? SendReminderEmails { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Anzahl der Tage bis zur ersten Erinnerung.
|
||||
/// </summary>
|
||||
public int? FirstReminderDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Intervall in Tagen zwischen Erinnerungs-E-Mails.
|
||||
/// </summary>
|
||||
public int? ReminderIntervalDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Anzahl der Tage bis zum Ablauf des Umschlags.
|
||||
/// </summary>
|
||||
public int? ExpiresWhenDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Anzahl der Tage vor Ablauf, ab denen gewarnt wird.
|
||||
/// </summary>
|
||||
public int? ExpiresWarningWhenDays { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Der Titel des Umschlags. Dies ist ein Pflichtfeld.
|
||||
/// </summary>
|
||||
@@ -29,10 +87,19 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
|
||||
/// </summary>
|
||||
public bool TFAEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Gibt an, ob der Umschlag nach dem Speichern sofort versendet werden soll.
|
||||
/// </summary>
|
||||
public bool Send { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Optionaler PDF-Dokumentinhalt als Base64.
|
||||
/// </summary>
|
||||
public CreateEnvelopeDocumentDto? Document { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Empfaengerliste inklusive optionaler Signaturfeld-Koordinaten.
|
||||
/// </summary>
|
||||
public List<CreateEnvelopeReceiverDto> Receivers { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -33,6 +33,8 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
|
||||
private IRepository<EnvelopeReceiver> EnvelopeReceiverRepository => _provider.GetRequiredService<IRepository<EnvelopeReceiver>>();
|
||||
|
||||
private IRepository<EnvelopeType> EnvelopeTypeRepository => _provider.GetRequiredService<IRepository<EnvelopeType>>();
|
||||
|
||||
private IRepository<Receiver> ReceiverRepository => _provider.GetRequiredService<IRepository<Receiver>>();
|
||||
|
||||
private IRepository<DocReceiverElement> DocReceiverElementRepository => _provider.GetRequiredService<IRepository<DocReceiverElement>>();
|
||||
@@ -73,6 +75,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
var now = DateTime.Now;
|
||||
var title = request.Title?.Trim() ?? string.Empty;
|
||||
var message = request.Message?.Trim() ?? string.Empty;
|
||||
var settings = await ResolveEnvelopeSettingsAsync(request, existing: null, cancel);
|
||||
|
||||
Envelope envelope;
|
||||
if (request.EnvelopeId is int envelopeId)
|
||||
@@ -85,11 +88,27 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
if (envelope.UserId != request.UserId)
|
||||
throw new ForbiddenException();
|
||||
|
||||
settings = await ResolveEnvelopeSettingsAsync(request, envelope, cancel);
|
||||
|
||||
await Repository.UpdateAsync(e =>
|
||||
{
|
||||
e.Title = title;
|
||||
e.Message = message;
|
||||
e.TfaEnabled = request.TFAEnabled;
|
||||
e.EnvelopeTypeId = settings.EnvelopeTypeId;
|
||||
e.Language = settings.Language;
|
||||
e.UseAccessCode = settings.UseAccessCode;
|
||||
e.CertificationType = settings.CertificationType;
|
||||
e.FinalEmailToCreator = settings.FinalEmailToCreator;
|
||||
e.FinalEmailToReceivers = settings.FinalEmailToReceivers;
|
||||
e.SendReminderEmails = settings.SendReminderEmails;
|
||||
e.FirstReminderDays = settings.FirstReminderDays;
|
||||
e.ReminderIntervalDays = settings.ReminderIntervalDays;
|
||||
e.ExpiresWhenDays = settings.ExpiresWhenDays;
|
||||
e.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
|
||||
e.ContractType = settings.ContractType;
|
||||
e.ExpiresWhen = settings.ExpiresWhen;
|
||||
e.ExpiresWarningWhen = settings.ExpiresWarningWhen;
|
||||
e.Status = EnvelopeStatus.EnvelopeSaved;
|
||||
e.ChangedWhen = now;
|
||||
}, e => e.Id == envelopeId, cancel);
|
||||
@@ -97,6 +116,20 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
envelope.Title = title;
|
||||
envelope.Message = message;
|
||||
envelope.TfaEnabled = request.TFAEnabled;
|
||||
envelope.EnvelopeTypeId = settings.EnvelopeTypeId;
|
||||
envelope.Language = settings.Language;
|
||||
envelope.UseAccessCode = settings.UseAccessCode;
|
||||
envelope.CertificationType = settings.CertificationType;
|
||||
envelope.FinalEmailToCreator = settings.FinalEmailToCreator;
|
||||
envelope.FinalEmailToReceivers = settings.FinalEmailToReceivers;
|
||||
envelope.SendReminderEmails = settings.SendReminderEmails;
|
||||
envelope.FirstReminderDays = settings.FirstReminderDays;
|
||||
envelope.ReminderIntervalDays = settings.ReminderIntervalDays;
|
||||
envelope.ExpiresWhenDays = settings.ExpiresWhenDays;
|
||||
envelope.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
|
||||
envelope.ContractType = settings.ContractType;
|
||||
envelope.ExpiresWhen = settings.ExpiresWhen;
|
||||
envelope.ExpiresWarningWhen = settings.ExpiresWarningWhen;
|
||||
envelope.Status = EnvelopeStatus.EnvelopeSaved;
|
||||
envelope.ChangedWhen = now;
|
||||
}
|
||||
@@ -114,12 +147,69 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
AddedWhen = now,
|
||||
ChangedWhen = now,
|
||||
Comment = string.Empty,
|
||||
Language = "de-DE",
|
||||
EnvelopeTypeId = settings.EnvelopeTypeId,
|
||||
Language = settings.Language,
|
||||
TfaEnabled = request.TFAEnabled,
|
||||
UseAccessCode = false,
|
||||
SendReminderEmails = false
|
||||
UseAccessCode = settings.UseAccessCode,
|
||||
CertificationType = settings.CertificationType,
|
||||
FinalEmailToCreator = settings.FinalEmailToCreator,
|
||||
FinalEmailToReceivers = settings.FinalEmailToReceivers,
|
||||
SendReminderEmails = settings.SendReminderEmails,
|
||||
FirstReminderDays = settings.FirstReminderDays,
|
||||
ReminderIntervalDays = settings.ReminderIntervalDays,
|
||||
ExpiresWhenDays = settings.ExpiresWhenDays,
|
||||
ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays,
|
||||
ExpiresWhen = settings.ExpiresWhen,
|
||||
ExpiresWarningWhen = settings.ExpiresWarningWhen,
|
||||
ContractType = settings.ContractType
|
||||
}, cancel);
|
||||
|
||||
if (request.UseSQLExecutor)
|
||||
{
|
||||
await Repository.UpdateAsync(e =>
|
||||
{
|
||||
e.Title = title;
|
||||
e.Message = message;
|
||||
e.TfaEnabled = request.TFAEnabled;
|
||||
e.EnvelopeTypeId = settings.EnvelopeTypeId;
|
||||
e.Language = settings.Language;
|
||||
e.UseAccessCode = settings.UseAccessCode;
|
||||
e.CertificationType = settings.CertificationType;
|
||||
e.FinalEmailToCreator = settings.FinalEmailToCreator;
|
||||
e.FinalEmailToReceivers = settings.FinalEmailToReceivers;
|
||||
e.SendReminderEmails = settings.SendReminderEmails;
|
||||
e.FirstReminderDays = settings.FirstReminderDays;
|
||||
e.ReminderIntervalDays = settings.ReminderIntervalDays;
|
||||
e.ExpiresWhenDays = settings.ExpiresWhenDays;
|
||||
e.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
|
||||
e.ExpiresWhen = settings.ExpiresWhen;
|
||||
e.ExpiresWarningWhen = settings.ExpiresWarningWhen;
|
||||
e.ContractType = settings.ContractType;
|
||||
e.Status = EnvelopeStatus.EnvelopeSaved;
|
||||
e.ChangedWhen = now;
|
||||
}, e => e.Id == envelope.Id, cancel);
|
||||
|
||||
envelope.Title = title;
|
||||
envelope.Message = message;
|
||||
envelope.TfaEnabled = request.TFAEnabled;
|
||||
envelope.EnvelopeTypeId = settings.EnvelopeTypeId;
|
||||
envelope.Language = settings.Language;
|
||||
envelope.UseAccessCode = settings.UseAccessCode;
|
||||
envelope.CertificationType = settings.CertificationType;
|
||||
envelope.FinalEmailToCreator = settings.FinalEmailToCreator;
|
||||
envelope.FinalEmailToReceivers = settings.FinalEmailToReceivers;
|
||||
envelope.SendReminderEmails = settings.SendReminderEmails;
|
||||
envelope.FirstReminderDays = settings.FirstReminderDays;
|
||||
envelope.ReminderIntervalDays = settings.ReminderIntervalDays;
|
||||
envelope.ExpiresWhenDays = settings.ExpiresWhenDays;
|
||||
envelope.ExpiresWarningWhenDays = settings.ExpiresWarningWhenDays;
|
||||
envelope.ExpiresWhen = settings.ExpiresWhen;
|
||||
envelope.ExpiresWarningWhen = settings.ExpiresWarningWhen;
|
||||
envelope.ContractType = settings.ContractType;
|
||||
envelope.Status = EnvelopeStatus.EnvelopeSaved;
|
||||
envelope.ChangedWhen = now;
|
||||
}
|
||||
|
||||
await Mediator.Send(new CreateHistoryCommand
|
||||
{
|
||||
EnvelopeId = envelope.Id,
|
||||
@@ -143,6 +233,105 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
|
||||
return _mapper.Map<EnvelopeDto>(envelope);
|
||||
}
|
||||
|
||||
private async Task<EnvelopeSettings> ResolveEnvelopeSettingsAsync(CreateEnvelopeCommand request, Envelope? existing, CancellationToken cancel)
|
||||
{
|
||||
EnvelopeType? selectedType = null;
|
||||
if (request.EnvelopeTypeId is int envelopeTypeId && envelopeTypeId > 0)
|
||||
{
|
||||
selectedType = await EnvelopeTypeRepository.Query
|
||||
.FirstOrDefaultAsync(t => t.Id == envelopeTypeId, cancel)
|
||||
?? throw new BadRequestException($"Envelope type {envelopeTypeId} not found.");
|
||||
}
|
||||
|
||||
var expiresWhenDays = Math.Max(0,
|
||||
request.ExpiresWhenDays
|
||||
?? selectedType?.ExpiresWhenDays
|
||||
?? existing?.ExpiresWhenDays
|
||||
?? 0);
|
||||
|
||||
var expiresWarningWhenDays = Math.Max(0,
|
||||
request.ExpiresWarningWhenDays
|
||||
?? selectedType?.ExpiresWarningWhenDays
|
||||
?? existing?.ExpiresWarningWhenDays
|
||||
?? 0);
|
||||
|
||||
var useAccessCode = request.UseAccessCode
|
||||
?? selectedType?.UseAccessCode
|
||||
?? existing?.UseAccessCode
|
||||
?? false;
|
||||
|
||||
if (request.TFAEnabled)
|
||||
useAccessCode = true;
|
||||
|
||||
return new EnvelopeSettings(
|
||||
EnvelopeTypeId: request.EnvelopeTypeId ?? existing?.EnvelopeTypeId,
|
||||
Language: ResolveLanguage(request.Language, selectedType?.Language, existing?.Language),
|
||||
UseAccessCode: useAccessCode,
|
||||
CertificationType: request.CertificationType
|
||||
?? (int?)selectedType?.CertificationType
|
||||
?? existing?.CertificationType
|
||||
?? (int)CertificationType.AdvancedElectronicSignature,
|
||||
FinalEmailToCreator: request.FinalEmailToCreator
|
||||
?? (int?)selectedType?.FinalEmailToCreator
|
||||
?? existing?.FinalEmailToCreator
|
||||
?? (int)FinalEmailType.No,
|
||||
FinalEmailToReceivers: request.FinalEmailToReceivers
|
||||
?? (int?)selectedType?.FinalEmailToReceivers
|
||||
?? existing?.FinalEmailToReceivers
|
||||
?? (int)FinalEmailType.No,
|
||||
SendReminderEmails: request.SendReminderEmails
|
||||
?? selectedType?.SendReminderEmails
|
||||
?? existing?.SendReminderEmails
|
||||
?? false,
|
||||
FirstReminderDays: Math.Max(0,
|
||||
request.FirstReminderDays
|
||||
?? selectedType?.FirstReminderDays
|
||||
?? existing?.FirstReminderDays
|
||||
?? 0),
|
||||
ReminderIntervalDays: Math.Max(0,
|
||||
request.ReminderIntervalDays
|
||||
?? selectedType?.ReminderIntervalDays
|
||||
?? existing?.ReminderIntervalDays
|
||||
?? 0),
|
||||
ExpiresWhenDays: expiresWhenDays,
|
||||
ExpiresWarningWhenDays: expiresWarningWhenDays,
|
||||
ExpiresWhen: expiresWhenDays > 0 ? DateTime.Now.AddDays(expiresWhenDays) : null,
|
||||
ExpiresWarningWhen: expiresWarningWhenDays > 0 ? DateTime.Now.AddDays(expiresWarningWhenDays) : null,
|
||||
ContractType: existing?.ContractType
|
||||
?? selectedType?.ContractType
|
||||
?? (int)ContractType.Contract);
|
||||
}
|
||||
|
||||
private static string ResolveLanguage(string? preferred, string? fallback1, string? fallback2)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(preferred))
|
||||
return preferred.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fallback1))
|
||||
return fallback1.Trim();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(fallback2))
|
||||
return fallback2.Trim();
|
||||
|
||||
return "de-DE";
|
||||
}
|
||||
|
||||
private sealed record EnvelopeSettings(
|
||||
int? EnvelopeTypeId,
|
||||
string Language,
|
||||
bool UseAccessCode,
|
||||
int CertificationType,
|
||||
int FinalEmailToCreator,
|
||||
int FinalEmailToReceivers,
|
||||
bool SendReminderEmails,
|
||||
int FirstReminderDays,
|
||||
int ReminderIntervalDays,
|
||||
int ExpiresWhenDays,
|
||||
int ExpiresWarningWhenDays,
|
||||
DateTime? ExpiresWhen,
|
||||
DateTime? ExpiresWarningWhen,
|
||||
int ContractType);
|
||||
|
||||
private async Task<string> ResolveSenderReferenceAsync(Envelope envelope, CancellationToken cancel)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(envelope.User?.Email))
|
||||
|
||||
@@ -123,6 +123,9 @@
|
||||
<data name="Back" xml:space="preserve">
|
||||
<value>Zurück</value>
|
||||
</data>
|
||||
<data name="Next" xml:space="preserve">
|
||||
<value>Weiter</value>
|
||||
</data>
|
||||
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
||||
<value>Klicken Sie auf den</value>
|
||||
</data>
|
||||
@@ -564,6 +567,12 @@
|
||||
<data name="AddReceiver" xml:space="preserve">
|
||||
<value>Empfänger hinzufügen</value>
|
||||
</data>
|
||||
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
|
||||
<value>Zuletzt verwendete Empfänger</value>
|
||||
</data>
|
||||
<data name="Skip" xml:space="preserve">
|
||||
<value>Überspringen</value>
|
||||
</data>
|
||||
<data name="DeleteReceiver" xml:space="preserve">
|
||||
<value>Empfänger löschen</value>
|
||||
</data>
|
||||
@@ -735,7 +744,9 @@
|
||||
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Fehler beim Laden der Datei: {0}</value></data>
|
||||
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Bitte unterzeichnen Sie das beigefügte Dokument.</value></data>
|
||||
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Kein Dokument geladen</value></data>
|
||||
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Keine Seiten zur Anzeige verfügbar.</value></data>
|
||||
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Noch keine Empfänger hinzugefügt.</value></data>
|
||||
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Seite</value></data>
|
||||
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Signaturfeld platzieren</value></data>
|
||||
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Alle Signaturfelder entfernen</value></data>
|
||||
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Bitte wählen Sie eine PDF-Datei aus.</value></data>
|
||||
|
||||
@@ -123,6 +123,9 @@
|
||||
<data name="Back" xml:space="preserve">
|
||||
<value>Back</value>
|
||||
</data>
|
||||
<data name="Next" xml:space="preserve">
|
||||
<value>Next</value>
|
||||
</data>
|
||||
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
||||
<value>Click the</value>
|
||||
</data>
|
||||
@@ -564,6 +567,12 @@
|
||||
<data name="AddReceiver" xml:space="preserve">
|
||||
<value>Add Receiver</value>
|
||||
</data>
|
||||
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
|
||||
<value>Recently used recipients</value>
|
||||
</data>
|
||||
<data name="Skip" xml:space="preserve">
|
||||
<value>Skip</value>
|
||||
</data>
|
||||
<data name="DeleteReceiver" xml:space="preserve">
|
||||
<value>Delete Receiver</value>
|
||||
</data>
|
||||
@@ -735,7 +744,9 @@
|
||||
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Error loading file: {0}</value></data>
|
||||
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Please sign the attached document.</value></data>
|
||||
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>No document loaded</value></data>
|
||||
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>No pages to display.</value></data>
|
||||
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>No receivers added yet.</value></data>
|
||||
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
|
||||
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Place signature field</value></data>
|
||||
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Remove all signature fields</value></data>
|
||||
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Please select a PDF file.</value></data>
|
||||
|
||||
@@ -123,6 +123,9 @@
|
||||
<data name="Back" xml:space="preserve">
|
||||
<value>Retour</value>
|
||||
</data>
|
||||
<data name="Next" xml:space="preserve">
|
||||
<value>Suivant</value>
|
||||
</data>
|
||||
<data name="AuthenticatorSetup_Prefix" xml:space="preserve">
|
||||
<value>Cliquez sur</value>
|
||||
</data>
|
||||
@@ -564,6 +567,12 @@
|
||||
<data name="AddReceiver" xml:space="preserve">
|
||||
<value>Ajouter un destinataire</value>
|
||||
</data>
|
||||
<data name="SenderWizard_RecentReceivers" xml:space="preserve">
|
||||
<value>Destinataires recents</value>
|
||||
</data>
|
||||
<data name="Skip" xml:space="preserve">
|
||||
<value>Passer</value>
|
||||
</data>
|
||||
<data name="DeleteReceiver" xml:space="preserve">
|
||||
<value>Supprimer le destinataire</value>
|
||||
</data>
|
||||
@@ -735,7 +744,9 @@
|
||||
<data name="SenderEditor_LoadFileError" xml:space="preserve"><value>Erreur lors du chargement du fichier : {0}</value></data>
|
||||
<data name="SenderEditor_Message_Placeholder" xml:space="preserve"><value>Veuillez signer le document ci-joint.</value></data>
|
||||
<data name="SenderEditor_NoDocumentLoaded" xml:space="preserve"><value>Aucun document chargé</value></data>
|
||||
<data name="SenderEditor_NoPagesToDisplay" xml:space="preserve"><value>Aucune page à afficher.</value></data>
|
||||
<data name="SenderEditor_NoReceiversAdded" xml:space="preserve"><value>Aucun destinataire ajouté pour le moment.</value></data>
|
||||
<data name="SenderEditor_PageLabel" xml:space="preserve"><value>Page</value></data>
|
||||
<data name="SenderEditor_PlaceSignatureField" xml:space="preserve"><value>Placer un champ de signature</value></data>
|
||||
<data name="SenderEditor_RemoveAllSignatureFields" xml:space="preserve"><value>Supprimer tous les champs de signature</value></data>
|
||||
<data name="SenderEditor_SelectPdf" xml:space="preserve"><value>Veuillez sélectionner un fichier PDF.</value></data>
|
||||
|
||||
@@ -22,41 +22,41 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
public string Language { get; set; }
|
||||
|
||||
[Column("EXPIRES_DAYS")]
|
||||
public int ExpiresDays { get; set; }
|
||||
public int? ExpiresDays { get; set; }
|
||||
|
||||
[Column("CERTIFICATION_TYPE")]
|
||||
public Constants.CertificationType CertificationType { get; set; } = Constants.CertificationType.AdvancedElectronicSignature;
|
||||
public Constants.CertificationType? CertificationType { get; set; }
|
||||
|
||||
[Column("USE_ACCESS_CODE")]
|
||||
public bool UseAccessCode { get; set; }
|
||||
public bool? UseAccessCode { get; set; }
|
||||
|
||||
[Column("FINAL_EMAIL_TO_CREATOR")]
|
||||
public Constants.FinalEmailType FinalEmailToCreator { get; set; }
|
||||
public Constants.FinalEmailType? FinalEmailToCreator { get; set; }
|
||||
|
||||
[Column("FINAL_EMAIL_TO_RECEIVERS")]
|
||||
public Constants.FinalEmailType FinalEmailToReceivers { get; set; }
|
||||
public Constants.FinalEmailType? FinalEmailToReceivers { get; set; }
|
||||
|
||||
[Required]
|
||||
[Column("ADDED_WHEN", TypeName = "datetime")]
|
||||
public DateTime AddedWhen { get; set; }
|
||||
|
||||
[Column("CHANGED_WHEN", TypeName = "datetime")]
|
||||
public DateTime ChangedWhen { get; set; }
|
||||
public DateTime? ChangedWhen { get; set; }
|
||||
|
||||
[Column("EXPIRES_WARNING_DAYS")]
|
||||
public int ExpiresWarningDays { get; set; }
|
||||
public int? ExpiresWarningDays { get; set; }
|
||||
|
||||
[Column("SEND_REMINDER_EMAILS")]
|
||||
public bool SendReminderEmails { get; set; } = false;
|
||||
public bool? SendReminderEmails { get; set; }
|
||||
|
||||
[Column("FIRST_REMINDER_DAYS")]
|
||||
public int FirstReminderDays { get; set; } = 0;
|
||||
public int? FirstReminderDays { get; set; }
|
||||
|
||||
[Column("REMINDER_INTERVAL_DAYS")]
|
||||
public int ReminderIntervalDays { get; set; } = 0;
|
||||
public int? ReminderIntervalDays { get; set; }
|
||||
|
||||
[Column("CONTRACT_TYPE")]
|
||||
public int ContractType { get; set; }
|
||||
public int? ContractType { get; set; }
|
||||
|
||||
[NotMapped]
|
||||
public bool TFA_Enabled { get; set; } = false;
|
||||
@@ -70,14 +70,14 @@ namespace EnvelopeGenerator.Domain.Entities
|
||||
[NotMapped]
|
||||
public int ExpiresWhenDays
|
||||
{
|
||||
get => ExpiresDays;
|
||||
get => ExpiresDays ?? 0;
|
||||
set => ExpiresDays = value;
|
||||
}
|
||||
|
||||
[NotMapped]
|
||||
public int ExpiresWarningWhenDays
|
||||
{
|
||||
get => ExpiresWarningDays;
|
||||
get => ExpiresWarningDays ?? 0;
|
||||
set => ExpiresWarningDays = value;
|
||||
}
|
||||
|
||||
|
||||
@@ -276,4 +276,61 @@
|
||||
<data name="YesWithAttachment" xml:space="preserve">
|
||||
<value>Yes, with Attachment</value>
|
||||
</data>
|
||||
<data name="ElectronicSignature" xml:space="preserve">
|
||||
<value>Electronic Signature</value>
|
||||
</data>
|
||||
<data name="EnvelopeType" xml:space="preserve">
|
||||
<value>Envelope Type</value>
|
||||
</data>
|
||||
<data name="Language" xml:space="preserve">
|
||||
<value>Language</value>
|
||||
</data>
|
||||
<data name="CertificationType" xml:space="preserve">
|
||||
<value>Certification</value>
|
||||
</data>
|
||||
<data name="TFAEnabled" xml:space="preserve">
|
||||
<value>2FA enabled</value>
|
||||
</data>
|
||||
<data name="UseAccessCode" xml:space="preserve">
|
||||
<value>Use Access Code</value>
|
||||
</data>
|
||||
<data name="FinalEmailToCreator" xml:space="preserve">
|
||||
<value>Final Email to Creator</value>
|
||||
</data>
|
||||
<data name="FinalEmailToReceivers" xml:space="preserve">
|
||||
<value>Final Email to Receivers</value>
|
||||
</data>
|
||||
<data name="SendReminderEmails" xml:space="preserve">
|
||||
<value>Send Reminder Emails</value>
|
||||
</data>
|
||||
<data name="FirstReminderDays" xml:space="preserve">
|
||||
<value>First Reminder (Days)</value>
|
||||
</data>
|
||||
<data name="ReminderIntervalDays" xml:space="preserve">
|
||||
<value>Reminder Interval (Days)</value>
|
||||
</data>
|
||||
<data name="ExpiresWhenDays" xml:space="preserve">
|
||||
<value>Expires After (Days)</value>
|
||||
</data>
|
||||
<data name="ExpiresWarningWhenDays" xml:space="preserve">
|
||||
<value>Expiry Warning (Days)</value>
|
||||
</data>
|
||||
<data name="SenderEditor_EnvelopeSettings" xml:space="preserve">
|
||||
<value>Envelope Settings</value>
|
||||
</data>
|
||||
<data name="SenderEditor_AdvancedOptional" xml:space="preserve">
|
||||
<value>Advanced</value>
|
||||
</data>
|
||||
<data name="SenderEditor_SelectTemplate" xml:space="preserve">
|
||||
<value>Select Template</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_ReminderInterval" xml:space="preserve">
|
||||
<value>Please enter a reminder interval greater than 0 days.</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_FirstReminder" xml:space="preserve">
|
||||
<value>Please enter valid days for the first reminder.</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_ExpiryWarning" xml:space="preserve">
|
||||
<value>The expiry warning cannot be after the expiry date.</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -113,4 +113,61 @@
|
||||
<data name="MessageInvitationSent" xml:space="preserve">
|
||||
<value>Invitation envoyee</value>
|
||||
</data>
|
||||
<data name="ElectronicSignature" xml:space="preserve">
|
||||
<value>Signature electronique</value>
|
||||
</data>
|
||||
<data name="EnvelopeType" xml:space="preserve">
|
||||
<value>Type d'enveloppe</value>
|
||||
</data>
|
||||
<data name="Language" xml:space="preserve">
|
||||
<value>Langue</value>
|
||||
</data>
|
||||
<data name="CertificationType" xml:space="preserve">
|
||||
<value>Certification</value>
|
||||
</data>
|
||||
<data name="TFAEnabled" xml:space="preserve">
|
||||
<value>2FA active</value>
|
||||
</data>
|
||||
<data name="UseAccessCode" xml:space="preserve">
|
||||
<value>Utiliser un code d'acces</value>
|
||||
</data>
|
||||
<data name="FinalEmailToCreator" xml:space="preserve">
|
||||
<value>E-mail final au createur</value>
|
||||
</data>
|
||||
<data name="FinalEmailToReceivers" xml:space="preserve">
|
||||
<value>E-mail final aux destinataires</value>
|
||||
</data>
|
||||
<data name="SendReminderEmails" xml:space="preserve">
|
||||
<value>Envoyer des e-mails de rappel</value>
|
||||
</data>
|
||||
<data name="FirstReminderDays" xml:space="preserve">
|
||||
<value>Premier rappel (jours)</value>
|
||||
</data>
|
||||
<data name="ReminderIntervalDays" xml:space="preserve">
|
||||
<value>Intervalle de rappel (jours)</value>
|
||||
</data>
|
||||
<data name="ExpiresWhenDays" xml:space="preserve">
|
||||
<value>Expire apres (jours)</value>
|
||||
</data>
|
||||
<data name="ExpiresWarningWhenDays" xml:space="preserve">
|
||||
<value>Avertissement d'expiration (jours)</value>
|
||||
</data>
|
||||
<data name="SenderEditor_EnvelopeSettings" xml:space="preserve">
|
||||
<value>Parametres de l'enveloppe</value>
|
||||
</data>
|
||||
<data name="SenderEditor_AdvancedOptional" xml:space="preserve">
|
||||
<value>Avance</value>
|
||||
</data>
|
||||
<data name="SenderEditor_SelectTemplate" xml:space="preserve">
|
||||
<value>Selectionner un modele</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_ReminderInterval" xml:space="preserve">
|
||||
<value>Veuillez saisir un intervalle de rappel superieur a 0 jour.</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_FirstReminder" xml:space="preserve">
|
||||
<value>Veuillez saisir des jours valides pour le premier rappel.</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_ExpiryWarning" xml:space="preserve">
|
||||
<value>L'avertissement d'expiration ne peut pas etre apres la date d'expiration.</value>
|
||||
</data>
|
||||
</root>
|
||||
|
||||
@@ -279,4 +279,61 @@
|
||||
<data name="YesWithAttachment" xml:space="preserve">
|
||||
<value>Ja, mit Anhang</value>
|
||||
</data>
|
||||
<data name="ElectronicSignature" xml:space="preserve">
|
||||
<value>Elektronische Signatur</value>
|
||||
</data>
|
||||
<data name="EnvelopeType" xml:space="preserve">
|
||||
<value>Umschlagtyp</value>
|
||||
</data>
|
||||
<data name="Language" xml:space="preserve">
|
||||
<value>Sprache</value>
|
||||
</data>
|
||||
<data name="CertificationType" xml:space="preserve">
|
||||
<value>Zertifizierung</value>
|
||||
</data>
|
||||
<data name="TFAEnabled" xml:space="preserve">
|
||||
<value>2FA aktiviert</value>
|
||||
</data>
|
||||
<data name="UseAccessCode" xml:space="preserve">
|
||||
<value>Zugriffscode verwenden</value>
|
||||
</data>
|
||||
<data name="FinalEmailToCreator" xml:space="preserve">
|
||||
<value>Finale E-Mail an Ersteller</value>
|
||||
</data>
|
||||
<data name="FinalEmailToReceivers" xml:space="preserve">
|
||||
<value>Finale E-Mail an Empfänger</value>
|
||||
</data>
|
||||
<data name="SendReminderEmails" xml:space="preserve">
|
||||
<value>Erinnerungs-E-Mails senden</value>
|
||||
</data>
|
||||
<data name="FirstReminderDays" xml:space="preserve">
|
||||
<value>Erste Erinnerung (Tage)</value>
|
||||
</data>
|
||||
<data name="ReminderIntervalDays" xml:space="preserve">
|
||||
<value>Erinnerungsintervall (Tage)</value>
|
||||
</data>
|
||||
<data name="ExpiresWhenDays" xml:space="preserve">
|
||||
<value>Läuft ab nach (Tage)</value>
|
||||
</data>
|
||||
<data name="ExpiresWarningWhenDays" xml:space="preserve">
|
||||
<value>Ablaufwarnung (Tage)</value>
|
||||
</data>
|
||||
<data name="SenderEditor_EnvelopeSettings" xml:space="preserve">
|
||||
<value>Envelope-Einstellungen</value>
|
||||
</data>
|
||||
<data name="SenderEditor_AdvancedOptional" xml:space="preserve">
|
||||
<value>Erweitert</value>
|
||||
</data>
|
||||
<data name="SenderEditor_SelectTemplate" xml:space="preserve">
|
||||
<value>Vorlage auswählen</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_ReminderInterval" xml:space="preserve">
|
||||
<value>Bitte geben Sie ein Erinnerungsintervall größer als 0 Tage ein.</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_FirstReminder" xml:space="preserve">
|
||||
<value>Bitte geben Sie gültige Tage für die erste Erinnerung ein.</value>
|
||||
</data>
|
||||
<data name="SenderEditor_Validation_ExpiryWarning" xml:space="preserve">
|
||||
<value>Die Ablaufwarnung darf nicht nach dem Ablaufdatum liegen.</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -105,10 +105,10 @@ Public Class frmEnvelopeMainData
|
||||
End If
|
||||
End Sub
|
||||
Private Function TranslateCertificationType(pType As CertificationType) As String
|
||||
Return Resources.Model.ResourceManager.GetString(pType.ToString())
|
||||
Return CommonServices.My.Resources.Model.ResourceManager.GetString(pType.ToString())
|
||||
End Function
|
||||
Private Function TranslateFinalEmailType(pType As FinalEmailType) As String
|
||||
Return Resources.Model.ResourceManager.GetString(pType.ToString())
|
||||
Return CommonServices.My.Resources.Model.ResourceManager.GetString(pType.ToString())
|
||||
End Function
|
||||
|
||||
Private Sub btOK_Click(sender As Object, e As EventArgs) Handles btOK.Click
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
using EnvelopeGenerator.Infrastructure.Crypt.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Crypt.DependencyInjection;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddCryptInfrastructure(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services
|
||||
.AddOptions<Pkcs11Options>()
|
||||
.Bind(configuration.GetSection(Pkcs11Options.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IPdfDigitalSignatureService, PdfDigitalSignatureService>();
|
||||
services.AddSingleton<Pkcs11SelfTestStatusStore>();
|
||||
services.AddSingleton<IPkcs11SelfTestStatusProvider>(sp => sp.GetRequiredService<Pkcs11SelfTestStatusStore>());
|
||||
services.AddHostedService<Pkcs11StartupSelfTestHostedService>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DevExpress.Document.Processor" Version="25.2.3" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="9.0.6" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" Version="9.0.6" />
|
||||
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="9.0.6" />
|
||||
<PackageReference Include="Pkcs11Interop" Version="5.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="resources\pkcs11\**\*">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
<TargetPath>pkcs11\%(RecursiveDir)%(Filename)%(Extension)</TargetPath>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
384
EnvelopeGenerator.Infrastructure.Crypt/README.md
Normal file
384
EnvelopeGenerator.Infrastructure.Crypt/README.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# EnvelopeGenerator.Infrastructure.Crypt
|
||||
|
||||
Umfassende technische Dokumentation der PKCS#11/SoftHSM-Integration fuer `EnvelopeGenerator`.
|
||||
|
||||
Dieses Dokument konsolidiert und ersetzt die verteilten Runbooks aus:
|
||||
|
||||
- `pkcs11-proxy-production/README.md`
|
||||
- `pkcs11-proxy-production/SSH_TEMP_ACCESS_MUST_DO.md`
|
||||
- aeltere Abschnitte in diesem README
|
||||
|
||||
Zielgruppe: Senior Developer, Platform Engineers, DevOps, Security Owner.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architekturueberblick
|
||||
|
||||
### 1.1 Ziel
|
||||
|
||||
`EnvelopeGenerator.Infrastructure.Crypt` stellt die kryptographische Infrastruktur fuer PDF-Signaturen bereit:
|
||||
|
||||
- Signaturerzeugung mit Schluesseln in SoftHSM/PKCS#11
|
||||
- Zertifikat-Auslese aus Token
|
||||
- Signaturpruefung auf PDF-Ebene
|
||||
- Health/Startup-Selbsttest fuer Betriebsfaehigkeit
|
||||
|
||||
### 1.2 Topologie (aktueller Betriebsmodus)
|
||||
|
||||
Der aktuell implementierte Produktions-nahe Pfad ist:
|
||||
|
||||
1. Linux hostet SoftHSM + `pkcs11-daemon` (Proxy-Server).
|
||||
2. Windows hostet `EnvelopeGenerator.Server`.
|
||||
3. Windows laedt ein Windows-kompatibles Proxy-PKCS#11-Modul (`libpkcs11-proxy.dll`).
|
||||
4. PKCS#11-Operationen werden ueber Socket (`PKCS11_PROXY_SOCKET`) an Linux weitergeleitet.
|
||||
|
||||
Wichtig:
|
||||
|
||||
- Ein Windows-Prozess kann **kein Linux `.so`** direkt laden.
|
||||
- Private Keys verbleiben im Token (nicht exportierbar).
|
||||
|
||||
### 1.3 Laufzeitpfade in der Anwendung
|
||||
|
||||
`Pkcs11InteropBootstrap` loest Assets relativ zu `Crypt:Pkcs11:AssetsRootPath` auf:
|
||||
|
||||
- Modul: `pkcs11\windows\proxy\libpkcs11-proxy.dll`
|
||||
- Tool-Fallback: `pkcs11\windows\opensc\tools\pkcs11-tool.exe`
|
||||
|
||||
Wenn `AssetsRootPath` leer ist, wird `AppContext.BaseDirectory` verwendet.
|
||||
|
||||
---
|
||||
|
||||
## 2. Linux-Seite: SoftHSM + Proxy Setup
|
||||
|
||||
### 2.1 Basispakete
|
||||
|
||||
Beispiel (Debian/Ubuntu):
|
||||
|
||||
```bash
|
||||
sudo apt-get update && sudo apt-get install -y softhsm2 opensc p11-kit libengine-pkcs11-openssl
|
||||
```
|
||||
|
||||
### 2.2 SoftHSM-Modulpfad pruefen
|
||||
|
||||
```bash
|
||||
ldconfig -p | grep softhsm
|
||||
find /usr -name "libsofthsm2.so" 2>/dev/null
|
||||
```
|
||||
|
||||
### 2.3 Token initialisieren
|
||||
|
||||
```bash
|
||||
softhsm2-util --show-slots
|
||||
softhsm2-util --init-token --slot <uninitialized-slot> --label CompanyProdToken --so-pin <so-pin> --pin <user-pin>
|
||||
softhsm2-util --show-slots
|
||||
```
|
||||
|
||||
Hinweis: Nach `--init-token` wird der Slot i. d. R. neu zugewiesen.
|
||||
|
||||
### 2.4 Schluesselpaar erzeugen
|
||||
|
||||
```bash
|
||||
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
|
||||
--slot <slot-id> --login --pin <user-pin> \
|
||||
--keypairgen --key-type rsa:3072 --label company-sign-key-v1 --id 01
|
||||
```
|
||||
|
||||
### 2.5 Zertifikat importieren
|
||||
|
||||
```bash
|
||||
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
|
||||
--slot <slot-id> --login --pin <user-pin> \
|
||||
--write-object ./company-sign-cert-v1.der --type cert --id 01 --label company-sign-cert-v1
|
||||
```
|
||||
|
||||
### 2.6 Objektzustand verifizieren
|
||||
|
||||
```bash
|
||||
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot <slot-id> --login --pin <user-pin> --list-objects
|
||||
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot <slot-id> --login --pin <user-pin> --list-objects --type cert
|
||||
```
|
||||
|
||||
### 2.7 Proxy-Daemon endpoint stabilisieren
|
||||
|
||||
Ein wiederkehrendes Betriebsproblem war ein zufaelliger/local-only Listener. Fix:
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /etc/systemd/system/pkcs11-proxy.service.d
|
||||
printf "[Service]\nEnvironment=PKCS11_DAEMON_SOCKET=tcp://0.0.0.0:5657\n" | sudo tee /etc/systemd/system/pkcs11-proxy.service.d/override.conf
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart pkcs11-proxy.service
|
||||
```
|
||||
|
||||
Pruefung:
|
||||
|
||||
```bash
|
||||
systemctl show pkcs11-proxy.service -p Environment --no-pager
|
||||
ss -ltnp | grep 5657
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Web-API/Server-Seite: Installation und Asset-Bereitstellung
|
||||
|
||||
### 3.1 Bezugsquellen der Windows-Artefakte
|
||||
|
||||
Fuer den Proxy-Betrieb werden auf Windows benoetigt:
|
||||
|
||||
1. Proxy-PKCS#11-DLL (`libpkcs11-proxy.dll`)
|
||||
2. Abhaengige Runtime-DLLs (z. B. MinGW runtime, je nach Build)
|
||||
3. `pkcs11-tool.exe` + zugehoerige OpenSC-DLLs fuer Fallback
|
||||
|
||||
Beschaffung:
|
||||
|
||||
- Build aus derselben `pkcs11-proxy`-Codebasis/Commit wie Linux-Daemon (Kompatibilitaet sicherstellen)
|
||||
- oder kontrolliert bereitgestellte, intern freigegebene Binaries
|
||||
|
||||
### 3.2 Empfohlene Ablage im Repo
|
||||
|
||||
Bundled Assets liegen unter:
|
||||
|
||||
- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/proxy/`
|
||||
- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/opensc/tools/`
|
||||
- `EnvelopeGenerator.Infrastructure.Crypt/resources/pkcs11/windows/opensc/pkcs11/`
|
||||
|
||||
Diese werden beim Build in den Output kopiert (siehe `.csproj`-Copy-Regeln).
|
||||
|
||||
### 3.3 Deployment-Muster
|
||||
|
||||
Es gibt zwei gueltige Wege:
|
||||
|
||||
1. **Bundled (Dev/QA, schnelle Reproduzierbarkeit)**
|
||||
- Assets werden mit dem Build ausgeliefert.
|
||||
- `AssetsRootPath = null` -> Aufloesung via `AppContext.BaseDirectory`.
|
||||
|
||||
2. **Zentraler Asset-Root (Prod, betriebliche Kontrolle)**
|
||||
- Assets in zentralem Pfad (z. B. `E:\DataFiles\Digital Data\EnvelopeGenerator`).
|
||||
- `AssetsRootPath` in `appsettings.json` auf diesen Root setzen.
|
||||
|
||||
### 3.4 Anwendungskonfiguration (`Crypt:Pkcs11`)
|
||||
|
||||
Beispiel:
|
||||
|
||||
```json
|
||||
{
|
||||
"Crypt": {
|
||||
"Pkcs11": {
|
||||
"AssetsRootPath": "E:\\DataFiles\\Digital Data\\EnvelopeGenerator",
|
||||
"SlotId": 2021549410,
|
||||
"TokenLabel": "CompanyProdToken",
|
||||
"ProxySocket": "tcp://172.24.12.64:5657",
|
||||
"UserPin": "<secret>",
|
||||
"PrivateKeyLabel": "company-sign-key-v1",
|
||||
"CertificateLabel": "company-sign-cert-v1",
|
||||
"ApplicationName": "signFLOW",
|
||||
"DefaultReason": null,
|
||||
"UseUtcSigningTime": true,
|
||||
"DefaultCertificationLevel": "NoCertification"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Sicherheitsregel:
|
||||
|
||||
- `UserPin` niemals in versionierte Dateien einchecken.
|
||||
- Secrets nur in Secret Store / nicht versionierte `appsettings.Secrets.json` / Vault.
|
||||
|
||||
---
|
||||
|
||||
## 4. Implementierte Services, Modelle, DTOs
|
||||
|
||||
### 4.1 DI und Startverhalten
|
||||
|
||||
- `AddCryptInfrastructure(...)` in `DependencyInjection/ServiceCollectionExtensions.cs`
|
||||
- bindet `Pkcs11Options`
|
||||
- registriert `IPdfDigitalSignatureService`
|
||||
- registriert Startup-Self-Test Hosted Service
|
||||
|
||||
### 4.2 Konfigurationsmodell
|
||||
|
||||
- `Pkcs11Options` (`EnvelopeGenerator.Application/Common/Configurations/Pkcs11Options.cs`)
|
||||
- `AssetsRootPath`, `SlotId`, `TokenLabel`, `ProxySocket`
|
||||
- `UserPin`, `PrivateKeyLabel`, `CertificateLabel`
|
||||
- `ApplicationName`, `DefaultReason`, `UseUtcSigningTime`, `DefaultCertificationLevel`
|
||||
- `ResolvedLibraryPath`, `ResolvedToolPath` (Runtime-resolved)
|
||||
|
||||
### 4.3 Kernservices
|
||||
|
||||
1. `PdfDigitalSignatureService`
|
||||
- Signiert PDF via DevExpress `PdfDeferredSignatureBuilder`
|
||||
- Nutzt `Pkcs11ExternalSigner` fuer externe Signatur
|
||||
- Erzwingt `ApplicationName` aus Config (nicht client-overridable)
|
||||
- Erweitert `Reason` um strukturiertes Metaformat:
|
||||
- `SFMETA:v1;doc=<uuid>;sha256=<hash>`
|
||||
- Parse beim Verify und Mapping in DTO
|
||||
|
||||
2. `Pkcs11InteropBootstrap`
|
||||
- Loest Modul-/Toolpfade auf
|
||||
- Setzt `PKCS11_PROXY_SOCKET`/`PKCS11_DAEMON_SOCKET`
|
||||
- PATH-Ergaenzung fuer native Abhaengigkeiten
|
||||
- Warmup via `pkcs11-tool -L`
|
||||
|
||||
3. `Pkcs11SessionRunner`
|
||||
- Interop Library-Load mit mehreren Init-Modi (Fallback-Reihenfolge)
|
||||
- Slot-Resolution (`SlotId`/`TokenLabel`)
|
||||
- Session Login/Logout
|
||||
|
||||
4. `Pkcs11ToolBridge`
|
||||
- Fallback via Prozessaufruf von `pkcs11-tool`
|
||||
- Cert read und DigestInfo-Signatur
|
||||
|
||||
5. `Pkcs11StartupSelfTestHostedService`
|
||||
- Fuehrt beim Start Interop-Probe aus
|
||||
- Fallback-Probe bei Interop-Fehler
|
||||
- Schreibt strukturierten Status
|
||||
|
||||
### 4.4 Wichtige Interfaces/DTOs
|
||||
|
||||
- `IPdfDigitalSignatureService`
|
||||
- `SignPdfAsync(...)`, `CheckPdfSignatureAsync(...)`, `ExtractOriginalPdfAsync(...)`
|
||||
- `IPkcs11SelfTestStatusProvider`
|
||||
- `PdfSignOptionsDto`
|
||||
- `UUID`, `Reason`, `Name`, `Location`, `Email`, `Phone`, `CertificationLevel`
|
||||
- `PdfSignatureCheckResultDto` / `PdfSignatureCheckItemDto`
|
||||
- inklusive `BusinessReason`, `UUID`, `OriginalDocumentSha256`
|
||||
- `Pkcs11SelfTestStatusDto`
|
||||
|
||||
---
|
||||
|
||||
## 5. Betriebserfahrungen und Challenges
|
||||
|
||||
### 5.1 Interop vs Tool-Verhalten
|
||||
|
||||
Beobachtetes Muster in der realen Umgebung:
|
||||
|
||||
- `pkcs11-tool` konnte erfolgreich auf Token zugreifen und signieren.
|
||||
- `Pkcs11Interop` scheiterte mit `CKR_FUNCTION_NOT_SUPPORTED` bzw. fehlendem `C_Initialize` Pointer in bestimmten Modi.
|
||||
|
||||
Konsequenz:
|
||||
|
||||
- Architektur wurde auf **interop-first + controlled tool-fallback** erweitert.
|
||||
- Health-Endpunkt differenziert Modus (`interop`, `pkcs11-tool`, `failed`).
|
||||
|
||||
### 5.2 Typische Fehlbilder
|
||||
|
||||
- `CKR_DEVICE_ERROR` bei Socket-/Proxy-Problemen
|
||||
- `CRYPT_PROVIDER_UNAVAILABLE` bei Pfad/ABI/Dependency-Mismatch
|
||||
- `CRYPT_SLOT_NOT_FOUND` bei Slot-/Label-Mismatch
|
||||
- `CKR_PIN_INCORRECT` bei PIN-Fehler
|
||||
- 307 Redirects bei HTTP/HTTPS-Mix in manuellen Tests
|
||||
|
||||
### 5.3 Plattformfallen
|
||||
|
||||
- MSVC vs MinGW Build-Unterschiede fuer Proxy-Client
|
||||
- fehlende Laufzeit-DLLs (`PATH`/Side-by-side)
|
||||
- Architektur-Mismatch x86 vs x64
|
||||
- Daemon-Bindings nicht stabil (random localhost port statt fixed endpoint)
|
||||
|
||||
---
|
||||
|
||||
## 6. Health, Monitoring und Verifikation
|
||||
|
||||
### 6.1 API Health
|
||||
|
||||
- Endpoint: `GET /api/health/crypt`
|
||||
- Liefert:
|
||||
- `success`, `mode`, `summary`, `interopError`, `toolError`, `checkedAtUtc`
|
||||
|
||||
Empfehlung:
|
||||
|
||||
- In Monitoring aufnehmen und bei `mode=pkcs11-tool` ueber laengere Zeit Warnung triggern.
|
||||
|
||||
### 6.2 Smoke-Test (empfohlen vor Release)
|
||||
|
||||
1. `GET /api/health/crypt` -> `success=true`
|
||||
2. `POST /api/PdfSignature/sign` mit Test-PDF
|
||||
3. `POST /api/PdfSignature/is-signed`
|
||||
4. Pruefen:
|
||||
- `signed=true`
|
||||
- `allSignaturesCryptographicallyValid=true`
|
||||
- `UUID`/`OriginalDocumentSha256` korrekt gemappt
|
||||
|
||||
---
|
||||
|
||||
## 7. Sicherheits- und Betriebsrichtlinien
|
||||
|
||||
### 7.1 Geheimnisse
|
||||
|
||||
- PIN, SO-PIN, Root-Creds niemals in Doku/Screenshots/Repo.
|
||||
- Nach Troubleshooting: Credentials rotieren.
|
||||
|
||||
### 7.2 Temporare SSH-Freigaben zwingend rueckbauen
|
||||
|
||||
Wenn SSH temporaer geoeffnet wurde, MUSS danach Rollback erfolgen:
|
||||
|
||||
```bash
|
||||
sudo systemctl disable --now ssh && sudo ufw delete allow from 172.24.10.11 to any port 22 proto tcp && sudo ufw status verbose && sudo systemctl status ssh --no-pager
|
||||
```
|
||||
|
||||
Optional harte Bereinigung:
|
||||
|
||||
```bash
|
||||
sudo apt-get purge -y openssh-server && sudo apt-get autoremove -y
|
||||
```
|
||||
|
||||
### 7.3 Schluesselrotation
|
||||
|
||||
- Neue Labels (`...-v2`) anlegen
|
||||
- App umkonfigurieren
|
||||
- Sign/Verify Regression testen
|
||||
- Altmaterial erst nach Freigabe entfernen
|
||||
|
||||
---
|
||||
|
||||
## 8. API-Verhalten bei Signaturmetadaten
|
||||
|
||||
Aktueller Standard:
|
||||
|
||||
- `UUID` kommt aus Sign-Input (nullable string). Wenn ungeltig/leer -> neue Guid wird erzeugt.
|
||||
- `Reason` wird intern angereichert mit strukturiertem Meta (`SFMETA:v1`).
|
||||
- `ApplicationName` ist rein serverseitig aus `appsettings` gesetzt.
|
||||
- `SigningTime` ist rein serverseitig und wird automatisch gesetzt (`UseUtcSigningTime=true` => `UtcNow`, sonst lokale Zeit).
|
||||
- `CertificationLevel` wird im Controller serverseitig gesetzt (`FormFillingAndAnnotations`), nicht vom Client.
|
||||
|
||||
Damit ist das Verhalten deterministisch und manipulationsaermer fuer Integrationsclients.
|
||||
|
||||
---
|
||||
|
||||
## 9. Troubleshooting-Checkliste (praxisnah)
|
||||
|
||||
1. Erreichbarkeit Linux Proxy:
|
||||
- `Test-NetConnection <linux-host> -Port 5657`
|
||||
2. Token sichtbar?
|
||||
- `pkcs11-tool --module <dll> -L -v`
|
||||
3. Zertifikat lesbar?
|
||||
- `--read-object --type cert --label <cert-label>`
|
||||
4. Signatur technisch moeglich?
|
||||
- `--sign -m SHA256-RSA-PKCS ...`
|
||||
5. App-Health check:
|
||||
- `/api/health/crypt`
|
||||
6. App-sign/verify end-to-end:
|
||||
- `/api/PdfSignature/sign` + `/api/PdfSignature/is-signed`
|
||||
|
||||
Wenn Interop scheitert, aber Tool funktioniert:
|
||||
|
||||
- Betrieb bleibt verfuegbar (Fallback),
|
||||
- aber Incident/Bug fuer Interop-Kompatibilitaet erfassen.
|
||||
|
||||
---
|
||||
|
||||
## 10. Ausblick / empfohlene Verbesserungen
|
||||
|
||||
1. Binary Transport statt Base64 bei grossen PDFs in Render/Sign-Pipelines.
|
||||
2. Persistente Metriken fuer Fallback-Quote (`interop` vs `tool`).
|
||||
3. Zertifikatskette/OCSP/CRL Validierung als optionales Policy-Modul.
|
||||
4. CI-Pruefung auf unsichere SQL-Muster (parallel zum Security-Workstream).
|
||||
5. Standardisierte Toast/UX-Rueckmeldungen fuer Signierfehler in UI.
|
||||
|
||||
---
|
||||
|
||||
## 11. Referenzen
|
||||
|
||||
- SoftHSMv2: `https://github.com/softhsm/SoftHSMv2`
|
||||
- OpenSC / pkcs11-tool: `https://github.com/OpenSC/OpenSC/wiki/Using-pkcs11-tool-and-OpenSSL`
|
||||
- PKCS#11 Standard: `https://www.oasis-open.org/committees/pkcs11/`
|
||||
@@ -0,0 +1,617 @@
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Enums;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
using DevExpress.Office.DigitalSignatures;
|
||||
using DevExpress.Pdf;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Formats.Asn1;
|
||||
using System.Security.Cryptography.Pkcs;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
|
||||
|
||||
public sealed class PdfDigitalSignatureService(IOptions<Pkcs11Options> options) : IPdfDigitalSignatureService
|
||||
{
|
||||
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 async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? optionsModel = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inputPdf);
|
||||
ArgumentNullException.ThrowIfNull(outputPdf);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var (workingInput, ownsWorkingInput) = await EnsureSeekableInputAsync(inputPdf, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
workingInput.Position = 0;
|
||||
var sourceHashHex = Convert.ToHexString(await SHA256.HashDataAsync(workingInput, cancellationToken));
|
||||
workingInput.Position = 0;
|
||||
|
||||
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
|
||||
{
|
||||
if (ownsWorkingInput)
|
||||
await workingInput.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, IReadOnlyList<PdfSignOptionsDto> optionsList, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inputPdf);
|
||||
ArgumentNullException.ThrowIfNull(outputPdf);
|
||||
ArgumentNullException.ThrowIfNull(optionsList);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
if (optionsList.Count == 0)
|
||||
{
|
||||
await inputPdf.CopyToAsync(outputPdf, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
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 async Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inputPdf);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
|
||||
|
||||
using var pdfStream = new MemoryStream(pdfBytes);
|
||||
using var signer = new PdfDocumentSigner(pdfStream);
|
||||
var signatures = signer.GetSignatureInfo();
|
||||
var byteRanges = ParseByteRanges(pdfBytes);
|
||||
|
||||
if (signatures is null || signatures.Count == 0)
|
||||
{
|
||||
return new PdfSignatureCheckResultDto(
|
||||
Signed: false,
|
||||
Signatures: []);
|
||||
}
|
||||
|
||||
var items = new List<PdfSignatureCheckItemDto>(signatures.Count);
|
||||
|
||||
for (var i = 0; i < signatures.Count; i++)
|
||||
{
|
||||
var signature = signatures[i];
|
||||
try
|
||||
{
|
||||
var pkcs7 = signer.GetPdfPkcs7Signature(signature.FieldName);
|
||||
bool valid = pkcs7.VerifySignature();
|
||||
var byteRange = i < byteRanges.Count ? byteRanges[i] : null;
|
||||
var coversWholeDocument = CoversWholeDocument(byteRange, pdfBytes.LongLength);
|
||||
var certificate = pkcs7.GetSignatureCertificate();
|
||||
var issuedTo = ParsePrincipal(certificate?.Subject);
|
||||
var issuedBy = ParsePrincipal(certificate?.Issuer);
|
||||
var reasonMeta = ParseReasonMeta(signature.Reason);
|
||||
var contact = ParseContactInfo(signature.ContactInfo);
|
||||
|
||||
items.Add(new PdfSignatureCheckItemDto(
|
||||
CoversWholeDoc: coversWholeDocument,
|
||||
Reason: signature.Reason,
|
||||
BusinessReason: reasonMeta.BusinessReason,
|
||||
Location: signature.Location,
|
||||
DeclaredName: signature.SignerName,
|
||||
ContactInfo: signature.ContactInfo,
|
||||
Email: contact.Email,
|
||||
Phone: contact.Phone,
|
||||
DigestAlgorithm: InferDigestAlgorithm(certificate),
|
||||
SignatureAlgorithm: InferSignatureAlgorithm(certificate),
|
||||
SignerName: issuedTo.CommonName ?? signature.SignerName,
|
||||
Issuer: issuedBy.Full,
|
||||
SerialNumber: ToColonSerial(certificate?.SerialNumber),
|
||||
ValidFrom: certificate is null ? null : new DateTimeOffset(certificate.NotBefore).ToUniversalTime(),
|
||||
ValidTo: certificate is null ? null : new DateTimeOffset(certificate.NotAfter).ToUniversalTime(),
|
||||
ExpiryCheckedAgainst: "now",
|
||||
Certificates: [BuildCertificateInfo(certificate, DateTimeOffset.UtcNow)],
|
||||
Unchanged: valid && coversWholeDocument,
|
||||
FieldName: signature.FieldName,
|
||||
IsCryptographicallyValid: valid,
|
||||
SigningTime: signature.SigningTime?.ToUniversalTime().ToString("O"),
|
||||
Filter: signature.Filter,
|
||||
SubFilter: signature.SubFilter,
|
||||
ApplicationName: null,
|
||||
UUID: reasonMeta.UUID,
|
||||
OriginalDocumentSha256: reasonMeta.OriginalDocumentSha256,
|
||||
MessageDigestHex: TryExtractMessageDigestHex(signature.Contents),
|
||||
Error: null));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var reasonMeta = ParseReasonMeta(signature.Reason);
|
||||
var contact = ParseContactInfo(signature.ContactInfo);
|
||||
|
||||
items.Add(new PdfSignatureCheckItemDto(
|
||||
CoversWholeDoc: false,
|
||||
Reason: signature.Reason,
|
||||
BusinessReason: reasonMeta.BusinessReason,
|
||||
Location: signature.Location,
|
||||
DeclaredName: signature.SignerName,
|
||||
ContactInfo: signature.ContactInfo,
|
||||
Email: contact.Email,
|
||||
Phone: contact.Phone,
|
||||
DigestAlgorithm: null,
|
||||
SignatureAlgorithm: null,
|
||||
SignerName: signature.SignerName,
|
||||
Issuer: null,
|
||||
SerialNumber: null,
|
||||
ValidFrom: null,
|
||||
ValidTo: null,
|
||||
ExpiryCheckedAgainst: "now",
|
||||
Certificates: [],
|
||||
Unchanged: false,
|
||||
FieldName: signature.FieldName,
|
||||
IsCryptographicallyValid: false,
|
||||
SigningTime: signature.SigningTime?.ToUniversalTime().ToString("O"),
|
||||
Filter: signature.Filter,
|
||||
SubFilter: signature.SubFilter,
|
||||
ApplicationName: null,
|
||||
UUID: reasonMeta.UUID,
|
||||
OriginalDocumentSha256: reasonMeta.OriginalDocumentSha256,
|
||||
MessageDigestHex: null,
|
||||
Error: ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
return new PdfSignatureCheckResultDto(
|
||||
Signed: true,
|
||||
Signatures: items);
|
||||
}
|
||||
|
||||
public async Task ExtractOriginalPdfAsync(Stream inputPdf, Stream outputPdf, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(inputPdf);
|
||||
ArgumentNullException.ThrowIfNull(outputPdf);
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
|
||||
|
||||
var byteRanges = ParseByteRanges(pdfBytes);
|
||||
if (byteRanges.Count == 0)
|
||||
{
|
||||
await outputPdf.WriteAsync(pdfBytes, cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
var revisionLength = byteRanges
|
||||
.Select(GetSignedRevisionLength)
|
||||
.Where(x => x > 0)
|
||||
.DefaultIfEmpty(pdfBytes.LongLength)
|
||||
.Min();
|
||||
|
||||
if (revisionLength > pdfBytes.LongLength)
|
||||
throw new InvalidOperationException("Invalid ByteRange in signed PDF.");
|
||||
|
||||
await outputPdf.WriteAsync(pdfBytes.AsMemory(0, (int)revisionLength), cancellationToken);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (certificate is null)
|
||||
{
|
||||
return new PdfSignatureCertificateInfoDto(
|
||||
IssuedTo: new PdfSignaturePrincipalDto(null, null, null, null),
|
||||
IssuedBy: new PdfSignaturePrincipalDto(null, null, null, null),
|
||||
SerialNumber: null,
|
||||
ValidFrom: null,
|
||||
ValidTo: null,
|
||||
SelfSigned: false,
|
||||
Expired: false,
|
||||
NotYetValid: false,
|
||||
IsSigner: true,
|
||||
Version: null,
|
||||
KeyAlgorithm: null,
|
||||
SignatureAlgorithm: null,
|
||||
Pem: null,
|
||||
KeySizeBits: null);
|
||||
}
|
||||
|
||||
var issuedTo = ParsePrincipal(certificate.Subject);
|
||||
var issuedBy = ParsePrincipal(certificate.Issuer);
|
||||
var validFrom = new DateTimeOffset(certificate.NotBefore).ToUniversalTime();
|
||||
var validTo = new DateTimeOffset(certificate.NotAfter).ToUniversalTime();
|
||||
|
||||
return new PdfSignatureCertificateInfoDto(
|
||||
IssuedTo: issuedTo,
|
||||
IssuedBy: issuedBy,
|
||||
SerialNumber: ToColonSerial(certificate.SerialNumber),
|
||||
ValidFrom: validFrom,
|
||||
ValidTo: validTo,
|
||||
SelfSigned: string.Equals(certificate.Subject, certificate.Issuer, StringComparison.OrdinalIgnoreCase),
|
||||
Expired: validTo < now,
|
||||
NotYetValid: validFrom > now,
|
||||
IsSigner: true,
|
||||
Version: certificate.Version,
|
||||
KeyAlgorithm: certificate.PublicKey.Oid?.FriendlyName ?? certificate.PublicKey.Oid?.Value,
|
||||
SignatureAlgorithm: certificate.SignatureAlgorithm.FriendlyName ?? certificate.SignatureAlgorithm.Value,
|
||||
Pem: ToPem(certificate.RawData),
|
||||
KeySizeBits: TryGetKeySize(certificate));
|
||||
}
|
||||
|
||||
private static int? TryGetKeySize(X509Certificate2 certificate)
|
||||
{
|
||||
try
|
||||
{
|
||||
return certificate.PublicKey.Key.KeySize;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? InferDigestAlgorithm(X509Certificate2? certificate)
|
||||
{
|
||||
if (certificate?.SignatureAlgorithm is null)
|
||||
return null;
|
||||
|
||||
var source = (certificate.SignatureAlgorithm.FriendlyName ?? certificate.SignatureAlgorithm.Value ?? string.Empty).ToUpperInvariant();
|
||||
if (source.Contains("SHA512", StringComparison.Ordinal)) return "SHA-512";
|
||||
if (source.Contains("SHA384", StringComparison.Ordinal)) return "SHA-384";
|
||||
if (source.Contains("SHA256", StringComparison.Ordinal)) return "SHA-256";
|
||||
if (source.Contains("SHA1", StringComparison.Ordinal)) return "SHA-1";
|
||||
return certificate.SignatureAlgorithm.FriendlyName ?? certificate.SignatureAlgorithm.Value;
|
||||
}
|
||||
|
||||
private static string? InferSignatureAlgorithm(X509Certificate2? certificate)
|
||||
{
|
||||
var oidName = certificate?.PublicKey.Oid?.FriendlyName?.ToUpperInvariant();
|
||||
if (oidName is not null && oidName.Contains("RSA", StringComparison.Ordinal))
|
||||
return "RSA";
|
||||
if (oidName is not null && oidName.Contains("ECDSA", StringComparison.Ordinal))
|
||||
return "ECDSA";
|
||||
return certificate?.PublicKey.Oid?.FriendlyName ?? certificate?.PublicKey.Oid?.Value;
|
||||
}
|
||||
|
||||
private static PdfSignaturePrincipalDto ParsePrincipal(string? dn)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dn))
|
||||
return new PdfSignaturePrincipalDto(null, null, null, dn);
|
||||
|
||||
string? commonName = null;
|
||||
string? organization = null;
|
||||
string? country = null;
|
||||
|
||||
foreach (var part in dn.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var equalsIndex = part.IndexOf('=');
|
||||
if (equalsIndex <= 0 || equalsIndex >= part.Length - 1)
|
||||
continue;
|
||||
|
||||
var key = part[..equalsIndex].Trim();
|
||||
var value = part[(equalsIndex + 1)..].Trim();
|
||||
|
||||
if (key.Equals("CN", StringComparison.OrdinalIgnoreCase)) commonName = value;
|
||||
else if (key.Equals("O", StringComparison.OrdinalIgnoreCase)) organization = value;
|
||||
else if (key.Equals("C", StringComparison.OrdinalIgnoreCase)) country = value;
|
||||
}
|
||||
|
||||
return new PdfSignaturePrincipalDto(commonName, organization, country, dn);
|
||||
}
|
||||
|
||||
private static PdfCertificationLevel? ToDevExpressCertificationLevel(PdfSignatureCertificationLevel? value)
|
||||
{
|
||||
return value is null ? null : (PdfCertificationLevel)(int)value.Value;
|
||||
}
|
||||
|
||||
private static string? ToColonSerial(string? raw)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
|
||||
var normalized = raw.Replace(":", string.Empty, StringComparison.Ordinal).Trim();
|
||||
if (normalized.Length % 2 != 0)
|
||||
return normalized;
|
||||
|
||||
return string.Join(':', Enumerable.Range(0, normalized.Length / 2).Select(i => normalized.Substring(i * 2, 2).ToUpperInvariant()));
|
||||
}
|
||||
|
||||
private static string ToPem(byte[] der)
|
||||
{
|
||||
var base64 = Convert.ToBase64String(der, Base64FormattingOptions.InsertLineBreaks);
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("-----BEGIN CERTIFICATE-----");
|
||||
sb.AppendLine(base64);
|
||||
sb.Append("-----END CERTIFICATE-----");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string? TryExtractMessageDigestHex(byte[]? signatureContents)
|
||||
{
|
||||
if (signatureContents is null || signatureContents.Length == 0)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var cms = new SignedCms();
|
||||
cms.Decode(signatureContents);
|
||||
|
||||
var signerInfo = cms.SignerInfos.Count > 0 ? cms.SignerInfos[0] : null;
|
||||
if (signerInfo is null)
|
||||
return null;
|
||||
|
||||
foreach (CryptographicAttributeObject attr in signerInfo.SignedAttributes)
|
||||
{
|
||||
if (!string.Equals(attr.Oid?.Value, "1.2.840.113549.1.9.4", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
foreach (var value in attr.Values)
|
||||
{
|
||||
var octets = TryReadOctetString(value.RawData);
|
||||
if (octets is not null && octets.Length > 0)
|
||||
return Convert.ToHexString(octets);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static byte[]? TryReadOctetString(byte[] rawData)
|
||||
{
|
||||
try
|
||||
{
|
||||
var reader = new AsnReader(rawData, AsnEncodingRules.BER);
|
||||
var octets = reader.ReadOctetString();
|
||||
if (reader.HasData)
|
||||
return null;
|
||||
return octets;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<long[]> ParseByteRanges(byte[] pdfBytes)
|
||||
{
|
||||
var text = Encoding.Latin1.GetString(pdfBytes);
|
||||
var matches = Regex.Matches(text, @"/ByteRange\s*\[(?<values>[\d\s]+)\]", RegexOptions.CultureInvariant);
|
||||
var ranges = new List<long[]>(matches.Count);
|
||||
|
||||
foreach (Match match in matches)
|
||||
{
|
||||
var raw = match.Groups["values"].Value;
|
||||
var parts = raw.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries);
|
||||
var values = new long[parts.Length];
|
||||
var ok = true;
|
||||
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
if (!long.TryParse(parts[i], out values[i]))
|
||||
{
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (ok && values.Length >= 4 && values.Length % 2 == 0)
|
||||
ranges.Add(values);
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
private static bool CoversWholeDocument(long[]? byteRange, long fileLength)
|
||||
{
|
||||
if (byteRange is null || byteRange.Length < 4 || byteRange.Length % 2 != 0)
|
||||
return false;
|
||||
|
||||
if (byteRange[0] != 0)
|
||||
return false;
|
||||
|
||||
long previousEnd = -1;
|
||||
for (var i = 0; i < byteRange.Length; i += 2)
|
||||
{
|
||||
var start = byteRange[i];
|
||||
var length = byteRange[i + 1];
|
||||
if (start < 0 || length < 0)
|
||||
return false;
|
||||
|
||||
var end = start + length;
|
||||
if (previousEnd > start)
|
||||
return false;
|
||||
|
||||
previousEnd = end;
|
||||
}
|
||||
|
||||
return previousEnd == fileLength;
|
||||
}
|
||||
|
||||
private static long GetSignedRevisionLength(long[] byteRange)
|
||||
{
|
||||
if (byteRange.Length < 2)
|
||||
return -1;
|
||||
|
||||
var start = byteRange[^2];
|
||||
var length = byteRange[^1];
|
||||
if (start < 0 || length < 0)
|
||||
return -1;
|
||||
|
||||
return start + length;
|
||||
}
|
||||
|
||||
private static string ResolveUuid(string? uuid)
|
||||
{
|
||||
return Guid.TryParse(uuid, out var parsed)
|
||||
? parsed.ToString()
|
||||
: Guid.NewGuid().ToString();
|
||||
}
|
||||
|
||||
private static string BuildReasonWithMeta(string? businessReason, string uuid, string sourceHashHex)
|
||||
{
|
||||
var trimmedReason = string.IsNullOrWhiteSpace(businessReason) ? null : businessReason.Trim();
|
||||
var prefix = string.IsNullOrWhiteSpace(trimmedReason) ? string.Empty : trimmedReason + " ";
|
||||
return $"{prefix}| SFMETA:v1;doc={uuid};sha256={sourceHashHex}";
|
||||
}
|
||||
|
||||
private static string? BuildContactInfo(string? email, string? phone)
|
||||
{
|
||||
var normalizedEmail = string.IsNullOrWhiteSpace(email) ? null : email.Trim();
|
||||
var normalizedPhone = string.IsNullOrWhiteSpace(phone) ? null : phone.Trim();
|
||||
|
||||
if (normalizedEmail is null && normalizedPhone is null)
|
||||
return null;
|
||||
|
||||
if (normalizedEmail is null)
|
||||
return $"tel:{normalizedPhone}";
|
||||
|
||||
if (normalizedPhone is null)
|
||||
return $"mailto:{normalizedEmail}";
|
||||
|
||||
return $"mailto:{normalizedEmail};tel:{normalizedPhone}";
|
||||
}
|
||||
|
||||
private static (string? Email, string? Phone) ParseContactInfo(string? contactInfo)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(contactInfo))
|
||||
return (null, null);
|
||||
|
||||
string? email = null;
|
||||
string? phone = null;
|
||||
|
||||
foreach (var part in contactInfo.Split(';', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (part.StartsWith("mailto:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
email = part[7..].Trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (part.StartsWith("tel:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
phone = part[4..].Trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (email is null && part.Contains('@', StringComparison.Ordinal))
|
||||
{
|
||||
email = part.Trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (phone is null)
|
||||
phone = part.Trim();
|
||||
}
|
||||
|
||||
return (
|
||||
string.IsNullOrWhiteSpace(email) ? null : email,
|
||||
string.IsNullOrWhiteSpace(phone) ? null : phone);
|
||||
}
|
||||
|
||||
private static (string? BusinessReason, string? UUID, string? OriginalDocumentSha256) ParseReasonMeta(string? reason)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(reason))
|
||||
return (reason, null, null);
|
||||
|
||||
var match = SignatureReasonMetaRegex.Match(reason);
|
||||
if (!match.Success)
|
||||
return (reason, null, null);
|
||||
|
||||
var businessReason = reason[..match.Index].Trim();
|
||||
if (string.IsNullOrWhiteSpace(businessReason))
|
||||
businessReason = null;
|
||||
|
||||
return (
|
||||
businessReason,
|
||||
match.Groups["doc"].Value,
|
||||
match.Groups["sha"].Value.ToUpperInvariant());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
|
||||
|
||||
internal static class Pkcs11DigestInfoBuilder
|
||||
{
|
||||
public static byte[] Build(byte[] hash, HashAlgorithmName hashAlgorithm)
|
||||
{
|
||||
byte[] prefix = hashAlgorithm.Name switch
|
||||
{
|
||||
"SHA256" =>
|
||||
[
|
||||
0x30, 0x31,
|
||||
0x30, 0x0d,
|
||||
0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01,
|
||||
0x05, 0x00,
|
||||
0x04, 0x20
|
||||
],
|
||||
"SHA384" =>
|
||||
[
|
||||
0x30, 0x41,
|
||||
0x30, 0x0d,
|
||||
0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x02,
|
||||
0x05, 0x00,
|
||||
0x04, 0x30
|
||||
],
|
||||
"SHA512" =>
|
||||
[
|
||||
0x30, 0x51,
|
||||
0x30, 0x0d,
|
||||
0x06, 0x09, 0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x03,
|
||||
0x05, 0x00,
|
||||
0x04, 0x40
|
||||
],
|
||||
_ => throw new NotSupportedException($"Hash algorithm '{hashAlgorithm.Name}' is not supported.")
|
||||
};
|
||||
|
||||
var digestInfo = new byte[prefix.Length + hash.Length];
|
||||
Buffer.BlockCopy(prefix, 0, digestInfo, 0, prefix.Length);
|
||||
Buffer.BlockCopy(hash, 0, digestInfo, prefix.Length, hash.Length);
|
||||
return digestInfo;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
using DevExpress.Office.DigitalSignatures;
|
||||
using DevExpress.Pdf;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
|
||||
|
||||
internal sealed class Pkcs11ExternalSigner(Pkcs11Options options) : Pkcs7SignerBase
|
||||
{
|
||||
private readonly Pkcs11PdfSigningRsa rsa = new(options);
|
||||
private readonly DigestCalculator digestCalculator = new(HashAlgorithmType.SHA256);
|
||||
private readonly IReadOnlyList<byte[]> certificates = [ReadCertificateBytesWithFallback(options)];
|
||||
|
||||
protected override IDigestCalculator DigestCalculator => digestCalculator;
|
||||
|
||||
protected override string SigningAlgorithmOID => "1.2.840.113549.1.1.1";
|
||||
|
||||
protected override IEnumerable<byte[]> GetCertificates() => certificates;
|
||||
|
||||
protected override byte[] SignDigest(byte[] digest)
|
||||
{
|
||||
return rsa.SignHash(digest, System.Security.Cryptography.HashAlgorithmName.SHA256, System.Security.Cryptography.RSASignaturePadding.Pkcs1);
|
||||
}
|
||||
|
||||
private static byte[] ReadCertificateBytesWithFallback(Pkcs11Options options)
|
||||
{
|
||||
try
|
||||
{
|
||||
return Pkcs11SessionRunner.Run(options, session =>
|
||||
{
|
||||
var attrs = new List<Net.Pkcs11Interop.HighLevelAPI.IObjectAttribute>
|
||||
{
|
||||
session.Factories.ObjectAttributeFactory.Create(Net.Pkcs11Interop.Common.CKA.CKA_CLASS, Net.Pkcs11Interop.Common.CKO.CKO_CERTIFICATE),
|
||||
session.Factories.ObjectAttributeFactory.Create(Net.Pkcs11Interop.Common.CKA.CKA_LABEL, options.CertificateLabel)
|
||||
};
|
||||
|
||||
var handles = session.FindAllObjects(attrs);
|
||||
if (handles.Count == 0)
|
||||
throw new InvalidOperationException($"Certificate '{options.CertificateLabel}' not found in token.");
|
||||
|
||||
var values = session.GetAttributeValue(handles[0], new List<Net.Pkcs11Interop.Common.CKA> { Net.Pkcs11Interop.Common.CKA.CKA_VALUE });
|
||||
return values[0].GetValueAsByteArray();
|
||||
});
|
||||
}
|
||||
catch when (Pkcs11ToolBridge.IsAvailable(options))
|
||||
{
|
||||
return Pkcs11ToolBridge.ReadCertificateBytes(options);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
|
||||
|
||||
internal static class Pkcs11InteropBootstrap
|
||||
{
|
||||
private static readonly object Sync = new();
|
||||
private static bool Initialized;
|
||||
private const string ProxyRelativePath = "pkcs11\\windows\\proxy\\libpkcs11-proxy.dll";
|
||||
private const string ToolRelativePath = "pkcs11\\windows\\opensc\\tools\\pkcs11-tool.exe";
|
||||
|
||||
public static void EnsureLoaded(Pkcs11Options options)
|
||||
{
|
||||
if (Initialized)
|
||||
return;
|
||||
|
||||
lock (Sync)
|
||||
{
|
||||
if (Initialized)
|
||||
return;
|
||||
|
||||
var libraryPath = ResolveLibraryPath(options);
|
||||
var toolPath = ResolveToolPath(options);
|
||||
options.SetResolvedPaths(libraryPath, toolPath);
|
||||
|
||||
EnsureNativeDependencySearchPath(options.ResolvedLibraryPath);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ProxySocket))
|
||||
{
|
||||
Environment.SetEnvironmentVariable("PKCS11_PROXY_SOCKET", options.ProxySocket);
|
||||
Environment.SetEnvironmentVariable("PKCS11_DAEMON_SOCKET", options.ProxySocket);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ResolvedToolPath))
|
||||
{
|
||||
var toolDir = Path.GetDirectoryName(options.ResolvedToolPath);
|
||||
if (!string.IsNullOrWhiteSpace(toolDir))
|
||||
EnsurePathContains(toolDir);
|
||||
}
|
||||
|
||||
WarmupWithPkcs11Tool(options);
|
||||
Initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveLibraryPath(Pkcs11Options options)
|
||||
{
|
||||
var assetsRoot = string.IsNullOrWhiteSpace(options.AssetsRootPath)
|
||||
? AppContext.BaseDirectory
|
||||
: options.AssetsRootPath;
|
||||
|
||||
var resolved = Path.Combine(assetsRoot, ProxyRelativePath);
|
||||
if (File.Exists(resolved))
|
||||
return resolved;
|
||||
|
||||
throw new FileNotFoundException($"PKCS#11 proxy module not found. Checked path: {resolved}", resolved);
|
||||
}
|
||||
|
||||
private static string? ResolveToolPath(Pkcs11Options options)
|
||||
{
|
||||
var assetsRoot = string.IsNullOrWhiteSpace(options.AssetsRootPath)
|
||||
? AppContext.BaseDirectory
|
||||
: options.AssetsRootPath;
|
||||
|
||||
var resolved = Path.Combine(assetsRoot, ToolRelativePath);
|
||||
if (File.Exists(resolved))
|
||||
return resolved;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static void WarmupWithPkcs11Tool(Pkcs11Options options)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(options.ResolvedToolPath))
|
||||
return;
|
||||
|
||||
if (!File.Exists(options.ResolvedToolPath))
|
||||
return;
|
||||
|
||||
var process = new System.Diagnostics.Process
|
||||
{
|
||||
StartInfo = new System.Diagnostics.ProcessStartInfo
|
||||
{
|
||||
FileName = options.ResolvedToolPath,
|
||||
Arguments = $"--module \"{options.ResolvedLibraryPath}\" -L",
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
process.Start();
|
||||
process.WaitForExit(15000);
|
||||
}
|
||||
|
||||
private static void EnsureNativeDependencySearchPath(string libraryPath)
|
||||
{
|
||||
var directory = Path.GetDirectoryName(libraryPath);
|
||||
if (string.IsNullOrWhiteSpace(directory))
|
||||
return;
|
||||
|
||||
EnsurePathContains(directory);
|
||||
}
|
||||
|
||||
private static void EnsurePathContains(string directory)
|
||||
{
|
||||
var currentPath = Environment.GetEnvironmentVariable("PATH") ?? string.Empty;
|
||||
var entries = currentPath.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (entries.Any(p => string.Equals(p, directory, StringComparison.OrdinalIgnoreCase)))
|
||||
return;
|
||||
|
||||
var updatedPath = string.IsNullOrWhiteSpace(currentPath)
|
||||
? directory
|
||||
: directory + ";" + currentPath;
|
||||
|
||||
Environment.SetEnvironmentVariable("PATH", updatedPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
using Net.Pkcs11Interop.Common;
|
||||
using Net.Pkcs11Interop.HighLevelAPI;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
|
||||
|
||||
internal sealed class Pkcs11PdfSigningRsa(Pkcs11Options options) : RSA
|
||||
{
|
||||
public override int KeySize
|
||||
{
|
||||
get => KeySizeValue;
|
||||
set => KeySizeValue = value;
|
||||
}
|
||||
|
||||
public override KeySizes[] LegalKeySizes => [new(1024, 8192, 8)];
|
||||
public override string? KeyExchangeAlgorithm => "RSA";
|
||||
public override string SignatureAlgorithm => "RSA";
|
||||
|
||||
public override byte[] SignHash(byte[] hash, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding)
|
||||
{
|
||||
if (padding != RSASignaturePadding.Pkcs1)
|
||||
throw new NotSupportedException("Only RSA PKCS#1 v1.5 padding is supported.");
|
||||
|
||||
byte[] digestInfo = Pkcs11DigestInfoBuilder.Build(hash, hashAlgorithm);
|
||||
try
|
||||
{
|
||||
return Pkcs11SessionRunner.Run(options, session =>
|
||||
{
|
||||
var attrs = new List<IObjectAttribute>
|
||||
{
|
||||
session.Factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, CKO.CKO_PRIVATE_KEY),
|
||||
session.Factories.ObjectAttributeFactory.Create(CKA.CKA_LABEL, options.PrivateKeyLabel)
|
||||
};
|
||||
|
||||
var handles = session.FindAllObjects(attrs);
|
||||
if (handles.Count == 0)
|
||||
throw new InvalidOperationException($"Private key '{options.PrivateKeyLabel}' not found in token.");
|
||||
|
||||
var mechanism = session.Factories.MechanismFactory.Create(CKM.CKM_RSA_PKCS);
|
||||
return session.Sign(mechanism, handles[0], digestInfo);
|
||||
});
|
||||
}
|
||||
catch when (Pkcs11ToolBridge.IsAvailable(options))
|
||||
{
|
||||
return Pkcs11ToolBridge.SignDigestInfo(options, digestInfo);
|
||||
}
|
||||
}
|
||||
|
||||
public override bool VerifyHash(byte[] hash, byte[] signature, HashAlgorithmName hashAlgorithm, RSASignaturePadding padding)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override byte[] Decrypt(byte[] data, RSAEncryptionPadding padding)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override byte[] Encrypt(byte[] data, RSAEncryptionPadding padding)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override RSAParameters ExportParameters(bool includePrivateParameters)
|
||||
=> throw new NotSupportedException();
|
||||
|
||||
public override void ImportParameters(RSAParameters parameters)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
using System.Threading;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
|
||||
|
||||
internal sealed class Pkcs11SelfTestStatusStore : IPkcs11SelfTestStatusProvider
|
||||
{
|
||||
private Pkcs11SelfTestStatusDto _status = new(
|
||||
Completed: false,
|
||||
Success: false,
|
||||
Mode: "pending",
|
||||
Summary: "PKCS#11 startup self-test has not run yet.",
|
||||
CheckedAtUtc: null,
|
||||
InteropError: null,
|
||||
ToolError: null);
|
||||
|
||||
public Pkcs11SelfTestStatusDto GetStatus() => Volatile.Read(ref _status);
|
||||
|
||||
public void SetStatus(Pkcs11SelfTestStatusDto status)
|
||||
{
|
||||
Volatile.Write(ref _status, status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
using Net.Pkcs11Interop.Common;
|
||||
using Net.Pkcs11Interop.HighLevelAPI;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
|
||||
|
||||
internal static class Pkcs11SessionRunner
|
||||
{
|
||||
public static T Run<T>(Pkcs11Options options, Func<ISession, T> action)
|
||||
{
|
||||
Pkcs11InteropBootstrap.EnsureLoaded(options);
|
||||
|
||||
var factories = new Pkcs11InteropFactories();
|
||||
using var pkcs11 = LoadLibraryWithFallback(factories, options.ResolvedLibraryPath, options.ProxySocket);
|
||||
var slot = ResolveSlot(pkcs11.GetSlotList(SlotsType.WithTokenPresent), options);
|
||||
|
||||
using var session = slot.OpenSession(SessionType.ReadOnly);
|
||||
session.Login(CKU.CKU_USER, options.UserPin);
|
||||
|
||||
try
|
||||
{
|
||||
return action(session);
|
||||
}
|
||||
finally
|
||||
{
|
||||
session.Logout();
|
||||
}
|
||||
}
|
||||
|
||||
private static IPkcs11Library LoadLibraryWithFallback(Pkcs11InteropFactories factories, string libraryPath, string? proxySocket)
|
||||
{
|
||||
List<Exception> errors = [];
|
||||
foreach (string candidate in BuildLibraryCandidates(libraryPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.MultiThreaded);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(new InvalidOperationException($"{candidate} | MultiThreaded+WithFunctionList", ex));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.SingleThreaded);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(new InvalidOperationException($"{candidate} | SingleThreaded+WithFunctionList", ex));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.MultiThreaded, InitType.WithoutFunctionList);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(new InvalidOperationException($"{candidate} | MultiThreaded+WithoutFunctionList", ex));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return factories.Pkcs11LibraryFactory.LoadPkcs11Library(factories, candidate, AppType.SingleThreaded, InitType.WithoutFunctionList);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
errors.Add(new InvalidOperationException($"{candidate} | SingleThreaded+WithoutFunctionList", ex));
|
||||
}
|
||||
}
|
||||
|
||||
var socketInfo = string.IsNullOrWhiteSpace(proxySocket) ? "<empty>" : proxySocket;
|
||||
throw new AggregateException($"Unable to initialize PKCS#11 library '{libraryPath}' with any supported initialization mode. PKCS11_PROXY_SOCKET={socketInfo}", errors);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> BuildLibraryCandidates(string configuredPath)
|
||||
{
|
||||
var unique = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
configuredPath
|
||||
};
|
||||
|
||||
var directory = Path.GetDirectoryName(configuredPath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
string[] alternates =
|
||||
[
|
||||
"client.dll",
|
||||
"pkcs11-proxy.dll",
|
||||
"libpkcs11-proxy.dll"
|
||||
];
|
||||
|
||||
foreach (var fileName in alternates)
|
||||
{
|
||||
var candidate = Path.Combine(directory, fileName);
|
||||
if (File.Exists(candidate))
|
||||
unique.Add(candidate);
|
||||
}
|
||||
}
|
||||
|
||||
return unique;
|
||||
}
|
||||
|
||||
private static ISlot ResolveSlot(IEnumerable<ISlot> slots, Pkcs11Options options)
|
||||
{
|
||||
if (options.SlotId.HasValue)
|
||||
{
|
||||
var bySlotId = slots.FirstOrDefault(s => s.SlotId == options.SlotId.Value);
|
||||
if (bySlotId is not null)
|
||||
return bySlotId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.TokenLabel))
|
||||
{
|
||||
var byTokenLabel = slots.FirstOrDefault(s =>
|
||||
string.Equals(s.GetTokenInfo().Label?.Trim(), options.TokenLabel.Trim(), StringComparison.Ordinal));
|
||||
|
||||
if (byTokenLabel is not null)
|
||||
return byTokenLabel;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Unable to resolve PKCS#11 slot. Check Crypt:Pkcs11:SlotId / TokenLabel.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Net.Pkcs11Interop.Common;
|
||||
using Net.Pkcs11Interop.HighLevelAPI;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
|
||||
|
||||
internal sealed class Pkcs11StartupSelfTestHostedService(
|
||||
IOptions<Pkcs11Options> options,
|
||||
Pkcs11SelfTestStatusStore statusStore,
|
||||
ILogger<Pkcs11StartupSelfTestHostedService> logger) : IHostedService
|
||||
{
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var pkcs11 = options.Value;
|
||||
|
||||
try
|
||||
{
|
||||
RunInteropProbe(pkcs11);
|
||||
statusStore.SetStatus(new Pkcs11SelfTestStatusDto(
|
||||
Completed: true,
|
||||
Success: true,
|
||||
Mode: "interop",
|
||||
Summary: "PKCS#11 startup self-test succeeded via interop.",
|
||||
CheckedAtUtc: DateTimeOffset.UtcNow,
|
||||
InteropError: null,
|
||||
ToolError: null));
|
||||
logger.LogInformation("PKCS#11 startup self-test succeeded (mode=interop, library={LibraryPath}, proxy={ProxySocket}).", pkcs11.ResolvedLibraryPath, pkcs11.ProxySocket);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
catch (Exception interopEx) when (Pkcs11ToolBridge.IsAvailable(pkcs11))
|
||||
{
|
||||
try
|
||||
{
|
||||
RunToolProbe(pkcs11);
|
||||
statusStore.SetStatus(new Pkcs11SelfTestStatusDto(
|
||||
Completed: true,
|
||||
Success: true,
|
||||
Mode: "pkcs11-tool",
|
||||
Summary: "PKCS#11 interop failed, tool fallback succeeded.",
|
||||
CheckedAtUtc: DateTimeOffset.UtcNow,
|
||||
InteropError: interopEx.Message,
|
||||
ToolError: null));
|
||||
logger.LogWarning(interopEx, "PKCS#11 interop self-test failed, but tool fallback is healthy (mode=pkcs11-tool, tool={ToolPath}).", pkcs11.ResolvedToolPath);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
catch (Exception toolEx)
|
||||
{
|
||||
statusStore.SetStatus(new Pkcs11SelfTestStatusDto(
|
||||
Completed: true,
|
||||
Success: false,
|
||||
Mode: "failed",
|
||||
Summary: "PKCS#11 startup self-test failed for both interop and tool fallback.",
|
||||
CheckedAtUtc: DateTimeOffset.UtcNow,
|
||||
InteropError: interopEx.Message,
|
||||
ToolError: toolEx.Message));
|
||||
logger.LogError(toolEx, "PKCS#11 startup self-test failed for both interop and tool fallback. library={LibraryPath}, proxy={ProxySocket}, tool={ToolPath}", pkcs11.ResolvedLibraryPath, pkcs11.ProxySocket, pkcs11.ResolvedToolPath);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
statusStore.SetStatus(new Pkcs11SelfTestStatusDto(
|
||||
Completed: true,
|
||||
Success: false,
|
||||
Mode: "failed",
|
||||
Summary: "PKCS#11 startup self-test failed via interop and no tool fallback is available.",
|
||||
CheckedAtUtc: DateTimeOffset.UtcNow,
|
||||
InteropError: ex.Message,
|
||||
ToolError: null));
|
||||
logger.LogError(ex, "PKCS#11 startup self-test failed (mode=interop, no fallback available). library={LibraryPath}, proxy={ProxySocket}", pkcs11.ResolvedLibraryPath, pkcs11.ProxySocket);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||
|
||||
private static void RunInteropProbe(Pkcs11Options options)
|
||||
{
|
||||
Pkcs11SessionRunner.Run(options, session =>
|
||||
{
|
||||
EnsureObjectExists(session, CKO.CKO_CERTIFICATE, options.CertificateLabel, "certificate");
|
||||
EnsureObjectExists(session, CKO.CKO_PRIVATE_KEY, options.PrivateKeyLabel, "private key");
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
private static void RunToolProbe(Pkcs11Options options)
|
||||
{
|
||||
_ = Pkcs11ToolBridge.ReadCertificateBytes(options);
|
||||
var testDigest = SHA256.HashData([1, 2, 3, 4]);
|
||||
var digestInfo = Pkcs11DigestInfoBuilder.Build(testDigest, HashAlgorithmName.SHA256);
|
||||
_ = Pkcs11ToolBridge.SignDigestInfo(options, digestInfo);
|
||||
}
|
||||
|
||||
private static void EnsureObjectExists(ISession session, CKO objectClass, string label, string objectType)
|
||||
{
|
||||
var attrs = new List<IObjectAttribute>
|
||||
{
|
||||
session.Factories.ObjectAttributeFactory.Create(CKA.CKA_CLASS, objectClass),
|
||||
session.Factories.ObjectAttributeFactory.Create(CKA.CKA_LABEL, label)
|
||||
};
|
||||
|
||||
var handles = session.FindAllObjects(attrs);
|
||||
if (handles.Count == 0)
|
||||
throw new InvalidOperationException($"PKCS#11 {objectType} '{label}' not found in token.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
using EnvelopeGenerator.Application.Common.Configurations;
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
|
||||
|
||||
internal static class Pkcs11ToolBridge
|
||||
{
|
||||
public static bool IsAvailable(Pkcs11Options options)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(options.ResolvedToolPath) && File.Exists(options.ResolvedToolPath);
|
||||
}
|
||||
|
||||
public static byte[] ReadCertificateBytes(Pkcs11Options options)
|
||||
{
|
||||
EnsureAvailable(options);
|
||||
|
||||
var outputFile = Path.Combine(Path.GetTempPath(), $"pkcs11-cert-{Guid.NewGuid():N}.der");
|
||||
try
|
||||
{
|
||||
var args = CreateBaseArgs(options);
|
||||
args.Add("--read-object");
|
||||
args.Add("--type");
|
||||
args.Add("cert");
|
||||
args.Add("--label");
|
||||
args.Add(options.CertificateLabel);
|
||||
args.Add("--output-file");
|
||||
args.Add(outputFile);
|
||||
|
||||
Execute(options, args);
|
||||
return File.ReadAllBytes(outputFile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteIfExists(outputFile);
|
||||
}
|
||||
}
|
||||
|
||||
public static byte[] SignDigestInfo(Pkcs11Options options, byte[] digestInfo)
|
||||
{
|
||||
EnsureAvailable(options);
|
||||
|
||||
var inputFile = Path.Combine(Path.GetTempPath(), $"pkcs11-sign-in-{Guid.NewGuid():N}.bin");
|
||||
var outputFile = Path.Combine(Path.GetTempPath(), $"pkcs11-sign-out-{Guid.NewGuid():N}.bin");
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(inputFile, digestInfo);
|
||||
|
||||
var args = CreateBaseArgs(options);
|
||||
args.Add("--sign");
|
||||
args.Add("--mechanism");
|
||||
args.Add("RSA-PKCS");
|
||||
args.Add("--type");
|
||||
args.Add("privkey");
|
||||
args.Add("--label");
|
||||
args.Add(options.PrivateKeyLabel);
|
||||
args.Add("--input-file");
|
||||
args.Add(inputFile);
|
||||
args.Add("--output-file");
|
||||
args.Add(outputFile);
|
||||
|
||||
Execute(options, args);
|
||||
return File.ReadAllBytes(outputFile);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteIfExists(inputFile);
|
||||
DeleteIfExists(outputFile);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> CreateBaseArgs(Pkcs11Options options)
|
||||
{
|
||||
var args = new List<string>
|
||||
{
|
||||
"--module",
|
||||
options.ResolvedLibraryPath,
|
||||
"--login",
|
||||
"--pin",
|
||||
options.UserPin
|
||||
};
|
||||
|
||||
if (options.SlotId.HasValue)
|
||||
{
|
||||
args.Add("--slot");
|
||||
args.Add(options.SlotId.Value.ToString());
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(options.TokenLabel))
|
||||
{
|
||||
args.Add("--token-label");
|
||||
args.Add(options.TokenLabel);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
private static void Execute(Pkcs11Options options, IReadOnlyList<string> arguments)
|
||||
{
|
||||
var process = new Process
|
||||
{
|
||||
StartInfo = new ProcessStartInfo
|
||||
{
|
||||
FileName = options.ResolvedToolPath!,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
}
|
||||
};
|
||||
|
||||
foreach (var argument in arguments)
|
||||
process.StartInfo.ArgumentList.Add(argument);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ProxySocket))
|
||||
{
|
||||
process.StartInfo.Environment["PKCS11_PROXY_SOCKET"] = options.ProxySocket;
|
||||
process.StartInfo.Environment["PKCS11_DAEMON_SOCKET"] = options.ProxySocket;
|
||||
}
|
||||
|
||||
process.Start();
|
||||
|
||||
string stdOut = process.StandardOutput.ReadToEnd();
|
||||
string stdErr = process.StandardError.ReadToEnd();
|
||||
|
||||
if (!process.WaitForExit(30000))
|
||||
{
|
||||
try { process.Kill(entireProcessTree: true); } catch { }
|
||||
throw new TimeoutException($"pkcs11-tool timed out. Args: {string.Join(' ', process.StartInfo.ArgumentList)}");
|
||||
}
|
||||
|
||||
if (process.ExitCode != 0)
|
||||
throw new InvalidOperationException($"pkcs11-tool failed with exit code {process.ExitCode}. StdOut: {stdOut} StdErr: {stdErr}");
|
||||
}
|
||||
|
||||
private static void EnsureAvailable(Pkcs11Options options)
|
||||
{
|
||||
if (!IsAvailable(options))
|
||||
throw new InvalidOperationException("pkcs11-tool is not available at resolved pkcs11 assets path.");
|
||||
}
|
||||
|
||||
private static void DeleteIfExists(string path)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
# FES Signature Infrastructure Plan
|
||||
|
||||
## 1. Goal
|
||||
|
||||
Build a reusable and modular cryptography infrastructure in `EnvelopeGenerator.Infrastructure.Crypt` to support FES (Advanced Electronic Signature) workflows.
|
||||
|
||||
This module must:
|
||||
|
||||
- use PKCS#11 providers (SoftHSMv2 first, real HSM later),
|
||||
- stay independent from UI/API concerns,
|
||||
- expose clean interfaces to `Application` layer use-cases,
|
||||
- support future extensions (timestamping, certificate chain validation, multiple key algorithms).
|
||||
|
||||
---
|
||||
|
||||
## 2. Context and Constraints
|
||||
|
||||
1. SoftHSMv2 is **not** a REST service. It is a PKCS#11 module loaded as a native library.
|
||||
2. `EnvelopeGenerator.Server` is presentation only; cryptographic implementation belongs to infrastructure.
|
||||
3. Clean Architecture boundaries must be respected:
|
||||
- `Application` depends on abstractions.
|
||||
- `Infrastructure.Crypt` implements abstractions.
|
||||
4. Initial target framework is `net8.0`.
|
||||
5. We need production-ready behavior, not only PoC signing.
|
||||
|
||||
---
|
||||
|
||||
## 3. High-Level Design
|
||||
|
||||
## 3.1 Architecture slice
|
||||
|
||||
- **Application layer (contracts/use-cases):**
|
||||
- request signing hash/document,
|
||||
- request certificate/public key material,
|
||||
- request verification,
|
||||
- consume signature evidence result.
|
||||
|
||||
- **Infrastructure.Crypt layer (this project):**
|
||||
- PKCS#11 session handling,
|
||||
- key/certificate object discovery,
|
||||
- hash signing operations,
|
||||
- local cryptographic verification,
|
||||
- certificate parsing helpers,
|
||||
- error normalization.
|
||||
|
||||
## 3.2 Provider model
|
||||
|
||||
Define provider-agnostic APIs and add one concrete provider first:
|
||||
|
||||
- `Pkcs11SignatureProvider` (primary)
|
||||
- future providers:
|
||||
- `RemoteSigningProvider` (if a dedicated signing gateway is introduced)
|
||||
- `MockSignatureProvider` (for deterministic test scenarios)
|
||||
|
||||
---
|
||||
|
||||
## 4. Deliverables (Phase by Phase)
|
||||
|
||||
## Phase 0 - Contracts and Domain Language
|
||||
|
||||
Create neutral models and interfaces in `Infrastructure.Crypt` (or shared abstractions if needed later):
|
||||
|
||||
- `SigningAlgorithm` (enum/value object)
|
||||
- `Pkcs11ProviderOptions`
|
||||
- `KeyLocator` (label/id/slot/token selectors)
|
||||
- `SignHashRequest`
|
||||
- `SignHashResult`
|
||||
- `VerifySignatureRequest`
|
||||
- `VerifySignatureResult`
|
||||
- `CertificateDescriptor`
|
||||
- `SignatureEvidence` (technical evidence payload)
|
||||
|
||||
Interfaces:
|
||||
|
||||
- `ISignatureProvider`
|
||||
- `ICertificateProvider`
|
||||
- `ISignatureVerifier`
|
||||
- `ICryptographicHealthCheck`
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- no dependency on ASP.NET types,
|
||||
- no UI/API naming leakage,
|
||||
- all model names are business-neutral.
|
||||
|
||||
## Phase 1 - PKCS#11 Core Adapter (SoftHSM-ready)
|
||||
|
||||
Implement:
|
||||
|
||||
- `Pkcs11LibraryLoader`
|
||||
- `Pkcs11SessionFactory`
|
||||
- `Pkcs11ObjectFinder`
|
||||
- `Pkcs11SignatureProvider`
|
||||
- `Pkcs11CertificateProvider`
|
||||
|
||||
Capabilities:
|
||||
|
||||
1. load module path from options (`libsofthsm2.so` or proxy module when required),
|
||||
2. enumerate slots/tokens,
|
||||
3. open read-write session,
|
||||
4. login with user pin,
|
||||
5. find private key by label/id,
|
||||
6. sign digest with selected mechanism,
|
||||
7. read certificate object by label/id.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- deterministic disposal of sessions,
|
||||
- no leaked native handles,
|
||||
- detailed typed errors (not raw exception strings only).
|
||||
|
||||
## Phase 2 - Verification and Certificate Utilities
|
||||
|
||||
Implement:
|
||||
|
||||
- `DefaultSignatureVerifier`
|
||||
- `X509CertificateParser`
|
||||
- optional `CertificateChainValidator` (toggleable)
|
||||
|
||||
Capabilities:
|
||||
|
||||
1. verify signature using returned certificate/public key,
|
||||
2. expose certificate metadata (`thumbprint`, `subject`, `issuer`, `notBefore`, `notAfter`),
|
||||
3. map validation failures into stable error codes.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- same request yields same verification outcome,
|
||||
- invalid signature returns structured negative result.
|
||||
|
||||
## Phase 3 - Evidence Builder
|
||||
|
||||
Implement:
|
||||
|
||||
- `SignatureEvidenceBuilder`
|
||||
|
||||
Evidence payload should contain:
|
||||
|
||||
- transaction/correlation id,
|
||||
- algorithm,
|
||||
- hash metadata,
|
||||
- key locator snapshot (safe subset),
|
||||
- certificate fingerprint and subject,
|
||||
- sign timestamp (UTC),
|
||||
- verification outcome,
|
||||
- provider type (`PKCS11`).
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- no secret values in evidence (pin, private key id internals),
|
||||
- evidence serializable for storage/audit.
|
||||
|
||||
## Phase 4 - Dependency Injection Module
|
||||
|
||||
Implement:
|
||||
|
||||
- `CryptInfrastructureDependencyInjection` extension methods
|
||||
|
||||
Methods:
|
||||
|
||||
- `AddCryptInfrastructure(...)`
|
||||
- `AddPkcs11Provider(...)`
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- one-line registration from composition root,
|
||||
- options validation on startup.
|
||||
|
||||
## Phase 5 - Test Suite (`EnvelopeGenerator.Tests`)
|
||||
|
||||
Add test categories for `net8.0`:
|
||||
|
||||
1. `Unit`:
|
||||
- request validation,
|
||||
- algorithm mapping,
|
||||
- evidence building,
|
||||
- error mapping.
|
||||
2. `Integration` (conditional/manual profile):
|
||||
- SoftHSM slot listing,
|
||||
- login,
|
||||
- sign hash,
|
||||
- verify,
|
||||
- certificate read.
|
||||
|
||||
Acceptance criteria:
|
||||
|
||||
- unit tests run in CI without native SoftHSM,
|
||||
- integration tests run when module path + pins are provided via environment variables.
|
||||
|
||||
---
|
||||
|
||||
## 5. Proposed Project Structure
|
||||
|
||||
```text
|
||||
EnvelopeGenerator.Infrastructure.Crypt/
|
||||
Abstractions/
|
||||
ISignatureProvider.cs
|
||||
ICertificateProvider.cs
|
||||
ISignatureVerifier.cs
|
||||
ICryptographicHealthCheck.cs
|
||||
Models/
|
||||
SigningAlgorithm.cs
|
||||
SignHashRequest.cs
|
||||
SignHashResult.cs
|
||||
VerifySignatureRequest.cs
|
||||
VerifySignatureResult.cs
|
||||
SignatureEvidence.cs
|
||||
CertificateDescriptor.cs
|
||||
KeyLocator.cs
|
||||
Options/
|
||||
Pkcs11ProviderOptions.cs
|
||||
Pkcs11/
|
||||
Pkcs11LibraryLoader.cs
|
||||
Pkcs11SessionFactory.cs
|
||||
Pkcs11ObjectFinder.cs
|
||||
Pkcs11SignatureProvider.cs
|
||||
Pkcs11CertificateProvider.cs
|
||||
Verification/
|
||||
DefaultSignatureVerifier.cs
|
||||
X509CertificateParser.cs
|
||||
Evidence/
|
||||
SignatureEvidenceBuilder.cs
|
||||
DependencyInjection/
|
||||
CryptInfrastructureDependencyInjection.cs
|
||||
Exceptions/
|
||||
CryptographicOperationException.cs
|
||||
Pkcs11ProviderException.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Configuration Strategy
|
||||
|
||||
Use options (bound from host config) with environment override support:
|
||||
|
||||
- `Crypt:Provider = PKCS11`
|
||||
- `Crypt:Pkcs11:AssetsRootPath` (base path that contains bundled `pkcs11` assets)
|
||||
- `Crypt:Pkcs11:SlotId` or token selectors
|
||||
- `Crypt:Pkcs11:UserPin` (from secret store/env, not plain committed value)
|
||||
- `Crypt:Pkcs11:PrivateKeyLabel`
|
||||
- `Crypt:Pkcs11:CertificateLabel`
|
||||
- `Crypt:Pkcs11:LoginType`
|
||||
|
||||
Validation rules:
|
||||
|
||||
- resolved module path must exist under configured/default assets root,
|
||||
- at least one key locator required,
|
||||
- pin required for signing operations.
|
||||
|
||||
---
|
||||
|
||||
## 7. Error Model
|
||||
|
||||
Define stable error codes to prevent provider-specific leakage:
|
||||
|
||||
- `CRYPT_PROVIDER_UNAVAILABLE`
|
||||
- `CRYPT_SLOT_NOT_FOUND`
|
||||
- `CRYPT_LOGIN_FAILED`
|
||||
- `CRYPT_KEY_NOT_FOUND`
|
||||
- `CRYPT_CERT_NOT_FOUND`
|
||||
- `CRYPT_SIGN_FAILED`
|
||||
- `CRYPT_VERIFY_FAILED`
|
||||
|
||||
Map low-level exceptions to these codes with safe diagnostics.
|
||||
|
||||
---
|
||||
|
||||
## 8. Non-Functional Requirements
|
||||
|
||||
1. **Security:** never log pins or raw private key handles.
|
||||
2. **Reliability:** deterministic cleanup (`IDisposable`/`IAsyncDisposable`).
|
||||
3. **Observability:** structured logs with correlation id.
|
||||
4. **Performance:** avoid repeated login per operation when a safe session strategy is available.
|
||||
5. **Extensibility:** algorithm/provider mapping must be open for extension.
|
||||
|
||||
---
|
||||
|
||||
## 9. Implementation Order (Small Tasks)
|
||||
|
||||
1. Add models + interfaces.
|
||||
2. Add options + validators.
|
||||
3. Add PKCS#11 low-level loader/session wrappers.
|
||||
4. Implement `Pkcs11SignatureProvider` for hash signing.
|
||||
5. Implement certificate retrieval.
|
||||
6. Implement verifier.
|
||||
7. Implement evidence builder.
|
||||
8. Add DI registration extension.
|
||||
9. Add unit tests.
|
||||
10. Add optional integration tests (SoftHSM profile).
|
||||
|
||||
---
|
||||
|
||||
## 10. Definition of Done
|
||||
|
||||
Done means:
|
||||
|
||||
1. `Infrastructure.Crypt` exposes generic cryptographic services usable by Application use-cases.
|
||||
2. SoftHSM-backed PKCS#11 sign/verify path works in integration tests.
|
||||
3. All sensitive configuration is externalized.
|
||||
4. Structured error codes are returned for expected failure scenarios.
|
||||
5. Documentation exists for host registration and required settings.
|
||||
@@ -0,0 +1,228 @@
|
||||
# FES (Advanced Electronic Signature) Summary and Integration Plan - EN
|
||||
|
||||
This document summarizes the shared diagram flow (`Ablauf SignFlow Unternehmenszertifikat`) and provides a small-step integration plan for the `EnvelopeGenerator` solution.
|
||||
|
||||
---
|
||||
|
||||
## 1) Simplified flow summary
|
||||
|
||||
Flow logic:
|
||||
|
||||
1. The sender uploads a document and defines signers.
|
||||
2. If a valid company certificate already exists in HSM, it is used with the company key pair.
|
||||
3. If no valid company certificate exists, CA issuance/renewal flow is required.
|
||||
4. The signer receives an e-mail with a signing link.
|
||||
5. The signer completes SMS OTP (2FA) and performs UI signature interaction.
|
||||
6. Backend computes the hash for the current signing stage and sends it to HSM.
|
||||
7. HSM signs the hash with the private key and returns signature + certificate context.
|
||||
8. Backend embeds digital signature and certificate into PDF and writes audit trail.
|
||||
9. After all signers complete, the final artifact is archived.
|
||||
10. The final signed document is distributed to signers.
|
||||
|
||||
Important: The visible UI signature is not yet the legal electronic signature. The legal signature is produced in backend+HSM steps.
|
||||
|
||||
---
|
||||
|
||||
## 2) Relation to current codebase (as-is)
|
||||
|
||||
- `EnvelopeGenerator.Server` is the presentation layer host (Blazor + API hosting).
|
||||
- Business flow is handled in `Application` via MediatR pipelines.
|
||||
- Current receiver flow is centered around UI overlay/signature capture.
|
||||
- Missing FES core: PKCS#11 hash-sign-verify + certificate-based PDF embedding pipeline.
|
||||
|
||||
Critical deployment fact:
|
||||
|
||||
- `EnvelopeGenerator.Server` is planned to run on Windows.
|
||||
- SoftHSM is currently planned to run on Linux.
|
||||
- A Windows process cannot directly load Linux PKCS#11 module (`libsofthsm2.so`).
|
||||
- Therefore, architecture must include a compatibility bridge (service or proxy) unless HSM/SoftHSM is local to Windows.
|
||||
|
||||
Conclusion: FES is not a replacement of the current flow; it is a cryptographic hardening layer behind it.
|
||||
|
||||
---
|
||||
|
||||
## 3) Why SoftHSM tests are foundational
|
||||
|
||||
The tests defined in `SOFTHSM_TEST_KILAVUZU_TR.md` are the technical prerequisite for FES:
|
||||
|
||||
- `slots/login` -> HSM connectivity and authentication
|
||||
- `certificate` -> certificate retrieval path
|
||||
- `sign` -> hash signing with private key
|
||||
- `verify` -> signature and certificate consistency validation
|
||||
|
||||
Without these tests passing, diagram steps 6-8 should not go live.
|
||||
|
||||
---
|
||||
|
||||
## 4) Target architecture (clean architecture aligned)
|
||||
|
||||
### Deployment topology options (must choose one)
|
||||
|
||||
1. **Linux signing service (recommended)**
|
||||
- Windows `EnvelopeGenerator.Server` calls Linux signing API over HTTPS.
|
||||
- Linux signing service performs PKCS#11 operations against SoftHSM.
|
||||
- Private keys never leave Linux/HSM boundary.
|
||||
|
||||
2. **PKCS#11 proxy bridge**
|
||||
- Windows host loads a Windows PKCS#11 proxy client DLL.
|
||||
- Proxy forwards PKCS#11 operations to Linux side connected to SoftHSM.
|
||||
|
||||
3. **Windows-local SoftHSM (dev/test)**
|
||||
- SoftHSM also installed on Windows and used locally.
|
||||
- Useful for local testing, not preferred production topology.
|
||||
|
||||
Decision note:
|
||||
|
||||
- For production-critical FES, choose option 1 by default unless enterprise HSM policy requires option 2.
|
||||
- Keep option 3 for development convenience only.
|
||||
|
||||
### Layer responsibilities
|
||||
|
||||
- `EnvelopeGenerator.Server`:
|
||||
- API endpoints, auth orchestration, input validation.
|
||||
- FES branch routing: standard-sign path vs external signing-service/proxy path.
|
||||
- No private-key material handling.
|
||||
|
||||
- `EnvelopeGenerator.Application`:
|
||||
- Use cases such as `SignWithHsmCommand`.
|
||||
- MediatR pipeline orchestration for status/history/audit.
|
||||
- FES evidence model aggregation (ip/email/phone/user-agent/otp proof/timestamps).
|
||||
|
||||
- `EnvelopeGenerator.Infrastructure`:
|
||||
- Implementations for `IPkcs11Service`, `IPdfSignatureService`, `ITimestampService`.
|
||||
- Pkcs11Interop integration and cryptographic operations.
|
||||
- Optional client for Linux signing service if topology option 1 is selected.
|
||||
|
||||
- `Domain`:
|
||||
- Signature evidence and audit-trail models.
|
||||
|
||||
---
|
||||
|
||||
## 5) Small-step integration plan
|
||||
|
||||
## Phase 0 - Scope and contracts
|
||||
|
||||
1. Finalize signing algorithms (for example `SHA256_RSA_PKCS`).
|
||||
2. Confirm strategy: company certificate vs signer-specific certificate.
|
||||
3. Freeze mandatory audit fields:
|
||||
- identity, OTP proof, IP, user-agent, transaction id, timestamps, hash metadata.
|
||||
4. Freeze deployment topology decision:
|
||||
- option 1 (Linux signing service), option 2 (PKCS#11 proxy), or option 3 (Windows-local SoftHSM).
|
||||
5. Define production host mapping:
|
||||
- where `EnvelopeGenerator.Server` runs,
|
||||
- where HSM/SoftHSM runs,
|
||||
- where certificate lifecycle is managed.
|
||||
|
||||
Deliverable: Technical decision record (ADR-style markdown).
|
||||
|
||||
## Phase 1 - PKCS#11 connector (infrastructure)
|
||||
|
||||
1. Define `IPkcs11Service` interface.
|
||||
2. Add `Pkcs11Interop` implementation.
|
||||
3. Add config model:
|
||||
- library path, slot, token label, key label, certificate label.
|
||||
4. Ensure secure secret handling for PIN.
|
||||
5. Ensure host-compatible module loading:
|
||||
- Windows server must load Windows DLL,
|
||||
- Linux server must load Linux `.so`.
|
||||
|
||||
Deliverable: Testable PKCS#11 service with health/slot/login/sign/verify capabilities.
|
||||
|
||||
## Phase 1.5 - Cross-host bridge implementation
|
||||
|
||||
1. If option 1 selected:
|
||||
- implement Linux signing service API contract (`/sign`, `/certificate`, `/health`),
|
||||
- implement authenticated HTTP client in `EnvelopeGenerator.Server`.
|
||||
2. If option 2 selected:
|
||||
- install/configure PKCS#11 proxy client DLL on Windows,
|
||||
- install/configure proxy server on Linux and bind to SoftHSM.
|
||||
|
||||
Deliverable: Windows-to-Linux signing communication path proven end-to-end.
|
||||
|
||||
## Phase 2 - Application use case
|
||||
|
||||
1. Add `SignDocumentHashCommand` (or equivalent).
|
||||
2. Pipeline flow:
|
||||
- compute/receive hash,
|
||||
- sign with HSM,
|
||||
- attach certificate context,
|
||||
- verify,
|
||||
- produce evidence object.
|
||||
3. Add resilient error mapping and retry strategy.
|
||||
|
||||
Deliverable: HSM signing use case in Application layer.
|
||||
|
||||
## Phase 3 - PDF signature embedding
|
||||
|
||||
1. Implement PDF signature embedding via `IPdfSignatureService`.
|
||||
2. Build signer-specific audit-trail pages.
|
||||
3. Append all audit pages to final PDF artifact.
|
||||
|
||||
Deliverable: Signed PDF with certificate context and audit trail pages.
|
||||
|
||||
## Phase 4 - Presentation/API integration
|
||||
|
||||
1. Integrate FES option into submit flow (feature flag guarded).
|
||||
2. Add operational endpoints:
|
||||
- HSM health/check
|
||||
- signature evidence query (restricted roles)
|
||||
3. Keep DTOs safe; avoid exposing sensitive cryptographic material.
|
||||
4. In `SignatureController.Submit`, add FES branch that sends signing payload + audit context to selected signing backend.
|
||||
|
||||
Deliverable: API-level orchestration with minimal UI disruption.
|
||||
|
||||
## Phase 5 - Audit, archive, distribution
|
||||
|
||||
1. Persist signature evidence records.
|
||||
2. Include hash/cert metadata in archival package.
|
||||
3. Add signed artifact reference to distribution e-mails.
|
||||
|
||||
Deliverable: Alignment with diagram steps 9-10.
|
||||
|
||||
## Phase 6 - Testing and operations
|
||||
|
||||
1. Postman collection + Newman automation.
|
||||
2. Positive/negative matrix:
|
||||
- wrong pin, wrong slot, missing cert, expired cert, HSM timeout.
|
||||
3. Observability:
|
||||
- structured logs, correlation id, latency metrics.
|
||||
|
||||
Deliverable: Production readiness checklist.
|
||||
|
||||
---
|
||||
|
||||
## 6) Transition strategy from test API to production flow
|
||||
|
||||
1. Keep `/api/pkcs11-test/*` only for non-production.
|
||||
2. In production, use the same internal services through business endpoints only.
|
||||
3. Roll out by feature flag:
|
||||
- `FesEnabled=false` initially
|
||||
- pilot sender group
|
||||
- full rollout
|
||||
|
||||
4. Keep explicit environment matrix:
|
||||
- local dev (option 3),
|
||||
- integration/staging (option 1 or 2),
|
||||
- production (option 1 or 2 only).
|
||||
|
||||
---
|
||||
|
||||
## 7) Risks and mitigations
|
||||
|
||||
- Certificate expiry risk -> proactive monitoring and renewal alerting.
|
||||
- HSM connectivity risk -> retry policy, circuit breaker, fallback queue.
|
||||
- PIN/secret exposure risk -> vault-backed secrets and rotation policy.
|
||||
- Verification failure risk -> block PDF sealing and move envelope to technical hold state.
|
||||
- Cross-host connectivity risk -> mTLS/JWT auth + timeout/retry/circuit breaker + health probe.
|
||||
|
||||
---
|
||||
|
||||
## 8) Short done checklist
|
||||
|
||||
- [ ] PKCS#11 service interface + implementation
|
||||
- [ ] Application command + pipeline behaviors
|
||||
- [ ] PDF signature embedding
|
||||
- [ ] Audit-trail generation
|
||||
- [ ] API integration + feature flag
|
||||
- [ ] Postman/Newman regression suite
|
||||
- [ ] Operational runbook and alerting rules
|
||||
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
84
EnvelopeGenerator.Infrastructure.Crypt/resources/ticket.md
Normal file
84
EnvelopeGenerator.Infrastructure.Crypt/resources/ticket.md
Normal file
@@ -0,0 +1,84 @@
|
||||
10/6/26, 10:46 PM
|
||||
|
||||
Test SoftHSM : SWINFRA-54
|
||||
|
||||
Projects / Infrastruktur Software / Issues /
|
||||
|
||||
Enter search…
|
||||
|
||||
SWINFRA-54
|
||||
|
||||
All…
|
||||
|
||||
Created by Marvin Kamm 3 months ago
|
||||
|
||||
Visible to issue readers 1
|
||||
|
||||
Updated by Matthias Dewald about 1 month ago
|
||||
|
||||
# **Test SoftHSM**
|
||||
|
||||
Project Priorität Status **SWI** Infrastruktur Softwa H Hoch O Offen re… Bearbeiter Fälligkeit Projekteinfluss MD _ ? ? Matthias Dewald Kein fälligkeit Kein projektbezug Boards No visible boards
|
||||
|
||||
Bitte eine Test-VM mit der Software SoftHSM bereitstellen.
|
||||
|
||||
Laut Info von @Henning Emrich soll die Installation unter Debian Linux deutlich einfacher sein, als unter Windows. Bitte prüfen.
|
||||
|
||||
@Hakan Tek & @OlgunR
|
||||
|
||||
Sollen bitte die API Ansteuerung testen
|
||||
|
||||
FYI
|
||||
|
||||
@Marlon Schreiber @Jan-Ulrich Hoss @Henning Emrich @Hakan Tek @OlgunR
|
||||
|
||||
**Attachments** 1
|
||||
|
||||
Files Attached to Comments
|
||||
|
||||
**PDF**
|
||||
|
||||
Ablauf SignFlow Un ternehme… 83 kB
|
||||
|
||||
MD _ **Matthias Dewald** Commented 3 months ago Ist für Dienstag 30.6 10:00 Uhr Eingeplant
|
||||
|
||||
HT **Hakan Tek** Commented 3 months ago
|
||||
|
||||
Für die Integration auf der .NET-Seite können wir die Pkcs11Interop-Bibliothek und die entsprechende DevExpress-Infrastruktur verwenden. @OlgunR, falls du noch weitere Vorschläge hast, füge sie bitte hinzu. @Marvin Kamm, @Henning Emrich, @Matthias Dewald, um mit den Tests beginnen zu können, benötigen wir die entsprechenden Verbindungsdaten für SoftHSM (Slot-ID, PIN, Bibliothekspfad usw.).
|
||||
|
||||
FYI
|
||||
|
||||
@Marlon Schreiber @Jan-Ulrich Hoss
|
||||
|
||||
bugtracker.dd:8080/issue/SWINFRA-54/Test-SoftHSM
|
||||
|
||||
1/2
|
||||
|
||||
10/6/26, 10:46 PM
|
||||
|
||||
Test SoftHSM : SWINFRA-54
|
||||
|
||||
MD
|
||||
|
||||
## **Matthias Dewald** Commented 3 months ago
|
||||
|
||||
Hallo zusammen, SoftHSM ist auf der 172.24.12.64 installiert:
|
||||
|
||||
Der Proxy läuft auf Port 5657 und erwartet Verbindungen über die Proxy-Bibliothek (libpkcs11-proxy.so)
|
||||
|
||||
@Hakan Tek Bitte testen
|
||||
|
||||
MD
|
||||
|
||||
## **Matthias Dewald** Commented about 1 month ago
|
||||
|
||||
Ablauf SignFlow Unternehmenszertifikat.drawio.pdf
|
||||
|
||||
**PDF**
|
||||
|
||||
Ablauf SignFlow Un ternehme… 83 kB
|
||||
|
||||
bugtracker.dd:8080/issue/SWINFRA-54/Test-SoftHSM
|
||||
|
||||
2/2
|
||||
|
||||
18
EnvelopeGenerator.Infrastructure.Doc/DependencyInjection.cs
Normal file
18
EnvelopeGenerator.Infrastructure.Doc/DependencyInjection.cs
Normal file
@@ -0,0 +1,18 @@
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Doc;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddDocInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
services.AddScoped<IPdfPageImageRenderer, PdfPageImageRenderer>();
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DevExpress.Document.Processor" Version="25.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
57
EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs
Normal file
57
EnvelopeGenerator.Infrastructure.Doc/PdfPageImageRenderer.cs
Normal file
@@ -0,0 +1,57 @@
|
||||
using DevExpress.Drawing;
|
||||
using DevExpress.Pdf;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
|
||||
namespace EnvelopeGenerator.Infrastructure.Doc;
|
||||
|
||||
public class PdfPageImageRenderer : IPdfPageImageRenderer
|
||||
{
|
||||
private const float RenderDpi = 110f;
|
||||
|
||||
public Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pdfBytes);
|
||||
|
||||
if (pdfBytes.Length == 0)
|
||||
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(Array.Empty<PdfPageImageDto>());
|
||||
|
||||
using var stream = new MemoryStream(pdfBytes, writable: false);
|
||||
using var processor = new PdfDocumentProcessor();
|
||||
processor.LoadDocument(stream);
|
||||
|
||||
var pages = processor.Document.Pages;
|
||||
if (pages.Count == 0)
|
||||
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(Array.Empty<PdfPageImageDto>());
|
||||
|
||||
var renderOptions = PdfPageRenderingParameters.CreateWithResolution(RenderDpi);
|
||||
var results = new List<PdfPageImageDto>(pages.Count);
|
||||
|
||||
for (var i = 0; i < pages.Count; i++)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var pageNumber = i + 1;
|
||||
var page = pages[i];
|
||||
var cropBox = page.CropBox;
|
||||
|
||||
using DXBitmap bitmap = processor.CreateDXBitmap(pageNumber, renderOptions);
|
||||
using var imageStream = new MemoryStream();
|
||||
bitmap.Save(imageStream, DXImageFormat.Png);
|
||||
|
||||
var dataUrl = $"data:image/png;base64,{Convert.ToBase64String(imageStream.ToArray())}";
|
||||
|
||||
results.Add(new PdfPageImageDto
|
||||
{
|
||||
PageNumber = pageNumber,
|
||||
WidthPt = cropBox.Width,
|
||||
HeightPt = cropBox.Height,
|
||||
WidthPx = bitmap.Width,
|
||||
HeightPx = bitmap.Height,
|
||||
ImageDataUrl = dataUrl
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(results);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
|
||||
|
||||
public sealed record SenderPdfFieldMoveRequest(
|
||||
int Index,
|
||||
int Page,
|
||||
double XPt,
|
||||
double YPt);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.PdfEditor;
|
||||
|
||||
public sealed record SenderPdfFieldOverlay(
|
||||
int Index,
|
||||
int Page,
|
||||
double XPt,
|
||||
double YPt,
|
||||
string ReceiverName,
|
||||
string Color);
|
||||
@@ -0,0 +1,458 @@
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Application.Common.Dto
|
||||
@using System.Globalization
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.Extensions.Localization
|
||||
@inject IStringLocalizer<Resource> UiLocalizer
|
||||
@inject IJSRuntime JSRuntime
|
||||
|
||||
<div class="sender-image-editor-shell">
|
||||
@if (PageCount > 0)
|
||||
{
|
||||
<div class="sender-image-editor__toolbar-host">
|
||||
<SenderPdfImageEditorToolbar CurrentPage="@_currentPage"
|
||||
TotalPages="@PageCount"
|
||||
ZoomPercent="@_zoomPercent"
|
||||
MinZoomPercent="@MinZoomPercent"
|
||||
MaxZoomPercent="@MaxZoomPercent"
|
||||
ZoomStepPercent="@ZoomStepPercent"
|
||||
GoFirstPage="OnGoFirstPageAsync"
|
||||
GoPreviousPage="OnGoPreviousPageAsync"
|
||||
GoNextPage="OnGoNextPageAsync"
|
||||
GoLastPage="OnGoLastPageAsync"
|
||||
GoToPage="OnGoToPageAsync"
|
||||
ZoomOut="OnZoomOutAsync"
|
||||
ZoomIn="OnZoomInAsync"
|
||||
SetZoomPercent="OnSetZoomPercentAsync"
|
||||
CanDownloadPdf="@CanDownloadPdf"
|
||||
CanPrintPdf="@CanPrintPdf"
|
||||
IsDownloading="@_isDownloading"
|
||||
IsPrinting="@_isPrinting"
|
||||
DownloadPdf="OnDownloadPdfAsync"
|
||||
PrintPdf="OnPrintPdfAsync" />
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="pdf-editor-wrapper sender-image-editor sender-editor-pdf-viewer"
|
||||
style="cursor: @(IsPlacementMode ? "crosshair" : (_isDragging ? "grabbing" : "default"));"
|
||||
tabindex="0"
|
||||
@onclick="HandleClickAsync"
|
||||
@onwheel="OnEditorWheelAsync"
|
||||
@onwheel:preventDefault="true"
|
||||
@onscroll="OnEditorScrollAsync"
|
||||
@onmousemove="OnEditorMouseMoveAsync"
|
||||
@onmouseup="OnEditorMouseUp"
|
||||
@onmouseleave="OnEditorMouseUp">
|
||||
@if (PageCount <= 0)
|
||||
{
|
||||
<div class="sender-image-editor__empty">@L("SenderEditor_NoPagesToDisplay", "Keine Seiten zur Anzeige verfügbar.")</div>
|
||||
}
|
||||
else if (Pages.Count == 0 && !string.IsNullOrWhiteSpace(PdfDataUrl))
|
||||
{
|
||||
<div class="sender-image-editor__native-preview" style="@GetZoomStyle()">
|
||||
<iframe class="sender-image-editor__native-frame"
|
||||
src="@PdfDataUrl"
|
||||
title="PDF Preview"></iframe>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="sender-image-editor__pages" style="@GetZoomStyle()">
|
||||
@for (var pageIndex = 0; pageIndex < PageCount; pageIndex++)
|
||||
{
|
||||
var pagePreview = pageIndex < Pages.Count ? Pages[pageIndex] : null;
|
||||
var pageNumber = pagePreview?.PageNumber ?? (pageIndex + 1);
|
||||
var widthPt = pagePreview?.WidthPt ?? 595.0;
|
||||
var heightPt = pagePreview?.HeightPt ?? 842.0;
|
||||
|
||||
<div class="sender-image-editor__page-block"
|
||||
data-editor-page="@pageNumber"
|
||||
data-page-width-pt="@widthPt"
|
||||
data-page-height-pt="@heightPt">
|
||||
<div class="sender-image-editor__page-canvas"
|
||||
data-editor-page-canvas="@pageNumber"
|
||||
style="@GetPageCanvasStyle(widthPt, heightPt)">
|
||||
@if (!string.IsNullOrWhiteSpace(pagePreview?.ImageDataUrl))
|
||||
{
|
||||
<img class="sender-image-editor__page-image"
|
||||
src="@(pagePreview!.ImageDataUrl)"
|
||||
alt="PDF Page @pageNumber"
|
||||
draggable="false" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="sender-image-editor__page-placeholder">@L("SenderEditor_PageLabel", "Seite") @pageNumber</div>
|
||||
}
|
||||
|
||||
@foreach (var field in Fields.Where(f => f.Page == pageNumber))
|
||||
{
|
||||
<div class="sender-image-editor__field"
|
||||
style="@GetFieldStyle(field, widthPt, heightPt)"
|
||||
title="@field.ReceiverName"
|
||||
@onmousedown="args => OnFieldMouseDownAsync(args, field)"
|
||||
@onmousedown:preventDefault="true"
|
||||
@onclick:stopPropagation="true">
|
||||
<span class="sender-image-editor__field-label">@field.ReceiverName</span>
|
||||
<button type="button"
|
||||
class="sender-image-editor__field-remove"
|
||||
title='@L("Delete", "Löschen")'
|
||||
draggable="false"
|
||||
@onclick:stopPropagation="true"
|
||||
@onclick="() => RemoveField.InvokeAsync(field.Index)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M4 4L12 12M12 4L4 12" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public bool IsPlacementMode { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int PageCount { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<PdfPageImageDto> Pages { get; set; } = Array.Empty<PdfPageImageDto>();
|
||||
|
||||
[Parameter]
|
||||
public string? PdfDataUrl { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string? PdfFileName { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public IReadOnlyList<SenderPdfFieldOverlay> Fields { get; set; } = Array.Empty<SenderPdfFieldOverlay>();
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<MouseEventArgs> EditorClick { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> RemoveField { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<SenderPdfFieldMoveRequest> MoveField { get; set; }
|
||||
|
||||
private int? _draggedFieldIndex;
|
||||
private bool _isDragging;
|
||||
private double _grabOffsetXPt;
|
||||
private double _grabOffsetYPt;
|
||||
private int _currentPage = 1;
|
||||
private int _zoomPercent = 112;
|
||||
private bool _zoomOverrideInitialized;
|
||||
private bool _isDownloading;
|
||||
private bool _isPrinting;
|
||||
|
||||
private const int MinZoomPercent = 50;
|
||||
private const int MaxZoomPercent = 300;
|
||||
private const int ZoomStepPercent = 5;
|
||||
private bool CanDownloadPdf => !string.IsNullOrWhiteSpace(PdfDataUrl);
|
||||
private bool CanPrintPdf => !string.IsNullOrWhiteSpace(PdfDataUrl);
|
||||
|
||||
private static string GetFieldStyle(SenderPdfFieldOverlay field, double pageWidthPt, double pageHeightPt)
|
||||
{
|
||||
var leftPercent = (field.XPt / pageWidthPt) * 100.0;
|
||||
var topPercent = (field.YPt / pageHeightPt) * 100.0;
|
||||
var widthPercent = (127.44 / pageWidthPt) * 100.0;
|
||||
var heightPercent = (141.12 / pageHeightPt) * 100.0;
|
||||
|
||||
var left = leftPercent.ToString("F4", CultureInfo.InvariantCulture);
|
||||
var top = topPercent.ToString("F4", CultureInfo.InvariantCulture);
|
||||
var width = widthPercent.ToString("F4", CultureInfo.InvariantCulture);
|
||||
var height = heightPercent.ToString("F4", CultureInfo.InvariantCulture);
|
||||
|
||||
return string.Join(' ',
|
||||
"position:absolute;",
|
||||
$"left:{left}%;",
|
||||
$"top:{top}%;",
|
||||
$"width:{width}%;",
|
||||
$"height:{height}%;",
|
||||
"border:2px solid;",
|
||||
"border-radius:8px;",
|
||||
"display:flex;",
|
||||
"align-items:flex-start;",
|
||||
"justify-content:space-between;",
|
||||
"gap:0.35rem;",
|
||||
"padding:0.2rem 0.3rem;",
|
||||
"overflow:hidden;",
|
||||
"z-index:30;",
|
||||
$"border-color:{field.Color};",
|
||||
$"background-color:{field.Color}22;");
|
||||
}
|
||||
|
||||
private string GetPageCanvasStyle(double pageWidthPt, double pageHeightPt)
|
||||
{
|
||||
var ratio = pageHeightPt <= 0 ? 595.0 / 842.0 : pageWidthPt / pageHeightPt;
|
||||
var ratioText = ratio.ToString("F6", CultureInfo.InvariantCulture);
|
||||
var cursor = _isDragging
|
||||
? "grabbing"
|
||||
: (IsPlacementMode ? "crosshair" : "default");
|
||||
return $"aspect-ratio:{ratioText};cursor:{cursor};";
|
||||
}
|
||||
|
||||
private string GetZoomStyle()
|
||||
{
|
||||
var factor = (_zoomPercent / 100.0).ToString("F4", CultureInfo.InvariantCulture);
|
||||
return $"zoom:{factor};";
|
||||
}
|
||||
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (PageCount <= 0)
|
||||
{
|
||||
_currentPage = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
_currentPage = Math.Max(1, Math.Min(_currentPage, PageCount));
|
||||
}
|
||||
|
||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||
{
|
||||
if (_zoomOverrideInitialized)
|
||||
return;
|
||||
|
||||
await JSRuntime.InvokeVoidAsync(
|
||||
"envelopeEditor.ensureZoomOverride",
|
||||
"sender-editor-pdf-viewer");
|
||||
|
||||
_zoomOverrideInitialized = true;
|
||||
}
|
||||
|
||||
private async Task HandleClickAsync(MouseEventArgs args)
|
||||
{
|
||||
if (EditorClick.HasDelegate)
|
||||
await EditorClick.InvokeAsync(args);
|
||||
}
|
||||
|
||||
private async Task OnFieldMouseDownAsync(MouseEventArgs args, SenderPdfFieldOverlay field)
|
||||
{
|
||||
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
|
||||
"envelopeEditor.getClickCoordsOnPdfPage",
|
||||
"sender-editor-pdf-viewer",
|
||||
args.ClientX,
|
||||
args.ClientY);
|
||||
|
||||
if (coords is null)
|
||||
return;
|
||||
|
||||
var pageNumber = coords.PageNumber;
|
||||
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
|
||||
var pointerXPt = coords.NormX * pageWidthPt;
|
||||
var pointerYPt = coords.NormY * pageHeightPt;
|
||||
|
||||
_draggedFieldIndex = field.Index;
|
||||
_isDragging = true;
|
||||
_grabOffsetXPt = pointerXPt - field.XPt;
|
||||
_grabOffsetYPt = pointerYPt - field.YPt;
|
||||
}
|
||||
|
||||
private async Task OnEditorMouseMoveAsync(MouseEventArgs args)
|
||||
{
|
||||
if (_draggedFieldIndex is null || !MoveField.HasDelegate)
|
||||
return;
|
||||
|
||||
var coords = await JSRuntime.InvokeAsync<EditorCoords?>(
|
||||
"envelopeEditor.getClickCoordsOnPdfPage",
|
||||
"sender-editor-pdf-viewer",
|
||||
args.ClientX,
|
||||
args.ClientY);
|
||||
|
||||
if (coords is null)
|
||||
return;
|
||||
|
||||
var pageNumber = coords.PageNumber;
|
||||
var (pageWidthPt, pageHeightPt) = GetPageSize(pageNumber);
|
||||
|
||||
const double sigWidthPt = 1.77 * 72;
|
||||
const double sigHeightPt = 1.96 * 72;
|
||||
|
||||
var xPt = (coords.NormX * pageWidthPt) - _grabOffsetXPt;
|
||||
var yPt = (coords.NormY * pageHeightPt) - _grabOffsetYPt;
|
||||
xPt = Math.Max(0, Math.Min(xPt, pageWidthPt - sigWidthPt));
|
||||
yPt = Math.Max(0, Math.Min(yPt, pageHeightPt - sigHeightPt));
|
||||
|
||||
await MoveField.InvokeAsync(new SenderPdfFieldMoveRequest(_draggedFieldIndex.Value, pageNumber, xPt, yPt));
|
||||
}
|
||||
|
||||
private async Task OnGoFirstPageAsync()
|
||||
{
|
||||
await NavigateToPageAsync(1);
|
||||
}
|
||||
|
||||
private async Task OnGoPreviousPageAsync()
|
||||
{
|
||||
await NavigateToPageAsync(_currentPage - 1);
|
||||
}
|
||||
|
||||
private async Task OnGoNextPageAsync()
|
||||
{
|
||||
await NavigateToPageAsync(_currentPage + 1);
|
||||
}
|
||||
|
||||
private async Task OnGoLastPageAsync()
|
||||
{
|
||||
await NavigateToPageAsync(PageCount);
|
||||
}
|
||||
|
||||
private async Task OnGoToPageAsync(int page)
|
||||
{
|
||||
await NavigateToPageAsync(page);
|
||||
}
|
||||
|
||||
private async Task NavigateToPageAsync(int targetPage)
|
||||
{
|
||||
if (PageCount <= 0)
|
||||
return;
|
||||
|
||||
var page = Math.Max(1, Math.Min(targetPage, PageCount));
|
||||
_currentPage = page;
|
||||
|
||||
await JSRuntime.InvokeVoidAsync(
|
||||
"envelopeEditor.scrollToPdfPage",
|
||||
"sender-editor-pdf-viewer",
|
||||
page);
|
||||
}
|
||||
|
||||
private async Task OnEditorScrollAsync(EventArgs _)
|
||||
{
|
||||
if (PageCount <= 0)
|
||||
return;
|
||||
|
||||
var visiblePage = await JSRuntime.InvokeAsync<int?>(
|
||||
"envelopeEditor.getVisiblePdfPageNumber",
|
||||
"sender-editor-pdf-viewer");
|
||||
|
||||
if (!visiblePage.HasValue)
|
||||
return;
|
||||
|
||||
var clamped = Math.Max(1, Math.Min(visiblePage.Value, PageCount));
|
||||
if (clamped == _currentPage)
|
||||
return;
|
||||
|
||||
_currentPage = clamped;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task OnZoomOutAsync()
|
||||
{
|
||||
SetZoomPercent(_zoomPercent - ZoomStepPercent);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task OnZoomInAsync()
|
||||
{
|
||||
SetZoomPercent(_zoomPercent + ZoomStepPercent);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task OnSetZoomPercentAsync(int zoomPercent)
|
||||
{
|
||||
SetZoomPercent(zoomPercent);
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
|
||||
private async Task OnEditorWheelAsync(WheelEventArgs args)
|
||||
{
|
||||
if (args.CtrlKey)
|
||||
{
|
||||
if (args.DeltaY < 0)
|
||||
await OnZoomInAsync();
|
||||
else if (args.DeltaY > 0)
|
||||
await OnZoomOutAsync();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
await JSRuntime.InvokeVoidAsync(
|
||||
"envelopeEditor.scrollPdfViewerByDelta",
|
||||
"sender-editor-pdf-viewer",
|
||||
args.DeltaY);
|
||||
}
|
||||
|
||||
private void SetZoomPercent(int value)
|
||||
{
|
||||
_zoomPercent = Math.Max(MinZoomPercent, Math.Min(value, MaxZoomPercent));
|
||||
}
|
||||
|
||||
private async Task OnDownloadPdfAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PdfDataUrl) || _isDownloading || _isPrinting)
|
||||
return;
|
||||
|
||||
_isDownloading = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
try
|
||||
{
|
||||
var fallbackName = string.IsNullOrWhiteSpace(PdfFileName)
|
||||
? "document.pdf"
|
||||
: PdfFileName;
|
||||
|
||||
var fileName = fallbackName.EndsWith(".pdf", StringComparison.OrdinalIgnoreCase)
|
||||
? fallbackName
|
||||
: $"{fallbackName}.pdf";
|
||||
|
||||
await JSRuntime.InvokeVoidAsync(
|
||||
"envelopeEditor.downloadDataUrlAsFile",
|
||||
PdfDataUrl,
|
||||
fileName,
|
||||
"application/pdf");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isDownloading = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnPrintPdfAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(PdfDataUrl) || _isPrinting || _isDownloading)
|
||||
return;
|
||||
|
||||
_isPrinting = true;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
try
|
||||
{
|
||||
await JSRuntime.InvokeVoidAsync(
|
||||
"envelopeEditor.printDataUrlPdf",
|
||||
PdfDataUrl,
|
||||
"application/pdf");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_isPrinting = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnEditorMouseUp(MouseEventArgs args)
|
||||
{
|
||||
_draggedFieldIndex = null;
|
||||
_isDragging = false;
|
||||
}
|
||||
|
||||
private (double WidthPt, double HeightPt) GetPageSize(int pageNumber)
|
||||
{
|
||||
var page = Pages.FirstOrDefault(p => p.PageNumber == pageNumber);
|
||||
return page is null ? (595.0, 842.0) : (page.WidthPt, page.HeightPt);
|
||||
}
|
||||
|
||||
private sealed record EditorCoords(double NormX, double NormY, int PageIndex, int PageNumber);
|
||||
|
||||
private string L(string key, string fallback)
|
||||
{
|
||||
var value = UiLocalizer[key];
|
||||
return value.ResourceNotFound ? fallback : value.Value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
<div class="sender-image-toolbar" @onclick:stopPropagation="true">
|
||||
<div class="sender-image-toolbar__group sender-image-toolbar__group--nav" role="group" aria-label="Page navigation">
|
||||
<button type="button" class="sender-image-toolbar__icon-btn" title="First page" disabled="@(!CanGoPrevious)" @onclick="GoToFirstAsync">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M8 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M16 7L11 12L16 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button type="button" class="sender-image-toolbar__icon-btn" title="Previous page" disabled="@(!CanGoPrevious)" @onclick="GoToPreviousAsync">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M15 7L10 12L15 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="sender-image-toolbar__value-box">
|
||||
<input type="number"
|
||||
min="1"
|
||||
max="@TotalPagesDisplay"
|
||||
value="@CurrentPageDisplay"
|
||||
class="sender-image-toolbar__page-input"
|
||||
@onchange="OnPageInputChangedAsync"
|
||||
disabled="@(TotalPagesDisplay <= 0)" />
|
||||
<span class="sender-image-toolbar__page-total">of @TotalPagesDisplay</span>
|
||||
</div>
|
||||
|
||||
<div class="sender-image-toolbar__select-wrap" title="Page list">
|
||||
<button type="button" class="sender-image-toolbar__icon-btn sender-image-toolbar__icon-btn--caret" disabled="@(TotalPagesDisplay <= 0)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<select class="sender-image-toolbar__page-select"
|
||||
aria-label="Select page"
|
||||
disabled="@(TotalPagesDisplay <= 0)"
|
||||
value="@CurrentPageDisplay"
|
||||
@onchange="OnPageSelectChangedAsync">
|
||||
@for (var pageNumber = 1; pageNumber <= TotalPagesDisplay; pageNumber++)
|
||||
{
|
||||
<option value="@pageNumber">@pageNumber</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<button type="button" class="sender-image-toolbar__icon-btn" title="Next page" disabled="@(!CanGoNext)" @onclick="GoToNextAsync">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M9 7L14 12L9 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button type="button" class="sender-image-toolbar__icon-btn" title="Last page" disabled="@(!CanGoNext)" @onclick="GoToLastAsync">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M16 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M8 7L13 12L8 17" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sender-image-toolbar__group sender-image-toolbar__group--zoom" role="group" aria-label="Zoom controls">
|
||||
<button type="button" class="sender-image-toolbar__icon-btn" title="Zoom out" disabled="@(!CanZoomOut)" @onclick="ZoomOutAsync">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M6 12H18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="sender-image-toolbar__value-box" aria-hidden="true">
|
||||
<span>@ZoomPercentDisplay%</span>
|
||||
</div>
|
||||
|
||||
<input type="range"
|
||||
class="sender-image-toolbar__zoom-slider"
|
||||
min="@MinZoomPercent"
|
||||
max="@MaxZoomPercent"
|
||||
step="@ZoomStepPercent"
|
||||
value="@ZoomPercentDisplay"
|
||||
@oninput="OnZoomSliderInputAsync"
|
||||
aria-label="Zoom slider" />
|
||||
|
||||
<button type="button" class="sender-image-toolbar__icon-btn" title="Zoom in" disabled="@(!CanZoomIn)" @onclick="ZoomInAsync">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M12 6V18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M6 12H18" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="sender-image-toolbar__group sender-image-toolbar__group--actions" role="group" aria-label="Document actions">
|
||||
<button type="button"
|
||||
class="sender-image-toolbar__icon-btn @(IsPrinting ? "sender-image-toolbar__icon-btn--busy" : string.Empty)"
|
||||
title="@(IsPrinting ? "Preparing print..." : "Print")"
|
||||
disabled="@(!CanPrintPdf || IsPrinting || IsDownloading)"
|
||||
@onclick="PrintPdfAsync">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M7 9V4H17V9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<rect x="5" y="9" width="14" height="8" rx="2" stroke="currentColor" stroke-width="2" />
|
||||
<path d="M7 14H17V20H7V14Z" stroke="currentColor" stroke-width="2" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button type="button"
|
||||
class="sender-image-toolbar__icon-btn @(IsDownloading ? "sender-image-toolbar__icon-btn--busy" : string.Empty)"
|
||||
title="@(IsDownloading ? "Preparing download..." : "Download PDF")"
|
||||
disabled="@(!CanDownloadPdf || IsDownloading || IsPrinting)"
|
||||
@onclick="DownloadPdfAsync">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M12 4V14" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
<path d="M8 10L12 14L16 10" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M5 18H19" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public int CurrentPage { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int TotalPages { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public int ZoomPercent { get; set; } = 112;
|
||||
|
||||
[Parameter]
|
||||
public int MinZoomPercent { get; set; } = 50;
|
||||
|
||||
[Parameter]
|
||||
public int MaxZoomPercent { get; set; } = 300;
|
||||
|
||||
[Parameter]
|
||||
public int ZoomStepPercent { get; set; } = 5;
|
||||
|
||||
[Parameter]
|
||||
public EventCallback GoFirstPage { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback GoPreviousPage { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback GoNextPage { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback GoLastPage { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> GoToPage { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback ZoomOut { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback ZoomIn { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<int> SetZoomPercent { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool CanDownloadPdf { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool CanPrintPdf { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsDownloading { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool IsPrinting { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback DownloadPdf { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback PrintPdf { get; set; }
|
||||
|
||||
private int CurrentPageDisplay => CurrentPage > 0 ? CurrentPage : 0;
|
||||
private int TotalPagesDisplay => TotalPages > 0 ? TotalPages : 0;
|
||||
private int ZoomPercentDisplay => ZoomPercent > 0 ? ZoomPercent : 100;
|
||||
private bool CanGoPrevious => CurrentPageDisplay > 1;
|
||||
private bool CanGoNext => TotalPagesDisplay > 0 && CurrentPageDisplay < TotalPagesDisplay;
|
||||
private bool CanZoomOut => ZoomPercentDisplay > MinZoomPercent;
|
||||
private bool CanZoomIn => ZoomPercentDisplay < MaxZoomPercent;
|
||||
|
||||
private async Task GoToFirstAsync()
|
||||
{
|
||||
if (GoFirstPage.HasDelegate)
|
||||
await GoFirstPage.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task GoToPreviousAsync()
|
||||
{
|
||||
if (GoPreviousPage.HasDelegate)
|
||||
await GoPreviousPage.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task GoToNextAsync()
|
||||
{
|
||||
if (GoNextPage.HasDelegate)
|
||||
await GoNextPage.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task GoToLastAsync()
|
||||
{
|
||||
if (GoLastPage.HasDelegate)
|
||||
await GoLastPage.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnPageInputChangedAsync(ChangeEventArgs args)
|
||||
{
|
||||
if (!GoToPage.HasDelegate)
|
||||
return;
|
||||
|
||||
if (!int.TryParse(args.Value?.ToString(), out var page))
|
||||
return;
|
||||
|
||||
page = Math.Max(1, Math.Min(page, Math.Max(1, TotalPagesDisplay)));
|
||||
await GoToPage.InvokeAsync(page);
|
||||
}
|
||||
|
||||
private async Task OnPageSelectChangedAsync(ChangeEventArgs args)
|
||||
{
|
||||
if (!GoToPage.HasDelegate)
|
||||
return;
|
||||
|
||||
if (!int.TryParse(args.Value?.ToString(), out var page))
|
||||
return;
|
||||
|
||||
page = Math.Max(1, Math.Min(page, Math.Max(1, TotalPagesDisplay)));
|
||||
await GoToPage.InvokeAsync(page);
|
||||
}
|
||||
|
||||
private async Task ZoomOutAsync()
|
||||
{
|
||||
if (ZoomOut.HasDelegate)
|
||||
await ZoomOut.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task ZoomInAsync()
|
||||
{
|
||||
if (ZoomIn.HasDelegate)
|
||||
await ZoomIn.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task OnZoomSliderInputAsync(ChangeEventArgs args)
|
||||
{
|
||||
if (!SetZoomPercent.HasDelegate)
|
||||
return;
|
||||
|
||||
if (!int.TryParse(args.Value?.ToString(), out var zoomPercent))
|
||||
return;
|
||||
|
||||
zoomPercent = Math.Max(MinZoomPercent, Math.Min(zoomPercent, MaxZoomPercent));
|
||||
await SetZoomPercent.InvokeAsync(zoomPercent);
|
||||
}
|
||||
|
||||
private async Task DownloadPdfAsync()
|
||||
{
|
||||
if (DownloadPdf.HasDelegate)
|
||||
await DownloadPdf.InvokeAsync();
|
||||
}
|
||||
|
||||
private async Task PrintPdfAsync()
|
||||
{
|
||||
if (PrintPdf.HasDelegate)
|
||||
await PrintPdf.InvokeAsync();
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,22 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.Sender;
|
||||
|
||||
public sealed record CreateEnvelopeWizardResult(
|
||||
string Title,
|
||||
string Message,
|
||||
int? EnvelopeTypeId,
|
||||
string Language,
|
||||
bool UseAccessCode,
|
||||
bool TFAEnabled,
|
||||
int CertificationType,
|
||||
int FinalEmailToCreator,
|
||||
int FinalEmailToReceivers,
|
||||
bool SendReminderEmails,
|
||||
int FirstReminderDays,
|
||||
int ReminderIntervalDays,
|
||||
int ExpiresWhenDays,
|
||||
int ExpiresWarningWhenDays,
|
||||
IReadOnlyList<CreateEnvelopeWizardReceiver> Receivers,
|
||||
byte[]? DocumentBytes,
|
||||
string? DocumentFileName);
|
||||
|
||||
public sealed record CreateEnvelopeWizardReceiver(string FullName, string EmailAddress);
|
||||
@@ -0,0 +1,104 @@
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
|
||||
<div class="sender-pdf-dropzone @DropZoneClass">
|
||||
<div class="sender-pdf-dropzone__content">
|
||||
<div class="sender-pdf-dropzone__title">@CurrentActionText</div>
|
||||
@if (!string.IsNullOrWhiteSpace(DragDropHintText))
|
||||
{
|
||||
<div class="sender-pdf-dropzone__hint">@DragDropHintText</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<InputFile OnChange="OnFileSelectedAsync"
|
||||
accept="@Accept"
|
||||
disabled="@Disabled"
|
||||
@ondragenter="OnDragEnter"
|
||||
@ondragleave="OnDragLeave"
|
||||
@ondragover="OnDragOver"
|
||||
@ondrop="OnDrop"
|
||||
class="sender-pdf-dropzone__input" />
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public bool Disabled { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public bool HasDocument { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public string Accept { get; set; } = ".pdf";
|
||||
|
||||
[Parameter]
|
||||
public string UploadText { get; set; } = "PDF hochladen";
|
||||
|
||||
[Parameter]
|
||||
public string ReplaceText { get; set; } = "PDF ersetzen";
|
||||
|
||||
[Parameter]
|
||||
public string DragDropHintText { get; set; } = "Datei hierhin ziehen oder klicken";
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<InputFileChangeEventArgs> FileSelected { get; set; }
|
||||
|
||||
private int _dragDepth;
|
||||
private bool _isDragActive;
|
||||
|
||||
private string CurrentActionText => HasDocument ? ReplaceText : UploadText;
|
||||
|
||||
private string DropZoneClass
|
||||
{
|
||||
get
|
||||
{
|
||||
if (Disabled)
|
||||
return "sender-pdf-dropzone--disabled";
|
||||
|
||||
return _isDragActive ? "sender-pdf-dropzone--drag-active" : string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDragEnter(DragEventArgs _)
|
||||
{
|
||||
if (Disabled)
|
||||
return;
|
||||
|
||||
_dragDepth++;
|
||||
_isDragActive = true;
|
||||
}
|
||||
|
||||
private void OnDragLeave(DragEventArgs _)
|
||||
{
|
||||
if (Disabled)
|
||||
return;
|
||||
|
||||
_dragDepth = Math.Max(0, _dragDepth - 1);
|
||||
if (_dragDepth == 0)
|
||||
_isDragActive = false;
|
||||
}
|
||||
|
||||
private void OnDragOver(DragEventArgs _)
|
||||
{
|
||||
if (Disabled)
|
||||
return;
|
||||
|
||||
_isDragActive = true;
|
||||
}
|
||||
|
||||
private void OnDrop(DragEventArgs _)
|
||||
{
|
||||
_dragDepth = 0;
|
||||
_isDragActive = false;
|
||||
}
|
||||
|
||||
private async Task OnFileSelectedAsync(InputFileChangeEventArgs args)
|
||||
{
|
||||
_dragDepth = 0;
|
||||
_isDragActive = false;
|
||||
|
||||
if (Disabled)
|
||||
return;
|
||||
|
||||
if (FileSelected.HasDelegate)
|
||||
await FileSelected.InvokeAsync(args);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
namespace EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter;
|
||||
|
||||
public readonly record struct ReceiverFilterCriteria
|
||||
{
|
||||
public readonly string FullName;
|
||||
public readonly string EmailAddress;
|
||||
|
||||
public ReceiverFilterCriteria(string? fullName = "", string? emailAddress = "")
|
||||
{
|
||||
FullName = Normalize(fullName);
|
||||
EmailAddress = Normalize(emailAddress);
|
||||
}
|
||||
|
||||
public readonly bool Matches(ReceiverFilterCriteria criteria)
|
||||
=> FullName.Contains(criteria.FullName, StringComparison.Ordinal)
|
||||
&& EmailAddress.Contains(criteria.EmailAddress, StringComparison.Ordinal);
|
||||
|
||||
private static string Normalize(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
return string.Empty;
|
||||
|
||||
return string.Concat(text.Where(c => !char.IsWhiteSpace(c))).ToLowerInvariant();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Components.Sender.ReceiverSuggestionFilter;
|
||||
|
||||
public class ReceiverSuggestionFilterHandler(IEnumerable<RecentReceiverSuggestionDto> suggestions)
|
||||
{
|
||||
private readonly IReadOnlyList<Entry> _entries = suggestions
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x.FullName) && !string.IsNullOrWhiteSpace(x.EmailAddress))
|
||||
.Select(x => new Entry(new ReceiverFilterCriteria(x.FullName, x.EmailAddress), x))
|
||||
.ToList();
|
||||
|
||||
private readonly record struct Entry(ReceiverFilterCriteria Criteria, RecentReceiverSuggestionDto Suggestion);
|
||||
|
||||
public IEnumerable<RecentReceiverSuggestionDto> Filter(ReceiverFilterCriteria criteria)
|
||||
{
|
||||
return _entries
|
||||
.Where(x => x.Criteria.Matches(criteria))
|
||||
.Select(x => x.Suggestion);
|
||||
}
|
||||
|
||||
public IEnumerable<RecentReceiverSuggestionDto> Filter(string fullName, string emailAddress)
|
||||
{
|
||||
var criteria = new ReceiverFilterCriteria(fullName, emailAddress);
|
||||
return Filter(criteria);
|
||||
}
|
||||
}
|
||||
@@ -170,7 +170,7 @@
|
||||
|
||||
if (result == EnvelopeLoginResult.Success)
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
var result = await AuthService.LoginSenderAsync(Username.Trim(), Password.Trim());
|
||||
|
||||
if (result == SenderLoginResult.Success) {
|
||||
Navigation.NavigateTo("/sender", forceLoad: true);
|
||||
Navigation.NavigateTo("/sender", forceLoad: false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,25 +1,26 @@
|
||||
@page "/sender"
|
||||
@rendermode InteractiveServer
|
||||
@rendermode InteractiveWebAssembly
|
||||
@using System.Text.Json
|
||||
@using EnvelopeGenerator.Domain.Constants
|
||||
@using EnvelopeGenerator.Server.Client.Models
|
||||
@using DevExpress.Blazor
|
||||
@using DevExpress.Blazor.PdfViewer
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using EnvelopeGenerator.Application.Common.Dto
|
||||
@using EnvelopeGenerator.Application.Common.Dto.History
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Domain.Resources
|
||||
@using EnvelopeGenerator.Server.Services
|
||||
@using EnvelopeGenerator.Server.Client.Components.Sender
|
||||
@using Microsoft.Extensions.Localization
|
||||
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeService EnvelopeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.DocumentService DocumentService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.PdfRenderService PdfRenderService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.EnvelopeTypeService EnvelopeTypeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
|
||||
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
|
||||
@inject EnvelopeReceiverPageDataService ReceiverPageDataService
|
||||
@inject IEnvelopeAuthService EnvelopeAuthService
|
||||
@inject ILogger<EnvelopeSenderPage> Logger
|
||||
@inject EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService CreateEnvelopeBootstrapStorageService
|
||||
@inject ILogger<SenderPage> Logger
|
||||
@inject NavigationManager Navigation
|
||||
@inject IJSRuntime JSRuntime
|
||||
@inject AppVersionService AppVersion
|
||||
@@ -262,7 +263,7 @@
|
||||
var envelope = cellContext.DataItem as EnvelopeDto;
|
||||
if (envelope != null)
|
||||
{
|
||||
var statusInfo = GetStatusInfo(envelope.Status);
|
||||
var statusInfo = GetStatusInfo(GetEffectiveEnvelopeStatus(envelope));
|
||||
<div class="status-badge status-badge--@statusInfo.CssClass">
|
||||
<span class="status-dot status-dot--@statusInfo.DotColor"></span>
|
||||
@statusInfo.Label
|
||||
@@ -314,12 +315,20 @@
|
||||
{
|
||||
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.5rem; background: white; border-radius: 6px; border: 1px solid #e5e7eb;">
|
||||
@{
|
||||
var isRejected = IsReceiverRejected(envelope, receiver);
|
||||
var isSigned = IsReceiverSigned(envelope, receiver);
|
||||
var accessCode = receiver.AccessCode;
|
||||
var hasAccessCode = !string.IsNullOrWhiteSpace(accessCode);
|
||||
}
|
||||
<span class="receiver-badge receiver-badge--@(isSigned ? "signed" : "unsigned")" style="min-width: 100px;">
|
||||
@if (isSigned)
|
||||
<span class="receiver-badge receiver-badge--@(isRejected ? "unsigned" : (isSigned ? "signed" : "unsigned"))" style="min-width: 100px;">
|
||||
@if (isRejected)
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
|
||||
</svg>
|
||||
<span>@GetStatusLabel(EnvelopeStatus.DocumentRejected)</span>
|
||||
}
|
||||
else if (isSigned)
|
||||
{
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M13.854 3.646a.5.5 0 0 1 0 .708l-7 7a.5.5 0 0 1-.708 0l-3.5-3.5a.5.5 0 1 1 .708-.708L6.5 10.293l6.646-6.647a.5.5 0 0 1 .708 0z" />
|
||||
@@ -408,6 +417,11 @@
|
||||
|
||||
</div>
|
||||
|
||||
<CreateEnvelopeWizardPopup Visible="@_createWizardVisible"
|
||||
VisibleChanged="OnCreateWizardVisibleChangedAsync"
|
||||
EnvelopeTypes="@_envelopeTypes"
|
||||
StartEditorRequested="StartEditorFromWizardAsync" />
|
||||
|
||||
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
|
||||
<DxPopup @bind-Visible="@_previewVisible"
|
||||
HeaderText="@($"{L("Preview", "Vorschau")} — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
|
||||
@@ -459,10 +473,15 @@
|
||||
else if (_previewPdfBytes is not null && _previewViewerMounted)
|
||||
{
|
||||
<div class="sender-preview-viewer-wrap">
|
||||
<DxPdfViewer DocumentContent="@_previewPdfBytes"
|
||||
ZoomLevel="-2"
|
||||
IsSinglePagePreview="true"
|
||||
CssClass="sender-preview-pdf-viewer" />
|
||||
<SenderPdfImageEditor IsPlacementMode="false"
|
||||
PageCount="@_previewPageCount"
|
||||
Pages="@_previewPagePreviews"
|
||||
PdfDataUrl="@PreviewPdfDataUrl"
|
||||
PdfFileName="@_previewFileName"
|
||||
Fields="@_previewFieldOverlays"
|
||||
EditorClick="OnPreviewEditorClickAsync"
|
||||
RemoveField="OnPreviewRemoveFieldAsync"
|
||||
MoveField="OnPreviewMoveFieldAsync" />
|
||||
</div>
|
||||
}
|
||||
</BodyContentTemplate>
|
||||
@@ -536,7 +555,15 @@
|
||||
private bool _previewLoading = false;
|
||||
private bool _previewViewerMounted = false;
|
||||
private byte[]? _previewPdfBytes = null;
|
||||
private int _previewPageCount = 0;
|
||||
private List<PdfPageImageDto> _previewPagePreviews = [];
|
||||
private string _previewFileName = "document.pdf";
|
||||
private string? _previewErrorMessage = null;
|
||||
private readonly IReadOnlyList<SenderPdfFieldOverlay> _previewFieldOverlays = [];
|
||||
|
||||
private string? PreviewPdfDataUrl => _previewPdfBytes is { Length: > 0 }
|
||||
? $"data:application/pdf;base64,{Convert.ToBase64String(_previewPdfBytes)}"
|
||||
: null;
|
||||
|
||||
// Phase 6e (step 1): UI state only. Export logic is added in step 2.
|
||||
private string _selectedExportFormat = "xlsx";
|
||||
@@ -548,6 +575,9 @@
|
||||
private string _deleteReason = string.Empty;
|
||||
private string? _deleteErrorMessage;
|
||||
|
||||
private bool _createWizardVisible = false;
|
||||
private IReadOnlyList<EnvelopeTypeDto> _envelopeTypes = [];
|
||||
|
||||
// Stable keys used to store/restore each tab's layout in UserPreferences.GridLayouts
|
||||
private const string GridKeyActive = "sender.active-envelopes";
|
||||
private const string GridKeyCompleted = "sender.completed-envelopes";
|
||||
@@ -577,6 +607,16 @@
|
||||
_appLogo = logos.App;
|
||||
_companyLogo = logos.Company;
|
||||
|
||||
try
|
||||
{
|
||||
_envelopeTypes = (await EnvelopeTypeService.GetAsync()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to load envelope types for create wizard.");
|
||||
_envelopeTypes = [];
|
||||
}
|
||||
|
||||
await LoadEnvelopesAsync();
|
||||
}
|
||||
|
||||
@@ -590,10 +630,11 @@
|
||||
{
|
||||
_allEnvelopes = await EnvelopeService.GetAsync() ?? [];
|
||||
|
||||
// Split into active and completed based on status
|
||||
// Split into active and completed based on effective status
|
||||
// (history-aware: rejected envelopes should not stay in active tab).
|
||||
var envelopes = _allEnvelopes.ToList();
|
||||
_activeEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsActive()).ToList();
|
||||
_completedEnvelopes = envelopes.Where(e => ((EnvelopeStatus)e.Status).IsCompleted()).ToList();
|
||||
_activeEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsActive()).ToList();
|
||||
_completedEnvelopes = envelopes.Where(e => GetEffectiveEnvelopeStatus(e).IsCompleted()).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -673,7 +714,40 @@
|
||||
|
||||
void CreateEnvelope()
|
||||
{
|
||||
Navigation.NavigateTo("/sender/editor");
|
||||
_createWizardVisible = true;
|
||||
}
|
||||
|
||||
Task OnCreateWizardVisibleChangedAsync(bool visible)
|
||||
{
|
||||
_createWizardVisible = visible;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
async Task StartEditorFromWizardAsync(CreateEnvelopeWizardResult result)
|
||||
{
|
||||
var payload = new CreateEnvelopeBootstrapPayload(
|
||||
Title: result.Title,
|
||||
Message: result.Message,
|
||||
EnvelopeTypeId: result.EnvelopeTypeId,
|
||||
Language: result.Language,
|
||||
UseAccessCode: result.UseAccessCode,
|
||||
TFAEnabled: result.TFAEnabled,
|
||||
CertificationType: result.CertificationType,
|
||||
FinalEmailToCreator: result.FinalEmailToCreator,
|
||||
FinalEmailToReceivers: result.FinalEmailToReceivers,
|
||||
SendReminderEmails: result.SendReminderEmails,
|
||||
FirstReminderDays: result.FirstReminderDays,
|
||||
ReminderIntervalDays: result.ReminderIntervalDays,
|
||||
ExpiresWhenDays: result.ExpiresWhenDays,
|
||||
ExpiresWarningWhenDays: result.ExpiresWarningWhenDays,
|
||||
Receivers: result.Receivers
|
||||
.Select(x => new CreateEnvelopeBootstrapReceiver(x.FullName, x.EmailAddress))
|
||||
.ToList(),
|
||||
DocumentBytes: result.DocumentBytes,
|
||||
DocumentFileName: result.DocumentFileName);
|
||||
|
||||
var token = await CreateEnvelopeBootstrapStorageService.StoreAsync(payload);
|
||||
Navigation.NavigateTo($"/sender/editor?bootstrapToken={token}");
|
||||
}
|
||||
|
||||
void EditEnvelope()
|
||||
@@ -740,18 +814,18 @@
|
||||
await InvokeAsync(StateHasChanged);
|
||||
UserPreferencesService.InvalidateCache();
|
||||
await AuthService.LogoutSenderAsync();
|
||||
Navigation.NavigateTo("/sender/login", forceLoad: true);
|
||||
Navigation.NavigateTo("/sender/login", forceLoad: false);
|
||||
}
|
||||
|
||||
bool IsEnvelopeSent(EnvelopeDto envelope)
|
||||
{
|
||||
var status = (EnvelopeStatus)envelope.Status;
|
||||
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||
return status >= EnvelopeStatus.EnvelopeQueued;
|
||||
}
|
||||
|
||||
bool IsEnvelopeEditable(EnvelopeDto envelope)
|
||||
{
|
||||
var status = (EnvelopeStatus)envelope.Status;
|
||||
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||
return status is EnvelopeStatus.EnvelopeCreated or EnvelopeStatus.EnvelopeSaved;
|
||||
}
|
||||
|
||||
@@ -800,7 +874,7 @@
|
||||
IReadOnlyList<StatusFilterItem> GetStatusFilterItems(IEnumerable<EnvelopeDto>? source)
|
||||
{
|
||||
return (source ?? Enumerable.Empty<EnvelopeDto>())
|
||||
.Select(x => x.Status)
|
||||
.Select(GetEffectiveEnvelopeStatus)
|
||||
.Distinct()
|
||||
.OrderBy(x => x)
|
||||
.Select(x => new StatusFilterItem(x, GetStatusLabel(x)))
|
||||
@@ -818,7 +892,7 @@
|
||||
|
||||
bool IsReceiverSigned(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
|
||||
{
|
||||
var status = (EnvelopeStatus)envelope.Status;
|
||||
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||
if (status is EnvelopeStatus.EnvelopeCompletelySigned or EnvelopeStatus.MessageCompletionSent)
|
||||
return true;
|
||||
|
||||
@@ -841,6 +915,43 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
bool IsReceiverRejected(EnvelopeDto envelope, EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto receiver)
|
||||
{
|
||||
var status = GetEffectiveEnvelopeStatus(envelope);
|
||||
var histories = envelope.Histories?.Where(h =>
|
||||
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected).ToList() ?? [];
|
||||
|
||||
if (histories.Count == 0)
|
||||
return false;
|
||||
|
||||
if (histories.Any(h => h.Receiver?.Id == receiver.ReceiverId))
|
||||
return true;
|
||||
|
||||
var receiverEmail = receiver.Receiver?.EmailAddress;
|
||||
if (!string.IsNullOrWhiteSpace(receiverEmail) &&
|
||||
histories.Any(h => string.Equals(h.UserReference, receiverEmail, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(receiver.Name) &&
|
||||
histories.Any(h => string.Equals(h.UserReference, receiver.Name, StringComparison.OrdinalIgnoreCase)))
|
||||
return true;
|
||||
|
||||
return status == EnvelopeStatus.EnvelopeRejected && (envelope.EnvelopeReceivers?.Count() ?? 0) == 1;
|
||||
}
|
||||
|
||||
EnvelopeStatus GetEffectiveEnvelopeStatus(EnvelopeDto envelope)
|
||||
{
|
||||
var rawStatus = envelope.Status;
|
||||
|
||||
if (rawStatus is EnvelopeStatus.EnvelopeRejected or EnvelopeStatus.EnvelopeWithdrawn)
|
||||
return rawStatus;
|
||||
|
||||
var hasRejectedHistory = (envelope.Histories ?? Enumerable.Empty<HistoryDto>())
|
||||
.Any(h => h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected);
|
||||
|
||||
return hasRejectedHistory ? EnvelopeStatus.EnvelopeRejected : rawStatus;
|
||||
}
|
||||
|
||||
DateTime GetHistoryDate(HistoryDto history)
|
||||
{
|
||||
return history.ActionDate ?? history.AddedWhen;
|
||||
@@ -916,6 +1027,9 @@
|
||||
// Unmount the viewer first — let the previous instance fully dispose
|
||||
_previewViewerMounted = false;
|
||||
_previewPdfBytes = null;
|
||||
_previewPageCount = 0;
|
||||
_previewPagePreviews = [];
|
||||
_previewFileName = $"envelope-{envelope.Id}.pdf";
|
||||
_previewErrorMessage = null;
|
||||
_previewLoading = true;
|
||||
_previewVisible = true;
|
||||
@@ -923,19 +1037,35 @@
|
||||
|
||||
try
|
||||
{
|
||||
var user = EnvelopeAuthService.GetCurrentUser();
|
||||
if (user?.Identity is null || !user.Identity.IsAuthenticated)
|
||||
_previewPdfBytes = await DocumentService.GetSenderDocumentBytesByEnvelopeIdAsync(envelope.Id);
|
||||
if (_previewPdfBytes is null || _previewPdfBytes.Length == 0)
|
||||
{
|
||||
_previewErrorMessage = L("NotAuthenticatedPleaseLoginAgain", "Nicht authentifiziert. Bitte melden Sie sich erneut an.");
|
||||
_previewErrorMessage = L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden");
|
||||
return;
|
||||
}
|
||||
|
||||
_previewPdfBytes = await ReceiverPageDataService.GetDocumentOfSenderAsync(user, envelope.Id);
|
||||
var previewPdfBytes = _previewPdfBytes;
|
||||
|
||||
_previewPagePreviews = [];
|
||||
try
|
||||
{
|
||||
_previewPagePreviews = (await PdfRenderService.RenderPagesAsync(previewPdfBytes))
|
||||
.OrderBy(p => p.PageNumber)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception renderEx)
|
||||
{
|
||||
Logger.LogWarning(renderEx, "Preview: page image rendering failed for envelope {EnvelopeId}. Falling back to native PDF preview.", envelope.Id);
|
||||
}
|
||||
|
||||
_previewPageCount = _previewPagePreviews.Count > 0
|
||||
? _previewPagePreviews.Count
|
||||
: EstimatePdfPageCount(previewPdfBytes) ?? 1;
|
||||
|
||||
// PDF ready — mount the viewer now
|
||||
_previewViewerMounted = true;
|
||||
}
|
||||
catch (DigitalData.Core.Exceptions.NotFoundException)
|
||||
catch (HttpRequestException ex) when ((int?)ex.StatusCode == 404)
|
||||
{
|
||||
_previewErrorMessage = string.Format(
|
||||
L("Sender_PreviewDocumentNotFound", "Das Dokument für Umschlag #{0} wurde nicht gefunden oder Sie haben keinen Zugriff darauf."),
|
||||
@@ -965,12 +1095,60 @@
|
||||
{
|
||||
await Task.Yield(); // wait one render cycle for the viewer to be removed from the DOM
|
||||
_previewPdfBytes = null;
|
||||
_previewPageCount = 0;
|
||||
_previewPagePreviews = [];
|
||||
_previewFileName = "document.pdf";
|
||||
_previewErrorMessage = null;
|
||||
_previewLoading = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
});
|
||||
}
|
||||
|
||||
Task OnPreviewEditorClickAsync(Microsoft.AspNetCore.Components.Web.MouseEventArgs _)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task OnPreviewRemoveFieldAsync(int _)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
Task OnPreviewMoveFieldAsync(SenderPdfFieldMoveRequest _)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
static int? EstimatePdfPageCount(byte[]? documentBytes)
|
||||
{
|
||||
if (documentBytes is null || documentBytes.Length == 0)
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var text = System.Text.Encoding.ASCII.GetString(documentBytes);
|
||||
const string marker = "/Type /Page";
|
||||
var count = 0;
|
||||
var index = 0;
|
||||
|
||||
while (true)
|
||||
{
|
||||
index = text.IndexOf(marker, index, StringComparison.Ordinal);
|
||||
if (index < 0)
|
||||
break;
|
||||
|
||||
count++;
|
||||
index += marker.Length;
|
||||
}
|
||||
|
||||
return count > 0 ? count : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Grid Layout Persistence ───────────────────────────────────────────────
|
||||
// LayoutAutoLoading fires when the grid initializes — restore saved layout.
|
||||
// LayoutAutoSaving fires when the user changes column order/width/sort — persist it.
|
||||
@@ -983,13 +1161,18 @@
|
||||
// If cache is empty (LoadFromServerAsync failed), skip layout restore silently.
|
||||
var prefs = UserPreferencesService.GetCached();
|
||||
if (prefs is not null && prefs.GridLayouts.TryGetValue(CurrentGridKey, out var json))
|
||||
e.Layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
|
||||
{
|
||||
var layout = JsonSerializer.Deserialize<GridPersistentLayout>(json);
|
||||
SanitizeLayoutFilters(layout);
|
||||
e.Layout = layout;
|
||||
}
|
||||
}
|
||||
|
||||
async Task OnGridLayoutAutoSaving(GridPersistentLayoutEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
SanitizeLayoutFilters(e.Layout);
|
||||
var json = JsonSerializer.Serialize(e.Layout);
|
||||
await UserPreferencesService.SaveGridLayoutAsync(CurrentGridKey, json);
|
||||
}
|
||||
@@ -999,5 +1182,55 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Keep column/order personalization, but never persist/restore data filters.
|
||||
// Tab counters are based on tab datasets; persisted grid filters can hide rows and
|
||||
// make the grid appear empty or incomplete.
|
||||
static void SanitizeLayoutFilters(GridPersistentLayout? layout)
|
||||
{
|
||||
if (layout is null)
|
||||
return;
|
||||
|
||||
ClearWritableProperty(layout, "FilterCriteria");
|
||||
ClearWritableProperty(layout, "SearchText");
|
||||
|
||||
if (layout.Columns is not System.Collections.IEnumerable columns)
|
||||
return;
|
||||
|
||||
foreach (var column in columns)
|
||||
{
|
||||
if (column is null)
|
||||
continue;
|
||||
|
||||
ClearWritableProperty(column, "FilterCriteria");
|
||||
ClearWritableProperty(column, "FilterValues");
|
||||
ClearWritableProperty(column, "FilterValue");
|
||||
ClearWritableProperty(column, "SearchText");
|
||||
}
|
||||
}
|
||||
|
||||
static void ClearWritableProperty(object target, string propertyName)
|
||||
{
|
||||
var prop = target.GetType()
|
||||
.GetProperties(System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public)
|
||||
.FirstOrDefault(p => p.Name == propertyName && p.GetIndexParameters().Length == 0);
|
||||
|
||||
if (prop is null || !prop.CanWrite)
|
||||
return;
|
||||
|
||||
var propertyType = prop.PropertyType;
|
||||
object? value;
|
||||
|
||||
if (!propertyType.IsValueType || Nullable.GetUnderlyingType(propertyType) is not null)
|
||||
{
|
||||
value = null;
|
||||
}
|
||||
else
|
||||
{
|
||||
value = Activator.CreateInstance(propertyType);
|
||||
}
|
||||
|
||||
prop.SetValue(target, value);
|
||||
}
|
||||
|
||||
private sealed record StatusFilterItem(EnvelopeStatus Value, string Text);
|
||||
}
|
||||
@@ -41,7 +41,11 @@ builder.Services.AddScoped<SignatureService>();
|
||||
builder.Services.AddScoped<SignatureCacheService>();
|
||||
builder.Services.AddSingleton<AppVersionService>();
|
||||
builder.Services.AddScoped<EnvelopeService>();
|
||||
builder.Services.AddScoped<EnvelopeTypeService>();
|
||||
builder.Services.AddScoped<LocalizationService>();
|
||||
builder.Services.AddScoped<ReceiverService>();
|
||||
builder.Services.AddScoped<PdfRenderService>();
|
||||
builder.Services.AddScoped<CreateEnvelopeBootstrapStorageService>();
|
||||
|
||||
// UI Preferences & Theme
|
||||
builder.Services.AddScoped<UserPreferencesService>();
|
||||
|
||||
@@ -26,4 +26,24 @@ public class AnnotationService(IHttpClientFactory httpClientFactory)
|
||||
var result = await response.Content.ReadFromJsonAsync<List<AnnotationDto>>(_jsonOptions, cancel);
|
||||
return result ?? [];
|
||||
}
|
||||
|
||||
public async Task RejectAsync(string envelopeKey, string? reason, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
|
||||
var response = await http.PostAsJsonAsync(
|
||||
$"/api/Annotation/reject/{Uri.EscapeDataString(envelopeKey)}",
|
||||
reason,
|
||||
_jsonOptions,
|
||||
cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync(cancel);
|
||||
throw new HttpRequestException(
|
||||
$"Failed to reject envelope {envelopeKey}: {(int)response.StatusCode} {response.StatusCode} - {body}",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.JSInterop;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public sealed class CreateEnvelopeBootstrapStorageService(IJSRuntime jsRuntime)
|
||||
{
|
||||
private const string StorageKeyPrefix = "sender.createEnvelope.bootstrap.";
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<string> StoreAsync(CreateEnvelopeBootstrapPayload payload)
|
||||
{
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var key = BuildStorageKey(token);
|
||||
var json = JsonSerializer.Serialize(payload, JsonOptions);
|
||||
await jsRuntime.InvokeVoidAsync("sessionStorage.setItem", key, json);
|
||||
return token;
|
||||
}
|
||||
|
||||
public async Task<CreateEnvelopeBootstrapPayload?> ConsumeAsync(string? token)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
return null;
|
||||
|
||||
var key = BuildStorageKey(token);
|
||||
var raw = await jsRuntime.InvokeAsync<string?>("sessionStorage.getItem", key);
|
||||
await jsRuntime.InvokeVoidAsync("sessionStorage.removeItem", key);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<CreateEnvelopeBootstrapPayload>(raw, JsonOptions);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildStorageKey(string token) => $"{StorageKeyPrefix}{token}";
|
||||
}
|
||||
|
||||
public sealed record CreateEnvelopeBootstrapPayload(
|
||||
string Title,
|
||||
string Message,
|
||||
int? EnvelopeTypeId,
|
||||
string Language,
|
||||
bool UseAccessCode,
|
||||
bool TFAEnabled,
|
||||
int CertificationType,
|
||||
int FinalEmailToCreator,
|
||||
int FinalEmailToReceivers,
|
||||
bool SendReminderEmails,
|
||||
int FirstReminderDays,
|
||||
int ReminderIntervalDays,
|
||||
int ExpiresWhenDays,
|
||||
int ExpiresWarningWhenDays,
|
||||
IReadOnlyList<CreateEnvelopeBootstrapReceiver> Receivers,
|
||||
byte[]? DocumentBytes,
|
||||
string? DocumentFileName);
|
||||
|
||||
public sealed record CreateEnvelopeBootstrapReceiver(string FullName, string EmailAddress);
|
||||
@@ -1,10 +1,23 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public class DocumentService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
private static HttpRequestException CreateHttpException(string messagePrefix, HttpStatusCode statusCode, string? reasonPhrase)
|
||||
{
|
||||
var statusCodeInt = (int)statusCode;
|
||||
return new HttpRequestException(
|
||||
$"{messagePrefix}. Status: {statusCodeInt} ({reasonPhrase ?? "Unknown error"})",
|
||||
null,
|
||||
statusCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches the PDF bytes for the given envelope key from the API.
|
||||
@@ -17,16 +30,75 @@ public class DocumentService(IHttpClientFactory httpClientFactory)
|
||||
var response = await http.GetAsync($"/api/Document/{Uri.EscapeDataString(envelopeKey)}", cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||
throw new HttpRequestException(
|
||||
$"Failed to load document. Status: {statusCode} ({reasonPhrase})",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
throw CreateHttpException("Failed to load document", response.StatusCode, response.ReasonPhrase);
|
||||
|
||||
var bytes = await response.Content.ReadAsByteArrayAsync(cancel);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches sender document details (including signature elements) by envelope id.
|
||||
/// Uses GET /api/Document/details?EnvelopeId={envelopeId}.
|
||||
/// </summary>
|
||||
public async Task<DocumentDto?> GetSenderDocumentDetailsByEnvelopeIdAsync(int envelopeId, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var detailsResponse = await http.GetAsync($"/api/Document/details?EnvelopeId={envelopeId}", cancel);
|
||||
|
||||
if (detailsResponse.StatusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
var fallbackBytes = await TryGetSenderDocumentBytesByEnvelopeIdAsync(http, envelopeId, cancel);
|
||||
if (fallbackBytes is null)
|
||||
return null;
|
||||
|
||||
return new DocumentDto(fallbackBytes, new DocumentDetailsDto
|
||||
{
|
||||
EnvelopeId = envelopeId,
|
||||
AddedWhen = DateTime.UtcNow,
|
||||
Elements = []
|
||||
});
|
||||
}
|
||||
|
||||
if (!detailsResponse.IsSuccessStatusCode)
|
||||
throw CreateHttpException("Failed to load sender document details", detailsResponse.StatusCode, detailsResponse.ReasonPhrase);
|
||||
|
||||
var details = await detailsResponse.Content.ReadFromJsonAsync<DocumentDetailsDto>(_jsonOptions, cancel)
|
||||
?? throw new HttpRequestException("Failed to deserialize sender document details response.");
|
||||
|
||||
var byteData = await TryGetSenderDocumentBytesByEnvelopeIdAsync(http, envelopeId, cancel);
|
||||
if (byteData is null)
|
||||
byteData = [];
|
||||
|
||||
return new DocumentDto(byteData, details);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches sender document bytes by envelope id.
|
||||
/// Uses GET /api/Document?EnvelopeId={envelopeId}.
|
||||
/// </summary>
|
||||
public async Task<byte[]?> GetSenderDocumentBytesByEnvelopeIdAsync(int envelopeId, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var response = await http.GetAsync($"/api/Document?EnvelopeId={envelopeId}", cancel);
|
||||
|
||||
if (response.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw CreateHttpException("Failed to load sender document bytes", response.StatusCode, response.ReasonPhrase);
|
||||
|
||||
return await response.Content.ReadAsByteArrayAsync(cancel);
|
||||
}
|
||||
|
||||
private static async Task<byte[]?> TryGetSenderDocumentBytesByEnvelopeIdAsync(HttpClient http, int envelopeId, CancellationToken cancel)
|
||||
{
|
||||
var bytesResponse = await http.GetAsync($"/api/Document?EnvelopeId={envelopeId}", cancel);
|
||||
if (bytesResponse.StatusCode == HttpStatusCode.NotFound)
|
||||
return null;
|
||||
|
||||
if (!bytesResponse.IsSuccessStatusCode)
|
||||
throw CreateHttpException("Failed to load sender document bytes", bytesResponse.StatusCode, bytesResponse.ReasonPhrase);
|
||||
|
||||
return await bytesResponse.Content.ReadAsByteArrayAsync(cancel);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,26 +101,7 @@ public class EnvelopeService(IHttpClientFactory clientFactory)
|
||||
CancellationToken cancel = default)
|
||||
{
|
||||
var httpClient = clientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var payload = new
|
||||
{
|
||||
EnvelopeId = request.EnvelopeId,
|
||||
Title = request.Title,
|
||||
Message = request.Message,
|
||||
TFAEnabled = request.TFAEnabled,
|
||||
Send = request.Send,
|
||||
Document = request.Document is null
|
||||
? null
|
||||
: new { DataAsBase64 = request.Document.DataAsBase64 },
|
||||
Receivers = request.Receivers.Select(r => new
|
||||
{
|
||||
EmailAddress = r.EmailAddress,
|
||||
Name = r.Name,
|
||||
PhoneNumber = r.PhoneNumber,
|
||||
Fields = r.Fields.Select(f => new { X = f.X, Y = f.Y, Page = f.Page }).ToList()
|
||||
}).ToList()
|
||||
};
|
||||
|
||||
var response = await httpClient.PostAsJsonAsync("/api/Envelope", payload, _jsonOptions, cancel);
|
||||
var response = await httpClient.PostAsJsonAsync("/api/Envelope", request, _jsonOptions, cancel);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return await response.Content.ReadFromJsonAsync<EnvelopeDto>(_jsonOptions, cancel);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public class EnvelopeTypeService(IHttpClientFactory clientFactory)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<IReadOnlyList<EnvelopeTypeDto>> GetAsync(CancellationToken cancel = default)
|
||||
{
|
||||
var http = clientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var response = await http.GetAsync("/api/EnvelopeType", cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var data = await response.Content.ReadFromJsonAsync<List<EnvelopeTypeDto>>(JsonOptions, cancel);
|
||||
return data ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public class PdfRenderService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public async Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsync(byte[] pdfBytes, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
using var response = await http.PostAsJsonAsync(
|
||||
"/api/PdfRender/pages",
|
||||
new PdfRenderRequestDto { PdfBytes = pdfBytes },
|
||||
_jsonOptions,
|
||||
cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var reasonPhrase = response.ReasonPhrase ?? "Unknown error";
|
||||
throw new HttpRequestException(
|
||||
$"Failed to render PDF pages. Status: {statusCode} ({reasonPhrase})",
|
||||
null,
|
||||
response.StatusCode);
|
||||
}
|
||||
|
||||
return await response.Content.ReadFromJsonAsync<List<PdfPageImageDto>>(_jsonOptions, cancel) ?? [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using EnvelopeGenerator.Application.Common.Dto.Receiver;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Client.Services;
|
||||
|
||||
public class ReceiverService(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches receiver email suggestions from sender-authorized API.
|
||||
/// Calls GET /api/Receiver?EmailAddressSearch={term}&onlyEmailAddress=true.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<string>> SearchReceiverEmailsAsync(string searchTerm, CancellationToken cancel = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchTerm))
|
||||
return [];
|
||||
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var url = $"/api/Receiver?EmailAddressSearch={Uri.EscapeDataString(searchTerm)}&onlyEmailAddress=true";
|
||||
var response = await http.GetAsync(url, cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var emails = await response.Content.ReadFromJsonAsync<List<string>>(JsonOptions, cancel);
|
||||
return emails ?? [];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fetches recent receiver suggestions for the authenticated sender.
|
||||
/// Calls GET /api/EnvelopeReceiver/recent-suggestions?take={take}.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<RecentReceiverSuggestionDto>> GetRecentReceiverSuggestionsAsync(int take = 20, CancellationToken cancel = default)
|
||||
{
|
||||
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
|
||||
var url = $"/api/EnvelopeReceiver/recent-suggestions?take={take}";
|
||||
var response = await http.GetAsync(url, cancel);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return [];
|
||||
|
||||
var suggestions = await response.Content.ReadFromJsonAsync<List<RecentReceiverSuggestionDto>>(JsonOptions, cancel);
|
||||
return suggestions ?? [];
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@using EnvelopeGenerator.Server.Client.Models
|
||||
@using EnvelopeGenerator.Server.Client.Options
|
||||
@using EnvelopeGenerator.Server.Client.Components.PdfEditor
|
||||
@using DevExpress.Blazor
|
||||
@using DevExpress.Blazor.PdfViewer
|
||||
@using DevExpress.Blazor.Reporting
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
@inject EnvelopeGenerator.Server.Client.Services.AuthService AuthService
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||
@inject AnnotationService AnnotationService
|
||||
@inject AppVersionService AppVersion
|
||||
@inject IMemoryCache MemoryCache
|
||||
@inject ILogger<ReceiverPage> Logger
|
||||
@@ -132,32 +133,6 @@
|
||||
<span class="pdf-toolbar__btn-text">Unterschreiben</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
@if (!(_envelopeReceiver?.Envelope?.ReadOnly ?? false))
|
||||
{
|
||||
<button class="pdf-toolbar__btn"
|
||||
@onclick="OpenSharePopup"
|
||||
title="Dokument teilen"
|
||||
style="flex-shrink: 0;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M13.5 1a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2zm-11 5a1.5 1.5 0 1 0 1.415 2H4a1.5 1.5 0 0 0-1.5-2zm11 5a1.5 1.5 0 1 0 1.415 2H15a1.5 1.5 0 0 0-1.5-2z"/>
|
||||
<path d="M2.854 7.646a.5.5 0 0 1 .0-.707l9-5a.5.5 0 1 1 .486.874l-9 5a.5.5 0 0 1-.486-.167zm0 .708a.5.5 0 0 1 .486-.167l9 5a.5.5 0 0 1-.486.874l-9-5a.5.5 0 0 1 0-.707z"/>
|
||||
</svg>
|
||||
<span class="pdf-toolbar__btn-text">Teilen</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button class="pdf-toolbar__btn"
|
||||
@onclick="LogoutAsync"
|
||||
disabled="@_isLoggingOut"
|
||||
title="Abmelden"
|
||||
style="flex-shrink: 0;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path fill-rule="evenodd" d="M10 15a1 1 0 0 0 1-1v-2a.5.5 0 0 1 1 0v2a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V2a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v2a.5.5 0 0 1-1 0V2a1 1 0 0 0-1-1H3a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h7z" />
|
||||
<path fill-rule="evenodd" d="M15.854 8.354a.5.5 0 0 0 0-.708l-3-3a.5.5 0 1 0-.708.708L14.293 7.5H6.5a.5.5 0 0 0 0 1h7.793l-2.147 2.146a.5.5 0 0 0 .708.708l3-3z" />
|
||||
</svg>
|
||||
<span class="pdf-toolbar__btn-text">@(_isLoggingOut ? "Abmelden..." : "Abmelden")</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -375,57 +350,6 @@
|
||||
</FooterContentTemplate>
|
||||
</DxPopup>
|
||||
|
||||
<DxPopup @bind-Visible="_sharePopupVisible"
|
||||
HeaderText="Dokument teilen"
|
||||
Width="520px"
|
||||
MaxWidth="95vw"
|
||||
ShowFooter="true"
|
||||
CloseOnOutsideClick="true">
|
||||
<BodyContentTemplate>
|
||||
<div class="mb-3">
|
||||
<label class="form-label" for="rp-share-mail" style="font-size: 0.9rem; font-weight: 500;">E-Mail</label>
|
||||
<input id="rp-share-mail"
|
||||
class="form-control"
|
||||
placeholder="user@mail.com"
|
||||
value="@_shareReceiverMail"
|
||||
@oninput="args => _shareReceiverMail = args.Value?.ToString() ?? string.Empty" />
|
||||
</div>
|
||||
|
||||
<div class="mb-1">
|
||||
<label class="form-label" for="rp-share-date" style="font-size: 0.9rem; font-weight: 500;">Gultig bis</label>
|
||||
<input id="rp-share-date"
|
||||
class="form-control"
|
||||
type="date"
|
||||
min="@DateTime.Today.AddDays(1).ToString("yyyy-MM-dd")"
|
||||
max="@DateTime.Today.AddDays(90).ToString("yyyy-MM-dd")"
|
||||
value="@_shareDateValid.ToString("yyyy-MM-dd")"
|
||||
@oninput="OnShareDateChanged" />
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_shareMessage))
|
||||
{
|
||||
<div class="mt-3 alert @(_shareMessageIsError ? "alert-danger" : "alert-success") mb-0" role="alert">
|
||||
@_shareMessage
|
||||
</div>
|
||||
}
|
||||
</BodyContentTemplate>
|
||||
<FooterContentTemplate>
|
||||
<div class="d-flex justify-content-end gap-2 w-100">
|
||||
<button class="btn btn-outline-secondary"
|
||||
@onclick="CloseSharePopup"
|
||||
disabled="@_isSharing">
|
||||
Schliessen
|
||||
</button>
|
||||
<button class="btn btn-primary"
|
||||
@onclick="ShareEnvelopeAsync"
|
||||
disabled="@_isSharing"
|
||||
style="background: linear-gradient(135deg, #4F46E5 0%, #4338CA 100%); border: none;">
|
||||
@(_isSharing ? "Senden..." : "Senden")
|
||||
</button>
|
||||
</div>
|
||||
</FooterContentTemplate>
|
||||
</DxPopup>
|
||||
|
||||
@code {
|
||||
// ----- Constants -----
|
||||
const string SignatureTabDraw = "draw";
|
||||
@@ -480,6 +404,12 @@
|
||||
string? _shareMessage;
|
||||
bool _shareMessageIsError = false;
|
||||
|
||||
// ----- Reject popup state -----
|
||||
bool _rejectPopupVisible = false;
|
||||
bool _isRejectSubmitting = false;
|
||||
string _rejectReason = string.Empty;
|
||||
string? _rejectValidationMessage;
|
||||
|
||||
async Task LogoutAsync()
|
||||
{
|
||||
if (_isLoggingOut || string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||
@@ -552,23 +482,12 @@
|
||||
|
||||
try
|
||||
{
|
||||
var shared = await AuthService.ShareEnvelopeReadOnlyAsync(EnvelopeKey!, email, _shareDateValid.Date);
|
||||
if (shared)
|
||||
{
|
||||
_shareMessage = "Dokument wurde erfolgreich als Nur-Lesen freigegeben.";
|
||||
_shareMessage = "Freigabe ist in dieser Ansicht derzeit nicht verfugbar.";
|
||||
_shareMessageIsError = false;
|
||||
_shareReceiverMail = string.Empty;
|
||||
_shareDateValid = DateTime.Today.AddDays(7);
|
||||
}
|
||||
else
|
||||
{
|
||||
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
|
||||
_shareMessageIsError = true;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogWarning(ex, "Failed to share envelope in read-only mode for {EnvelopeKey}", EnvelopeKey);
|
||||
Logger.LogError(ex, "Failed to share envelope {EnvelopeKey}", EnvelopeKey);
|
||||
_shareMessage = "Freigabe fehlgeschlagen. Bitte versuchen Sie es erneut.";
|
||||
_shareMessageIsError = true;
|
||||
}
|
||||
@@ -627,6 +546,11 @@
|
||||
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
|
||||
if (_envelopeReceiver is null)
|
||||
Logger.LogWarning("Envelope receiver data is null for {EnvelopeKey}", EnvelopeKey);
|
||||
else if (IsRejected(_envelopeReceiver))
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}/rejected", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build initial report (no signature image yet)
|
||||
_report = BuildReport(_pdfBytes, _signatures, capturedSignature: null);
|
||||
@@ -672,6 +596,16 @@
|
||||
}
|
||||
|
||||
// ----- Report builder -----
|
||||
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
|
||||
{
|
||||
var status = envelopeReceiver.Envelope?.Status;
|
||||
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
|
||||
return true;
|
||||
|
||||
return envelopeReceiver.Envelope?.Histories?.Any(h =>
|
||||
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an XtraReport wrapping the PDF bytes.
|
||||
/// If a signature is captured and there are signature fields, the signature image is
|
||||
@@ -905,6 +839,56 @@
|
||||
forceLoad: true);
|
||||
}
|
||||
|
||||
void OpenRejectPopup()
|
||||
{
|
||||
_rejectValidationMessage = null;
|
||||
_rejectPopupVisible = true;
|
||||
}
|
||||
|
||||
void CloseRejectPopup()
|
||||
{
|
||||
if (_isRejectSubmitting)
|
||||
return;
|
||||
|
||||
_rejectPopupVisible = false;
|
||||
}
|
||||
|
||||
async Task SubmitRejectAsync()
|
||||
{
|
||||
if (_isRejectSubmitting)
|
||||
return;
|
||||
|
||||
_isRejectSubmitting = true;
|
||||
_rejectValidationMessage = null;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
|
||||
try
|
||||
{
|
||||
var reason = string.IsNullOrWhiteSpace(_rejectReason)
|
||||
? null
|
||||
: _rejectReason.Trim();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||
{
|
||||
_rejectValidationMessage = "Envelope-Schluessel fehlt.";
|
||||
_isRejectSubmitting = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
return;
|
||||
}
|
||||
|
||||
await AnnotationService.RejectAsync(EnvelopeKey, reason);
|
||||
|
||||
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to reject envelope {EnvelopeKey}", EnvelopeKey);
|
||||
_rejectValidationMessage = "Die Ablehnung konnte nicht gespeichert werden. Bitte versuchen Sie es erneut.";
|
||||
_isRejectSubmitting = false;
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
async Task<string?> GetActiveSignatureDataUrlAsync()
|
||||
{
|
||||
if (_activeSignatureTab == SignatureTabDraw)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
@page "/envelope/{EnvelopeKey}/rejected"
|
||||
@rendermode InteractiveServer
|
||||
@using EnvelopeGenerator.Application.Resources
|
||||
@using EnvelopeGenerator.Domain.Constants
|
||||
@using EnvelopeGenerator.Domain.Interfaces
|
||||
@using Microsoft.Extensions.Localization
|
||||
@inject NavigationManager Navigation
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService ReceiverAuthorizationService
|
||||
@inject EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService PageDataService
|
||||
@inject IStringLocalizer<Resource> UiLocalizer
|
||||
|
||||
<div class="envelope-viewer-layout" style="justify-content: center; align-items: center; padding: 1.5rem;">
|
||||
<div style="width: min(920px, 100%); background: rgba(255,255,255,0.96); border-radius: 20px; box-shadow: 0 18px 48px rgba(15,23,42,0.22); overflow: hidden;">
|
||||
<div style="padding: 1.2rem 1.4rem; border-bottom: 1px solid rgba(15,23,42,0.08); background: linear-gradient(135deg, rgba(239,68,68,0.12) 0%, rgba(220,38,38,0.08) 100%);">
|
||||
<div style="font-size: 1rem; font-weight: 700; color: #991b1b;">@L("DocRejected", "Dokument abgelehnt")</div>
|
||||
</div>
|
||||
|
||||
<div style="padding: 2rem 1.5rem;">
|
||||
<div style="display: flex; justify-content: center; margin-bottom: 1rem;">
|
||||
<div style="width: 90px; height: 90px; border-radius: 999px; background: #fee2e2; display: inline-flex; align-items: center; justify-content: center; color: #b91c1c;">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zM5.354 4.646a.5.5 0 1 0-.708.708L7.293 8l-2.647 2.646a.5.5 0 0 0 .708.708L8 8.707l2.646 2.647a.5.5 0 0 0 .708-.708L8.707 8l2.647-2.646a.5.5 0 0 0-.708-.708L8 7.293 5.354 4.646z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2 style="text-align: center; font-size: 1.4rem; color: #7f1d1d; margin-bottom: 0.75rem;">
|
||||
@(_isReadAndConfirm
|
||||
? L("RejectionInfo1Confirmation", "Dieses Dokument wurde als nicht bestaetigt markiert.")
|
||||
: L("RejectionInfo1", "Dieses Dokument wurde abgelehnt."))
|
||||
</h2>
|
||||
|
||||
<p style="text-align: center; margin: 0 auto; max-width: 760px; color: #374151; line-height: 1.6;">
|
||||
@((MarkupString)_messageLine2)
|
||||
</p>
|
||||
|
||||
<div style="margin-top: 1.4rem; text-align: center;">
|
||||
<a href="/" class="btn btn-outline-secondary" style="font-weight: 600;">Portal Startseite</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter] public string? EnvelopeKey { get; set; }
|
||||
|
||||
bool _isReadAndConfirm;
|
||||
string _messageLine2 = string.Empty;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(EnvelopeKey))
|
||||
{
|
||||
Navigation.NavigateTo("/");
|
||||
return;
|
||||
}
|
||||
|
||||
var user = await ReceiverAuthorizationService.AuthorizeAsync(EnvelopeKey);
|
||||
if (user is null)
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/login/{Uri.EscapeDataString(EnvelopeKey)}");
|
||||
return;
|
||||
}
|
||||
|
||||
var envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
|
||||
if (envelopeReceiver is null)
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!IsRejected(envelopeReceiver))
|
||||
{
|
||||
Navigation.NavigateTo($"/envelope/{Uri.EscapeDataString(EnvelopeKey)}", forceLoad: true);
|
||||
return;
|
||||
}
|
||||
|
||||
_isReadAndConfirm = envelopeReceiver.Envelope?.IsReadAndConfirm() ?? false;
|
||||
|
||||
var senderName = envelopeReceiver.Envelope?.User is null
|
||||
? string.Empty
|
||||
: $"{envelopeReceiver.Envelope.User.Prename} {envelopeReceiver.Envelope.User.Name}".Trim();
|
||||
var senderEmail = envelopeReceiver.Envelope?.User?.Email ?? string.Empty;
|
||||
var envelopeTitle = envelopeReceiver.Envelope?.Title ?? string.Empty;
|
||||
|
||||
var template = L("RejectionInfo2", "Das Dokument \"{2}\" von {0} ({1}) wurde abgelehnt.");
|
||||
_messageLine2 = string.Format(template, senderName, senderEmail, envelopeTitle);
|
||||
}
|
||||
|
||||
static bool IsRejected(EnvelopeGenerator.Application.Common.Dto.EnvelopeReceiver.EnvelopeReceiverDto envelopeReceiver)
|
||||
{
|
||||
var status = envelopeReceiver.Envelope?.Status;
|
||||
if (status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected)
|
||||
return true;
|
||||
|
||||
return envelopeReceiver.Envelope?.Histories?.Any(h =>
|
||||
h.Status is EnvelopeStatus.DocumentRejected or EnvelopeStatus.EnvelopeRejected) == true;
|
||||
}
|
||||
|
||||
string L(string key, string fallback)
|
||||
{
|
||||
var value = UiLocalizer[key];
|
||||
return value.ResourceNotFound ? fallback : value.Value;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,114 +0,0 @@
|
||||
@page "/envelope/DxPdfViewer"
|
||||
@rendermode InteractiveServer
|
||||
@using System.IO
|
||||
@using DevExpress.Blazor
|
||||
@using System.Reflection
|
||||
@using DevExpress.Blazor.PdfViewer
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
|
||||
<style>
|
||||
.custom-drop-zone {
|
||||
padding: 0 !important;
|
||||
border-style: dashed;
|
||||
border-width: 2px !important;
|
||||
height: 230px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(183, 183, 183, 0.1);
|
||||
}
|
||||
|
||||
.custom-drop-zone.custom-drop-zone-hover {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.custom-drop-zone svg {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.custom-drop-zone > *:not(#overviewDemoSelectButton) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pdf-viewer {
|
||||
height: 800px !important;
|
||||
min-height: 800px !important;
|
||||
}
|
||||
|
||||
.pdf-viewer .dxbrv-surface-wrapper,
|
||||
.pdf-viewer .dxbrv-document-surface {
|
||||
height: 100% !important;
|
||||
min-height: 750px !important;
|
||||
}
|
||||
|
||||
.pdf-viewer .dxbrv-report-preview-content {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
min-width: 200px !important;
|
||||
min-height: 200px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="overviewDemoDropZone" class="card custom-drop-zone rounded-3 w-100 m-0">
|
||||
<span class="drop-file-icon mb-3"></span>
|
||||
<span class="drop-file-label">Drag and Drop File Here</span><span class="m-1">or</span>
|
||||
<DxButton Id="overviewDemoSelectButton"
|
||||
CssClass="m-1"
|
||||
RenderStyle="ButtonRenderStyle.Primary"
|
||||
Text="Select File" />
|
||||
</div>
|
||||
<DxFileInput @ref="fileInput"
|
||||
AcceptedFileTypes="@ALLOWED_FILE_TYPES"
|
||||
AllowedFileExtensions="@ALLOWED_FILE_TYPES"
|
||||
CssClass="w-100"
|
||||
ExternalDropZoneCssSelector="#overviewDemoDropZone"
|
||||
ExternalDropZoneDragOverCssClass="custom-drop-zone-hover"
|
||||
ExternalSelectButtonCssSelector="#overviewDemoSelectButton"
|
||||
FilesUploading="OnFilesUploading"
|
||||
MaxFileSize="2000000">
|
||||
</DxFileInput>
|
||||
|
||||
@if (DocumentContent != null && DocumentContent.Length > 0)
|
||||
{
|
||||
<div class="alert alert-success mt-3">
|
||||
PDF loaded: @DocumentContent.Length bytes
|
||||
</div>
|
||||
<DxPdfViewer CssClass="w-100 pdf-viewer" DocumentContent="@DocumentContent" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info mt-3">
|
||||
Please upload a PDF file to view it.
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
readonly List<string> ALLOWED_FILE_TYPES = new List<string> { ".pdf" };
|
||||
DxFileInput fileInput;
|
||||
byte[] DocumentContent { get; set; }
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Assembly assembly = Assembly.GetExecutingAssembly();
|
||||
Stream stream = assembly.GetManifestResourceStream("EnvelopeGenerator.Server.Resources.Invoice.pdf");
|
||||
if (stream != null)
|
||||
{
|
||||
using (stream)
|
||||
using (var binaryReader = new BinaryReader(stream))
|
||||
DocumentContent = binaryReader.ReadBytes((int)stream.Length);
|
||||
}
|
||||
}
|
||||
protected async Task OnFilesUploading(FilesUploadingEventArgs args)
|
||||
{
|
||||
using (MemoryStream stream = new MemoryStream())
|
||||
{
|
||||
IFileInputSelectedFile file = args.Files[0];
|
||||
await file.OpenReadStream(file.Size).CopyToAsync(stream);
|
||||
DocumentContent = stream.ToArray();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
@page "/envelope/{EnvelopeKey}/DxReportViewer"
|
||||
@rendermode InteractiveServer
|
||||
@using XtraReport = DevExpress.XtraReports.UI.XtraReport
|
||||
@using DevExpress.Blazor.Reporting
|
||||
@using Microsoft.Extensions.Options
|
||||
@using EnvelopeGenerator.Server.Client.Options
|
||||
@using EnvelopeGenerator.Server.Client.Services
|
||||
@inject InMemoryReportStorageWebExtension ReportStorage
|
||||
@inject DocumentService DocumentService
|
||||
@inject IOptions<ApiOptions> AppOptions
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
<link href="_content/DevExpress.Blazor.Reporting.Viewer/css/dx-blazor-reporting-components.bs5.css" rel="stylesheet" />
|
||||
|
||||
|
||||
@if (_report is not null) {
|
||||
<DxReportViewer Report="_report" RootCssClasses="w-100 h-100" Zoom="1.3" />
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public string EnvelopeKey { get; init; } = null!;
|
||||
|
||||
XtraReport? _report = null;
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
_report = await CreateReport();
|
||||
}
|
||||
|
||||
async Task<XtraReport> CreateReport()
|
||||
{
|
||||
if (AppOptions.Value.UsePredefinedReports)
|
||||
{
|
||||
return Client.PredefinedReports.ReportsFactory.GetReport("LargeDatasetReport");
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
var pdfBytes = await DocumentService.GetDocumentAsync(EnvelopeKey);
|
||||
if (pdfBytes is null || pdfBytes.Length == 0)
|
||||
throw new InvalidOperationException($"No PDF bytes found for EnvelopeKey: {EnvelopeKey}");
|
||||
|
||||
var report = new XtraReport();
|
||||
var detail = new DevExpress.XtraReports.UI.DetailBand();
|
||||
report.Bands.Add(detail);
|
||||
detail.Controls.Add(new DevExpress.XtraReports.UI.XRPdfContent { Source = pdfBytes, GenerateOwnPages = true });
|
||||
return report;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
@page "/envelope/Embed"
|
||||
@rendermode InteractiveServer
|
||||
@using System.IO
|
||||
@using DevExpress.Blazor
|
||||
@using System.Reflection
|
||||
|
||||
<link href="_content/DevExpress.Blazor.Themes/blazing-berry.bs5.min.css" rel="stylesheet" />
|
||||
|
||||
<style>
|
||||
.custom-drop-zone {
|
||||
padding: 0 !important;
|
||||
border-style: dashed;
|
||||
border-width: 2px !important;
|
||||
height: 230px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: rgba(183, 183, 183, 0.1);
|
||||
}
|
||||
|
||||
.custom-drop-zone.custom-drop-zone-hover {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.custom-drop-zone svg {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.custom-drop-zone > *:not(#overviewDemoSelectButton) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pdf-viewer {
|
||||
height: 800px !important;
|
||||
min-height: 800px !important;
|
||||
}
|
||||
|
||||
.pdf-viewer .dxbrv-surface-wrapper,
|
||||
.pdf-viewer .dxbrv-document-surface {
|
||||
height: 100% !important;
|
||||
min-height: 750px !important;
|
||||
}
|
||||
|
||||
.pdf-viewer .dxbrv-report-preview-content {
|
||||
width: auto !important;
|
||||
height: auto !important;
|
||||
min-width: 200px !important;
|
||||
min-height: 200px !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div id="overviewDemoDropZone" class="card custom-drop-zone rounded-3 w-100 m-0">
|
||||
<span class="drop-file-icon mb-3"></span>
|
||||
<span class="drop-file-label">Drag and Drop File Here</span><span class="m-1">or</span>
|
||||
<DxButton Id="overviewDemoSelectButton"
|
||||
CssClass="m-1"
|
||||
RenderStyle="ButtonRenderStyle.Primary"
|
||||
Text="Select File" />
|
||||
</div>
|
||||
<DxFileInput @ref="fileInput"
|
||||
AcceptedFileTypes="@ALLOWED_FILE_TYPES"
|
||||
AllowedFileExtensions="@ALLOWED_FILE_TYPES"
|
||||
CssClass="w-100"
|
||||
ExternalDropZoneCssSelector="#overviewDemoDropZone"
|
||||
ExternalDropZoneDragOverCssClass="custom-drop-zone-hover"
|
||||
ExternalSelectButtonCssSelector="#overviewDemoSelectButton"
|
||||
FilesUploading="OnFilesUploading"
|
||||
MaxFileSize="2000000">
|
||||
</DxFileInput>
|
||||
|
||||
@if (DocumentContent != null && DocumentContent.Length > 0)
|
||||
{
|
||||
<div class="alert alert-success mt-3">
|
||||
PDF loaded: @DocumentContent.Length bytes
|
||||
</div>
|
||||
<embed src="@GetPdfDataUrl()" type="application/pdf" class="w-100 pdf-viewer" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="alert alert-info mt-3">
|
||||
Please upload a PDF file to view it.
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
readonly List<string> ALLOWED_FILE_TYPES = new List<string> { ".pdf" };
|
||||
DxFileInput fileInput;
|
||||
byte[] DocumentContent { get; set; }
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
Assembly assembly = Assembly.GetExecutingAssembly();
|
||||
Stream stream = assembly.GetManifestResourceStream("EnvelopeGenerator.Server.Resources.Invoice.pdf");
|
||||
if (stream != null)
|
||||
{
|
||||
using (stream)
|
||||
using (var binaryReader = new BinaryReader(stream))
|
||||
DocumentContent = binaryReader.ReadBytes((int)stream.Length);
|
||||
}
|
||||
}
|
||||
|
||||
protected async Task OnFilesUploading(FilesUploadingEventArgs args)
|
||||
{
|
||||
using (MemoryStream stream = new MemoryStream())
|
||||
{
|
||||
IFileInputSelectedFile file = args.Files[0];
|
||||
await file.OpenReadStream(file.Size).CopyToAsync(stream);
|
||||
DocumentContent = stream.ToArray();
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
}
|
||||
|
||||
private string GetPdfDataUrl()
|
||||
{
|
||||
if (DocumentContent == null || DocumentContent.Length == 0)
|
||||
return string.Empty;
|
||||
|
||||
string base64 = Convert.ToBase64String(DocumentContent);
|
||||
return $"data:application/pdf;base64,{base64}#toolbar=0&navpanes=0&scrollbar=1";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,11 +100,12 @@ public class AnnotationController : ControllerBase
|
||||
/// <summary>
|
||||
/// Rejects the document for the current receiver.
|
||||
/// </summary>
|
||||
/// <param name="envelopeKey">Envelope key route parameter.</param>
|
||||
/// <param name="reason">Optional rejection reason.</param>
|
||||
[Authorize(Policy = AuthPolicy.Receiver)]
|
||||
[HttpPost("reject")]
|
||||
[HttpPost("reject/{envelopeKey}")]
|
||||
[Obsolete("Use MediatR")]
|
||||
public async Task<IActionResult> Reject([FromBody] string? reason = null)
|
||||
public async Task<IActionResult> Reject([FromRoute] string envelopeKey, [FromBody] string? reason = null)
|
||||
{
|
||||
var signature = User.ReceiverSignature();
|
||||
var uuid = User.EnvelopeUuid();
|
||||
|
||||
@@ -31,17 +31,30 @@ public class DocumentController(IMediator mediator, IAuthorizationService authSe
|
||||
/// <param name="cancel">Cancellation token.</param>
|
||||
[HttpGet]
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery? query = null)
|
||||
public async Task<IActionResult> GetDocument(CancellationToken cancel, [FromQuery] ReadDocumentQuery query)
|
||||
{
|
||||
if (query is null)
|
||||
return BadRequest("Missing document query.");
|
||||
|
||||
var senderDoc = await mediator.Send(query, cancel);
|
||||
return senderDoc.ByteData is byte[] senderDocByte
|
||||
return senderDoc?.ByteData is byte[] senderDocByte
|
||||
? File(senderDocByte, "application/octet-stream")
|
||||
: NotFound("Document is empty.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns sender document metadata and element details (without binary payload) for editor loading.
|
||||
/// </summary>
|
||||
/// <param name="query">Document query with EnvelopeId.</param>
|
||||
/// <param name="cancel">Cancellation token.</param>
|
||||
[HttpGet("details")]
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
public async Task<IActionResult> GetDocumentDetails(CancellationToken cancel, [FromQuery] ReadDocumentDetailsQuery query)
|
||||
{
|
||||
var senderDoc = await mediator.Send(query with { UserId = User.GetId() }, cancel);
|
||||
if (senderDoc is null)
|
||||
return NotFound("Document is empty.");
|
||||
|
||||
return Ok(senderDoc);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the document for the specified envelope key.
|
||||
/// </summary>
|
||||
|
||||
@@ -112,6 +112,20 @@ public class EnvelopeReceiverController : ControllerBase
|
||||
return name is null ? NotFound() : Ok(name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns recent receiver suggestions for the authenticated sender.
|
||||
/// </summary>
|
||||
/// <param name="query">Suggestion query options (take 3..5 is applied in handler).</param>
|
||||
/// <param name="cancel">Cancellation token.</param>
|
||||
/// <returns>List of recent distinct receiver suggestions.</returns>
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
[HttpGet("recent-suggestions")]
|
||||
public async Task<IActionResult> GetRecentSuggestions([FromQuery] ReadRecentReceiverSuggestionsQuery query, CancellationToken cancel)
|
||||
{
|
||||
var result = await _mediator.Send(query.ForSender(User.GetUsername()), cancel);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Datenübertragungsobjekt mit Informationen zu Umschlägen, Empfängern und Unterschriften.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class HealthController(IPkcs11SelfTestStatusProvider pkcs11StatusProvider) : ControllerBase
|
||||
{
|
||||
[AllowAnonymous]
|
||||
[HttpGet("crypt")]
|
||||
public IActionResult GetCryptHealth()
|
||||
{
|
||||
var status = pkcs11StatusProvider.GetStatus();
|
||||
return Ok(status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using DigitalData.Auth.Claims;
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
using EnvelopeGenerator.Domain.Constants;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class PdfRenderController(IPdfPageImageRenderer renderer) : ControllerBase
|
||||
{
|
||||
// TODO(perf): Replace JSON/base64 transport for PdfBytes with application/octet-stream (or multipart) and stream-based request parsing to reduce payload size and allocation pressure for large PDFs.
|
||||
[HttpPost("pages")]
|
||||
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
|
||||
public async Task<IActionResult> RenderPages([FromBody] PdfRenderRequestDto? request, CancellationToken cancel)
|
||||
{
|
||||
if (request?.PdfBytes is not { Length: > 0 } bytes)
|
||||
return BadRequest("Missing PDF bytes.");
|
||||
|
||||
IReadOnlyList<PdfPageImageDto> pages = await renderer.RenderPagesAsImagesAsync(bytes, cancel);
|
||||
return Ok(pages);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using EnvelopeGenerator.Application.Common.Dto;
|
||||
using EnvelopeGenerator.Application.Common.Enums;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace EnvelopeGenerator.Server.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class PdfSignatureController(IPdfDigitalSignatureService signatureService) : ControllerBase
|
||||
{
|
||||
public sealed class SignPdfForm
|
||||
{
|
||||
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 sealed class CheckPdfSignatureForm
|
||||
{
|
||||
public IFormFile? File { get; set; }
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("sign")]
|
||||
[Consumes("multipart/form-data")]
|
||||
public async Task<IActionResult> 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.");
|
||||
|
||||
var signOptions = new PdfSignOptionsDto
|
||||
{
|
||||
UUID = request.UUID,
|
||||
Name = request.Name,
|
||||
Reason = request.Reason,
|
||||
Location = request.Location,
|
||||
Email = request.Email,
|
||||
Phone = request.Phone,
|
||||
CertificationLevel = PdfSignatureCertificationLevel.FormFillingAndAnnotations
|
||||
};
|
||||
|
||||
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);
|
||||
return File(output, "application/pdf", $"{fileName}-signed.pdf");
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpPost("is-signed")]
|
||||
[Consumes("multipart/form-data")]
|
||||
public async Task<IActionResult> 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();
|
||||
var result = await signatureService.CheckPdfSignatureAsync(input, cancel);
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,9 @@
|
||||
<Authors>Digital Data GmbH</Authors>
|
||||
<Company>Digital Data GmbH</Company>
|
||||
<Product>EnvelopeGenerator.Server</Product>
|
||||
<Version>1.4.0-beta</Version>
|
||||
<FileVersion>1.4.0.0</FileVersion>
|
||||
<AssemblyVersion>1.4.0.0</AssemblyVersion>
|
||||
<Version>1.5.0-beta</Version>
|
||||
<FileVersion>1.5.0.0</FileVersion>
|
||||
<AssemblyVersion>1.5.0.0</AssemblyVersion>
|
||||
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
|
||||
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||
</PropertyGroup>
|
||||
@@ -21,6 +21,8 @@
|
||||
<ProjectReference Include="..\EnvelopeGenerator.Server.Client\EnvelopeGenerator.Server.Client.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.Domain\EnvelopeGenerator.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure.Crypt\EnvelopeGenerator.Infrastructure.Crypt.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
|
||||
<ProjectReference Include="..\..\EnvelopeGenerator.PdfEditor\EnvelopeGenerator.PdfEditor.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -67,7 +67,7 @@ public static class ReceiverClaimExtensions
|
||||
/// Gets the authenticated receiver email address from the claims.
|
||||
/// </summary>
|
||||
public static string ReceiverMail(this ClaimsPrincipal user)
|
||||
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email);
|
||||
=> user.GetRequiredClaimValue(JwtRegisteredClaimNames.Email, ClaimTypes.Email);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the authenticated envelope identifier from the claims.
|
||||
|
||||
@@ -26,6 +26,9 @@ using NLog;
|
||||
using DigitalData.Auth.Claims;
|
||||
using EnvelopeGenerator.Server;
|
||||
using DigitalData.EmailProfilerDispatcher;
|
||||
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
|
||||
using EnvelopeGenerator.Infrastructure.Doc;
|
||||
using EnvelopeGenerator.Infrastructure.Crypt.DependencyInjection;
|
||||
|
||||
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
|
||||
logger.Info("EnvelopeGenerator.Server logging initialized!");
|
||||
@@ -36,6 +39,7 @@ try
|
||||
|
||||
// Load YARP configuration from yarp.json
|
||||
builder.Configuration.AddJsonFile("yarp.json", optional: true, reloadOnChange: true);
|
||||
builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true);
|
||||
|
||||
builder.Logging.SetMinimumLevel(Microsoft.Extensions.Logging.LogLevel.Trace);
|
||||
|
||||
@@ -341,6 +345,10 @@ try
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.LocalizationService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ReceiverService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfRenderService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.EnvelopeTypeService>();
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService>();
|
||||
|
||||
// Business Services (Server specific)
|
||||
builder.Services.AddScoped<DocumentService>();
|
||||
@@ -349,6 +357,8 @@ try
|
||||
builder.Services.AddScoped<EnvelopeReceiverService>();
|
||||
builder.Services.AddScoped<SignatureService>();
|
||||
builder.Services.AddScoped<SignatureCacheService>();
|
||||
builder.Services.AddDocInfrastructure();
|
||||
builder.Services.AddCryptInfrastructure(config);
|
||||
builder.Services.AddScoped<EnvelopeGenerator.Application.Common.Interfaces.Services.IEnvelopeMailService, EnvelopeGenerator.Application.Services.EnvelopeMailService>();
|
||||
builder.Services.AddSingleton<AppVersionService>();
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user