Merge branch 'feat/server-hsm-integr'

This commit is contained in:
2026-10-26 14:45:59 +01:00
64 changed files with 2658 additions and 403 deletions

1
.gitignore vendored
View File

@@ -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

View File

@@ -48,6 +48,16 @@ This section explains every markdown file in the repo: what it contains, why it
- Implementing sender-side PDF.js overlay (signature field placement) — the receiver side is the reference implementation
**Maintenance:** Update when `pdf-viewer.js` API changes, new JS interop functions are added, or the signing flow is modified.
#### `DATABASE_STRUCTURE_CONTEXT.md`
**What:** Deep DB documentation for the envelope domain. Includes table/column snapshot, entity-to-table mapping, EF relationship/constraint map, stored procedure behavior + call sites, and trigger behavior + wiring path.
**Why it exists:** DB is centralized and can evolve independently; this file preserves migration-critical data semantics when historical DB context is hard to recover.
**When you need it:**
- Planning or reviewing DB/schema changes
- Debugging status transitions/history side effects
- Tracing where `PRSIG_*` procedures or DB triggers affect app behavior
- Reconciling Domain entities with physical SQL schema
**Maintenance:** Refresh on each DB migration, trigger/procedure change, or significant Domain/EGDbContext model change.
---
### Deployment Docs
@@ -268,6 +278,7 @@ Manual testing workflow:
## Technical Debt TODOs
- **CRITICAL (Licensing / pre-commercial release):** Remove all `SixLabors.ImageSharp` and `QRCoder-ImageSharp` package dependencies from production/runtime projects before commercial release. Replace remaining QR/image paths with permissive-license alternatives (no copyleft/share-source obligations), run repo-wide package audit (`*.csproj`), and add CI guardrails to block reintroduction.
- 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.
@@ -293,11 +304,51 @@ Manual testing workflow:
**Key Tables:**
- `TBSIG_ENVELOPE` - Envelope metadata
- `TBSIG_ENVELOPE_RECEIVER` - Receiver assignments
- `TBSIG_DOC_RECEIVER_ELEMENT` - Signature fields (X, Y in INCHES)
- `TBSIG_DOCUMENT_RECEIVER_ELEMENT` - Signature fields (X, Y in INCHES)
- `TBSIG_RECEIVER` - Receiver registry
- `TBSIG_DOCUMENT` - PDF binary data
- `TBSIG_ENVELOPE_DOCUMENT` - PDF binary data
- `TBSIG_ENVELOPE_HISTORY` - Audit trail
### Data Structure (Domain + EGDbContext)
Use this as the canonical quick map before touching sender/receiver workflow code.
#### Core Aggregate Shape
- `Envelope` (`TBSIG_ENVELOPE`) is the root aggregate for sending workflow.
- `Envelope -> Documents` is `1:N` via `Document.EnvelopeId` (`TBSIG_ENVELOPE_DOCUMENT`).
- `Envelope -> Histories` is `1:N` via `History.EnvelopeId` (`TBSIG_ENVELOPE_HISTORY`).
- `Envelope <-> Receiver` is `N:N` through `EnvelopeReceiver` (`TBSIG_ENVELOPE_RECEIVER`, composite PK `{EnvelopeId, ReceiverId}`).
- `Document -> DocReceiverElement` is `1:N` via `DocReceiverElement.DocumentId` (`TBSIG_DOCUMENT_RECEIVER_ELEMENT`).
- `DocReceiverElement -> ElementAnnotation` is `1:N` via `ElementAnnotation.ElementId` (`TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION`).
#### Status and Tracking Entities
- `DocumentStatus` (`TBSIG_DOCUMENT_STATUS`) links `EnvelopeId + ReceiverId` and stores receiver-level signing state.
- `History` stores timeline events (`EnvelopeStatus`) with polymorphic `UserReference` lookup semantics.
- `EnvelopeReceiverReadOnly` (`TBSIG_ENVELOPE_RECEIVER_READ_ONLY`) stores temporary read-only delegation with validity window (`DateValid`).
#### Important Constraints From `EGDbContext`
- `Envelope.Uuid` has a unique index.
- `Receiver.Signature` has a unique index.
- `Receiver.EmailAddress` has a unique index.
- `EnvelopeReceiver` uses composite primary key: `{ EnvelopeId, ReceiverId }`.
- `History.UserReference` is mapped conditionally (`IsMigration == false`) to:
- `Receiver.EmailAddress` (`History.Receiver`)
- `User.Email` from UserManager (`History.Sender`)
- `EnvelopeReceiverReadOnly.AddedWho` maps to `Receiver.EmailAddress` (principal key mapping, not receiver GUID).
#### Coordinate-Critical Fields
- Signature placement is persisted in `DocReceiverElement` as `X`, `Y`, `Width`, `Height`, `Page`.
- These coordinates are in INCHES (not PDF points, not pixels).
#### Cross-Module Tables in Same Context
- `EGDbContext` also includes UserManager entities: `User`, `Group`, `Module`, `GroupOfUser`, `ModuleOfUser`, `UserRep`, and `ClientUser`.
- Mail dispatcher entity `EmailOut` is also registered under `#if NET` and trigger-configurable.
#### Trigger-Aware Model Notes
- Trigger names are injected from `DbTriggerParams` in appsettings and applied via `ToTable(tb => tb.HasTrigger(...))`.
- Current trigger registration is explicit (`AddTrigger<T>()` calls), not attribute/reflection driven.
- Do not remove trigger mappings casually; this project depends on DB-side automation/audit behavior.
## DevExpress
**License:** Commercial (v25.2.3)

View File

@@ -0,0 +1,384 @@
# Database Structure Context
## Why this file exists
This is the deep-dive DB reference for migration work when schema can change and old DB knowledge may be lost.
- Scope: `EnvelopeGenerator.Domain` entities + `EnvelopeGenerator.Infrastructure/EGDbContext.cs` + live DB metadata snapshot from `DD_ECM`.
- Goal: make schema transitions predictable (what exists, how it is linked, where logic executes).
- Safety: all DB checks used read-only `SELECT` metadata queries; no data mutation.
## Snapshot metadata
- Source DB: `DD_ECM` on `SDD-VHP04-SQL19\DD_TESTING01`
- Snapshot date: `2026-10-20`
- App context: `EnvelopeGenerator.Infrastructure/EGDbContext.cs`
## Fast navigation
- High-level aggregate map: `AGENTS.md` -> `Database` section
- EF model config source: `EnvelopeGenerator.Infrastructure/EGDbContext.cs`
- Stored procedure wrappers:
- `EnvelopeGenerator.Application/Common/SQL/EnvelopeCreateReadSQL.cs`
- `EnvelopeGenerator.Application/Common/SQL/EnvelopeReceiverAddReadSQL.cs`
- `EnvelopeGenerator.Application/Common/SQL/DocumentCreateReadSQL.cs`
- Direct SQL execution in controller:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs`
## Core data model (ER-level)
- `Envelope` (`TBSIG_ENVELOPE`) is root aggregate.
- `Envelope (1) -> (N) Document` via `TBSIG_ENVELOPE_DOCUMENT.ENVELOPE_ID`.
- `Envelope (1) -> (N) History` via `TBSIG_ENVELOPE_HISTORY.ENVELOPE_ID`.
- `Envelope (N) <-> (N) Receiver` through `EnvelopeReceiver` (`TBSIG_ENVELOPE_RECEIVER`, composite PK `{ENVELOPE_ID, RECEIVER_ID}`).
- `Document (1) -> (N) DocReceiverElement` via `TBSIG_DOCUMENT_RECEIVER_ELEMENT.DOCUMENT_ID`.
- `DocReceiverElement (1) -> (N) ElementAnnotation` via `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION.ELEMENT_ID`.
- `DocumentStatus` links `Envelope + Receiver` state in `TBSIG_DOCUMENT_STATUS`.
## Entity <-> table mapping (code-level)
| Entity | Table | PK in code | PK in DB | Important relation notes |
|---|---|---|---|---|
| `Envelope` | `TBSIG_ENVELOPE` | `Id` | `GUID` | Has `Documents`, `Histories`, `EnvelopeReceivers`; unique index on `Uuid` configured in EF |
| `EnvelopeReceiver` | `TBSIG_ENVELOPE_RECEIVER` | `{EnvelopeId, ReceiverId}` | `{ENVELOPE_ID, RECEIVER_ID}` | DB also has `GUID` identity (not modeled as entity PK) |
| `Document` | `TBSIG_ENVELOPE_DOCUMENT` | `Id` | `GUID` | FK `ENVELOPE_ID` |
| `DocReceiverElement` | `TBSIG_DOCUMENT_RECEIVER_ELEMENT` | `Id` | `GUID` | Coordinates persisted here (`POSITION_X/Y`, `WIDTH`, `HEIGHT`, `PAGE`) |
| `ElementAnnotation` | `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION` | `Id` | `GUID` | FK `ELEMENT_ID` |
| `DocumentStatus` | `TBSIG_DOCUMENT_STATUS` | `Id` | `GUID` | FK `ENVELOPE_ID`, `RECEIVER_ID` |
| `History` | `TBSIG_ENVELOPE_HISTORY` | `Id` | `GUID` | `USER_REFERENCE` maps to `Receiver.EmailAddress` and `User.Email` when `IsMigration == false` |
| `Receiver` | `TBSIG_RECEIVER` | `Id` | `GUID` | Unique indexes in code: `Signature`, `EmailAddress` |
| `EnvelopeReceiverReadOnly` | `TBSIG_ENVELOPE_RECEIVER_READ_ONLY` | `Id` | `GUID` | `AddedWho` is FK-like mapping to `Receiver.EmailAddress` |
| `EnvelopeType` | `TBSIG_ENVELOPE_TYPE` | `Id` | `GUID` | Used as optional envelope defaults/template |
| `EmailTemplate` | `TBSIG_EMAIL_TEMPLATE` | `Id` | `GUID` | Trigger-managed `CHANGED_WHEN` |
| `EmailOut` (legacy/bridge) | `TBEMLP_EMAIL_OUT` | `Id` | `GUID` | Internal/obsolete domain class, still registered for DB access |
## Physical schema snapshot (table/column level)
Notes:
- Format per column: `COLUMN_NAME TYPE NULLABLE`.
- This section reflects live DB metadata at snapshot time.
### `TBSIG_ENVELOPE`
- `GUID int NOT NULL`
- `USER_ID int NOT NULL`
- `STATUS int NOT NULL`
- `ENVELOPE_UUID nvarchar(36) NOT NULL`
- `MESSAGE nvarchar(max) NOT NULL`
- `EXPIRES_WHEN datetime NULL`
- `EXPIRES_WARNING_WHEN datetime NULL`
- `VERIFICATION_TYPE tinyint NOT NULL`
- `TITLE nvarchar(128) NULL`
- `CONTRACT_TYPE int NULL`
- `LANGUAGE nvarchar(5) NULL`
- `SEND_REMINDER_EMAILS bit NULL`
- `FIRST_REMINDER_DAYS int NULL`
- `REMINDER_INTERVAL_DAYS int NULL`
- `ENVELOPE_TYPE int NULL`
- `CERTIFICATION_TYPE int NULL`
- `USE_ACCESS_CODE bit NULL`
- `FINAL_EMAIL_TO_CREATOR int NULL`
- `FINAL_EMAIL_TO_RECEIVERS int NULL`
- `EXPIRES_WHEN_DAYS int NULL`
- `EXPIRES_WARNING_WHEN_DAYS int NULL`
- `TFA_ENABLED bit NOT NULL`
- `DOC_RESULT varbinary(max) NULL`
- `LANG_CODE varchar(5) NOT NULL`
- `SOURCE varchar(10) NOT NULL`
- `COMMENT varchar(128) NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
### `TBSIG_ENVELOPE_RECEIVER`
- `ENVELOPE_ID int NOT NULL`
- `RECEIVER_ID int NOT NULL`
- `SEQUENCE int NOT NULL`
- `NAME nvarchar(128) NULL`
- `JOB_TITLE nvarchar(128) NULL`
- `COMPANY_NAME nvarchar(128) NULL`
- `PRIVATE_MESSAGE nvarchar(max) NULL`
- `ACCESS_CODE nvarchar(64) NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `GUID bigint NOT NULL`
- `PHONE_NUMBER varchar(20) NULL`
- `PARENT_ENVELOPE_RECEIVER_ID int NOT NULL`
### `TBSIG_ENVELOPE_DOCUMENT`
- `GUID int NOT NULL`
- `ENVELOPE_ID int NOT NULL`
- `FILENAME nvarchar(256) NOT NULL`
- `FILEPATH nvarchar(256) NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `FILENAME_ORIGINAL nvarchar(256) NULL`
- `BYTE_DATA varbinary(max) NULL`
### `TBSIG_DOCUMENT_RECEIVER_ELEMENT`
- `GUID int NOT NULL`
- `DOCUMENT_ID int NOT NULL`
- `RECEIVER_ID int NOT NULL`
- `ELEMENT_TYPE int NOT NULL`
- `POSITION_X float NOT NULL`
- `POSITION_Y float NOT NULL`
- `WIDTH float NOT NULL`
- `HEIGHT float NOT NULL`
- `PAGE int NOT NULL`
- `REQUIRED bit NOT NULL`
- `TOOLTIP nvarchar(512) NULL`
- `READ_ONLY bit NOT NULL`
- `ANNOTATION_INDEX int NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `CHANGED_WHO nvarchar(100) NULL`
- `FULL_NAME nvarchar(100) NULL`
- `POSITION nvarchar(100) NULL`
- `PLACE nvarchar(100) NULL`
- `INK varbinary(max) NULL`
### `TBSIG_DOCUMENT_RECEIVER_ELEMENT_ANNOTATION`
- `GUID bigint NOT NULL`
- `ELEMENT_ID int NOT NULL`
- `NAME nvarchar(100) NOT NULL`
- `VALUE nvarchar(max) NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `CHANGED_WHO nvarchar(100) NULL`
- `TYPE nvarchar(50) NOT NULL`
- `POSITION_X float NULL`
- `POSITION_Y float NULL`
- `WIDTH float NULL`
- `HEIGHT float NULL`
### `TBSIG_DOCUMENT_STATUS`
- `GUID int NOT NULL`
- `ENVELOPE_ID int NOT NULL`
- `RECEIVER_ID int NOT NULL`
- `STATUS int NOT NULL`
- `STATUS_CHANGED_WHEN datetime NULL`
- `VALUE nvarchar(max) NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
### `TBSIG_ENVELOPE_HISTORY`
- `GUID bigint NOT NULL`
- `ENVELOPE_ID int NOT NULL`
- `USER_REFERENCE nvarchar(128) NOT NULL`
- `STATUS int NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `ACTION_DATE datetime NULL`
- `COMMENT nvarchar(max) NULL`
### `TBSIG_RECEIVER`
- `GUID int NOT NULL`
- `EMAIL_ADDRESS nvarchar(128) NOT NULL`
- `SIGNATURE nvarchar(64) NOT NULL`
- `ADDED_WHEN datetime NOT NULL`
- `TOTP_SECRET_KEY nvarchar(max) NULL`
- `TFA_REG_DEADLINE datetime NULL`
- `CULTURE nvarchar(10) NULL`
### `TBSIG_ENVELOPE_RECEIVER_READ_ONLY`
- `GUID bigint NOT NULL`
- `ENVELOPE_ID bigint NOT NULL`
- `RECEIVER_MAIL nvarchar(250) NOT NULL`
- `DATE_VALID date NOT NULL`
- `ADDED_WHO varchar(100) NOT NULL`
- `ADDED_WHEN datetime NULL`
- `CHANGED_WHO varchar(100) NULL`
- `CHANGED_WHEN datetime NULL`
### `TBSIG_ENVELOPE_TYPE`
- `GUID int NOT NULL`
- `TITLE nvarchar(128) NOT NULL`
- `LANGUAGE nvarchar(5) NOT NULL`
- `EXPIRES_DAYS int NULL`
- `CERTIFICATION_TYPE int NULL`
- `USE_ACCESS_CODE bit NULL`
- `FINAL_EMAIL_TO_CREATOR int NULL`
- `FINAL_EMAIL_TO_RECEIVERS int NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `EXPIRES_WARNING_DAYS int NULL`
- `SEND_REMINDER_EMAILS bit NULL`
- `FIRST_REMINDER_DAYS int NULL`
- `REMINDER_INTERVAL_DAYS int NULL`
- `CONTRACT_TYPE int NULL`
### `TBSIG_EMAIL_TEMPLATE`
- `GUID int NOT NULL`
- `NAME nvarchar(64) NULL`
- `BODY nvarchar(max) NULL`
- `SUBJECT nvarchar(512) NULL`
- `ADDED_WHEN datetime NOT NULL`
- `CHANGED_WHEN datetime NULL`
- `LANG_CODE varchar(5) NOT NULL`
### `TBEMLP_EMAIL_OUT`
- `GUID int NOT NULL`
- `REMINDER_TYPE_ID int NOT NULL`
- `SENDING_PROFILE int NOT NULL`
- `REFERENCE_ID int NOT NULL`
- `REFERENCE_STRING varchar(200) NULL`
- `ENTITY_ID int NULL`
- `WF_ID int NOT NULL`
- `WF_REFERENCE varchar(200) NULL`
- `EMAIL_ADRESS varchar(1000) NOT NULL`
- `EMAIL_SUBJ varchar(500) NOT NULL`
- `EMAIL_BODY varchar(max) NOT NULL`
- `EMAIL_ATTMT1 varchar(512) NULL`
- `EMAIL_SENT datetime NULL`
- `COMMENT varchar(500) NULL`
- `ADDED_WHO varchar(50) NOT NULL`
- `ADDED_WHEN datetime NULL`
- `CHANGED_WHO varchar(50) NULL`
- `CHANGED_WHEN datetime NULL`
- `ERROR_TIMESTAMP datetime NULL`
- `ERROR_MSG varchar(900) NULL`
- `ATTACHMENT1_BYTES varbinary(max) NULL`
- `ATT1_RELATED_ID bigint NULL`
- `ATT1_REL_TYPE varchar(20) NULL`
## Constraints and indexes (relevant)
From EF + DB metadata snapshot:
- `TBSIG_ENVELOPE`: PK `PK_TBSIG_ENVELOPE (GUID)`.
- `TBSIG_ENVELOPE_RECEIVER`: composite PK `PK_TBSIG_ENVELOPE_RECEIVER (ENVELOPE_ID, RECEIVER_ID)`.
- `TBSIG_DOCUMENT_RECEIVER_ELEMENT`: PK `GUID`, plus unique key `UQTBSIG_DOCUMENT_RECEIVER_ELEMENT_DocID_PosX_PosY (DOCUMENT_ID, POSITION_X, POSITION_Y)`.
- `TBSIG_RECEIVER`: PK `GUID`, unique key `UQTBSIG_RECEIVER (EMAIL_ADDRESS)`.
- `TBEMLP_EMAIL_OUT`: PK `GUID`, FK `FK_TBEMLP_EMAIL_OUT_SENDING_PROFILE`.
## Stored procedures: behavior and code call sites
### Active in .NET code path
1) `PRSIG_API_CREATE_ENVELOPE`
- Purpose: creates envelope row (`STATUS=1002`, `SOURCE='API'`, defaults) and returns `ENVELOPE_UUID` via output.
- DB writes: `TBSIG_ENVELOPE`.
- Called by:
- SQL template `EnvelopeGenerator.Application/Common/SQL/EnvelopeCreateReadSQL.cs`
- executor `EnvelopeGenerator.Infrastructure/Executor/EnvelopeExecutor.cs`
- used in `EnvelopeReceiverController.CreateAsync` and `CreateEnvelopeReceiverCommandHandler`.
2) `PRSIG_API_CREATE_RECEIVER`
- Purpose: upserts `TBSIG_RECEIVER` by email, adds envelope assignment row with generated access code.
- DB writes: `TBSIG_RECEIVER`, `TBSIG_ENVELOPE_RECEIVER`.
- Called by:
- SQL template `EnvelopeGenerator.Application/Common/SQL/EnvelopeReceiverAddReadSQL.cs`
- executor `EnvelopeGenerator.Infrastructure/Executor/EnvelopeReceiverExecutor.cs`
- used in `EnvelopeReceiverController.CreateAsync` and `CreateEnvelopeReceiverCommandHandler`.
3) `PRSIG_API_ADD_DOC`
- Purpose: stores one document for envelope if absent; returns doc id.
- DB writes: `TBSIG_ENVELOPE_DOCUMENT`.
- Called by:
- SQL template `EnvelopeGenerator.Application/Common/SQL/DocumentCreateReadSQL.cs`
- executor `EnvelopeGenerator.Infrastructure/Executor/DocumentExecutor.cs`
- used in `EnvelopeReceiverController.CreateAsync`.
4) `PRSIG_API_ADD_DOC_RECEIVER_ELEM`
- Purpose: inserts signature element record if `(DOC_ID, RECEIVER_ID)` pair does not exist.
- DB writes: `TBSIG_DOCUMENT_RECEIVER_ELEMENT`.
- Called directly by:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs`
5) `PRSIG_API_ADD_HISTORY_STATE`
- Purpose: appends history row with status and user reference.
- DB writes: `TBSIG_ENVELOPE_HISTORY`.
- Called directly by:
- `EnvelopeGenerator.Server/EnvelopeGenerator.Server/Controllers/EnvelopeReceiverController.cs`
### Legacy/other path references in repo
- `PRSIG_GET_ENVELOPES_FOR_USER` and `PRSIG_GET_CLOSED_ENVELOPES_FOR_USER` are called in legacy VB path:
- `EnvelopeGenerator.CommonServices/Models/EnvelopeModel.vb`
- `PRSIG_API_WITHDRAW_ENVELOPE` exists in DB snapshot but no active `.cs` call site found in current server flow.
## Trigger behavior and where they are wired
## How trigger names are wired in app
- Config source: `EnvelopeGenerator.Server/EnvelopeGenerator.Server/appsettings.json` -> `DbTriggerParams`.
- DI binding: `EnvelopeGenerator.Infrastructure/DependencyInjection.cs` (`AddDbTriggerParams`).
- Model binding: `EnvelopeGenerator.Infrastructure/EGDbContext.cs` -> `AddTrigger<T>()` -> `ToTable(tb => tb.HasTrigger(triggerName))`.
### Trigger definitions observed in DB snapshot
1) `TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_INS`
- Fires on insert of `TBSIG_DOCUMENT_RECEIVER_ELEMENT`.
- Normalizes/rewrites `POSITION_X`, `POSITION_Y` and sets `CHANGED_WHO='TBSIG_DOC_REC_EL_AFT_INS'`.
2) `TBSIG_DOCUMENT_RECEIVER_ELEMENT_AFT_UPD`
- Fires on update of `TBSIG_DOCUMENT_RECEIVER_ELEMENT`.
- Rewrites coordinates and `CHANGED_WHO='TBSIG_DOC_REC_EL_AFT_UPD'` except when `CHANGED_WHO='OFFSET_Y_POS'`.
3) `TBSIG_ENVELOPE_HISTORY_AFT_INS`
- Fires on history insert.
- Central state machine side-effects:
- syncs `TBSIG_ENVELOPE.STATUS` for selected `100x` states,
- on `1003` applies Y-offset update to receiver elements,
- on `2005` calculates partial/fully signed and may insert follow-up `1006` history,
- on `2007` (type-dependent reject path) writes rejection emails into `TBEMLP_EMAIL_OUT` and updates envelope rejected state,
- on `3001` invitation-message counting can move envelope to `1004`.
4) `TBSIG_EMAIL_TEMPLATE_AFT_UPD`
- Fires on update of `TBSIG_EMAIL_TEMPLATE`.
- Sets `CHANGED_WHEN = GETDATE()`.
5) `TBSIG_ENVELOPE_RECEIVER_READ_ONLY_UPD`
- Fires on update of `TBSIG_ENVELOPE_RECEIVER_READ_ONLY`.
- Updates config timestamp row (`TBSIG_CONFIG.GUID = 1`).
6) `TBEMLP_EMAIL_OUT_AFT_UPD`
- Fires on update of `TBEMLP_EMAIL_OUT`.
- When `EMAIL_SENT` changes and `WF_ID in (3001..3005)`, writes corresponding history entries to `TBSIG_ENVELOPE_HISTORY`.
### Trigger mismatch alert (important)
- `DbTriggerParams` contains `TBEMLP_EMAIL_OUT_AFT_INS`, but this trigger was not found in the snapshot DB (`ExistsInDb = 0`).
- `TBEMLP_EMAIL_OUT_AFT_UPD` exists and is active.
This should be treated as environment drift and validated before migrations.
## Known model-vs-DB drift risks
These are not necessarily bugs, but they are migration risk hotspots:
- `Receiver.EmailAddress` is `[StringLength(250)]` in entity, DB column is `nvarchar(128)`.
- `EnvelopeReceiverReadOnly.AddedWho` entity expects `nvarchar(250)`, DB column is `varchar(100)`.
- `EnvelopeReceiver` entity does not model DB columns `GUID` and `PARENT_ENVELOPE_RECEIVER_ID`.
- `Envelope` entity does not explicitly model some DB columns (`VERIFICATION_TYPE`, `LANG_CODE`, `SOURCE`).
- `EmailOut` entity has `[NotMapped] AddedWhen`, while DB has `ADDED_WHEN` column.
Before schema changes, reconcile these deltas intentionally (either mapping update or documented accepted divergence).
## Migration-safe operating rules
- Do not rely on implicit trigger behavior; document expected side effects in feature code touching history/status flows.
- Any change to `TBSIG_ENVELOPE_HISTORY` semantics requires trigger impact review first.
- Keep `DbTriggerParams` synchronized with actual DB trigger inventory per environment.
- For stored procedure changes, update both:
- SQL wrapper templates (`Application/Common/SQL/*`)
- direct SQL callers in controllers/executors.
## Refresh checklist (read-only)
Run these metadata checks before/after DB migration:
1. Column inventory per table (`INFORMATION_SCHEMA.COLUMNS`).
2. Constraint inventory (`INFORMATION_SCHEMA.TABLE_CONSTRAINTS` + `KEY_COLUMN_USAGE`).
3. Trigger inventory (`sys.triggers`) and definitions (`OBJECT_DEFINITION`).
4. Stored procedure inventory (`sys.procedures`) and definitions (`OBJECT_DEFINITION`).
5. Repo call-site grep for `PRSIG_` names to detect dead or missing integration points.

View File

@@ -0,0 +1,17 @@
namespace EnvelopeGenerator.Application.Common.Configurations;
/// <summary>
///
/// </summary>
public class ESignParams
{
/// <summary>
///
/// </summary>
public required string ApplicationName { get; set; }
/// <summary>
///
/// </summary>
public string DefaultReason { get; set; } = string.Empty;
}

View File

@@ -1,12 +1,14 @@
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Interfaces;
namespace EnvelopeGenerator.Application.Common.Dto;
// TODO: Add the “Reason” property and integrate with UI and the digital signature process.
/// <summary>
/// Data Transfer Object representing a positioned element assigned to a document receiver.
/// </summary>
public class DocReceiverElementDto : IDocReceiverElement
public class DocReceiverElementDto : IDocReceiverElement, ISignatureInkPayload
{
/// <summary>
/// Gets or sets the unique identifier of the element.
@@ -18,11 +20,21 @@ public class DocReceiverElementDto : IDocReceiverElement
/// </summary>
public int DocumentId { get; set; }
/// <summary>
///
/// </summary>
public virtual DocumentDto? Document { get; set; }
/// <summary>
/// Gets or sets the identifier of the receiver.
/// </summary>
public int ReceiverId { get; set; }
/// <summary>
///
/// </summary>
public virtual ReceiverDto? Receiver { get; set; }
/// <summary>
/// Gets or sets the type of the element.
/// </summary>
@@ -122,4 +134,41 @@ public class DocReceiverElementDto : IDocReceiverElement
///
/// </summary>
public byte[]? Ink { get; set; }
}
/// <summary>
/// Returns the stored signature bytes as an image payload with detected MIME type.
/// </summary>
/// <exception cref="InvalidOperationException">Thrown when signature image bytes do not exist.</exception>
public (byte[] Bytes, string ContentType, string FileExtension) ToImage()
{
if (Ink is not { Length: > 0 })
throw new InvalidOperationException("Signature image (Ink) does not exist.");
if (HasPrefix(Ink, 0x89, 0x50, 0x4E, 0x47))
return (Ink, "image/png", ".png");
if (HasPrefix(Ink, 0xFF, 0xD8, 0xFF))
return (Ink, "image/jpeg", ".jpg");
if (HasPrefix(Ink, 0x52, 0x49, 0x46, 0x46) &&
Ink.Length >= 12 &&
Ink[8] == 0x57 && Ink[9] == 0x45 && Ink[10] == 0x42 && Ink[11] == 0x50)
return (Ink, "image/webp", ".webp");
return (Ink, "application/octet-stream", ".bin");
}
private static bool HasPrefix(byte[] data, params byte[] prefix)
{
if (data.Length < prefix.Length)
return false;
for (var i = 0; i < prefix.Length; i++)
{
if (data[i] != prefix[i])
return false;
}
return true;
}
}

View File

@@ -0,0 +1,54 @@
using EnvelopeGenerator.Application.Common.Enums;
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
///
/// </summary>
public sealed record PdfSignOptionsDto
{
/// <summary>
///
/// </summary>
public long DocumentElementId { get; set; }
/// <summary>
///
/// </summary>
public string Name { get; set; } = null!;
/// <summary>
///
/// </summary>
public string Reason { get; set; } = null!;
/// <summary>
///
/// </summary>
public string Location { get; set; } = null!;
/// <summary>
///
/// </summary>
public string? Email { get; set; }
/// <summary>
///
/// </summary>
public string? Phone { get; set; }
/// <summary>
///
/// </summary>
public string ApplicationName { get; set; } = null!;
/// <summary>
///
/// </summary>
public PdfSignatureCertificationLevel CertificationLevel { get; set; }
/// <summary>
///
/// </summary>
public PdfSignatureAppearanceDto? Appearance { get; set; }
}

View File

@@ -0,0 +1,96 @@
namespace EnvelopeGenerator.Application.Common.Dto;
/// <summary>
///
/// </summary>
public enum PdfSignatureCoordinateUnit
{
/// <summary>
///
/// </summary>
Point = 0,
/// <summary>
///
/// </summary>
Inch = 1
}
/// <summary>
///
/// </summary>
public enum PdfSignatureCoordinateOrigin
{
/// <summary>
///
/// </summary>
BottomLeft = 0,
/// <summary>
///
/// </summary>
TopLeft = 1
}
/// <summary>
///
/// </summary>
/// <param name="PageNumber"></param>
/// <param name="Left"></param>
/// <param name="Bottom"></param>
/// <param name="Width"></param>
/// <param name="Height"></param>
/// <param name="ShowLabels"></param>
/// <param name="ShowName"></param>
/// <param name="ShowDate"></param>
/// <param name="ShowReason"></param>
/// <param name="ShowLocation"></param>
/// <param name="ShowDistinguishedName"></param>
/// <param name="DateTimeFormat"></param>
/// <param name="CoordinateUnit"></param>
/// <param name="CoordinateOrigin"></param>
public sealed record PdfSignatureAppearanceDto(
int PageNumber,
double Left,
double Bottom,
double Width,
double Height,
bool ShowLabels = false,
bool ShowName = false,
bool ShowDate = false,
bool ShowReason = false,
bool ShowLocation = false,
bool ShowDistinguishedName = false,
string DateTimeFormat = "dd.MM.yyyy HH:mm:ss zzz",
PdfSignatureCoordinateUnit CoordinateUnit = PdfSignatureCoordinateUnit.Point,
PdfSignatureCoordinateOrigin CoordinateOrigin = PdfSignatureCoordinateOrigin.BottomLeft)
{
/// <summary>
///
/// </summary>
/// <param name="FamilyName"></param>
/// <param name="Size"></param>
/// <param name="IsBold"></param>
/// <param name="IsItalic"></param>
/// <param name="IsUnderline"></param>
public sealed record FontDto(string FamilyName = "Helvetica", float Size = 10, bool IsBold = false, bool IsItalic = false, bool IsUnderline = false)
{
/// <summary>
///
/// </summary>
public RgbaColorDto Color { get; init; } = new();
}
/// <summary>
///
/// </summary>
/// <param name="R"></param>
/// <param name="G"></param>
/// <param name="B"></param>
public sealed record RgbaColorDto(double R = 0, double G = 0, double B = 0);
/// <summary>
///
/// </summary>
public FontDto Font { get; init; } = new();
}

View File

@@ -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,
long? DocumentElementId,
string? OriginalDocumentSha256,
string? MessageDigestHex,
string? Error);
public sealed record PdfSignatureCheckResultDto(
bool Signed,
IReadOnlyList<PdfSignatureCheckItemDto> Signatures);

View File

@@ -0,0 +1,27 @@
namespace EnvelopeGenerator.Application.Common.Enums;
/// <summary>
///
/// </summary>
public enum PdfSignatureCertificationLevel
{
/// <summary>
/// All changes are allowed
/// </summary>
NoCertification = 0,
/// <summary>
/// No changes are allowed
/// </summary>
NoChangesAllowed,
/// <summary>
/// Allows users to fill in forms and sign the document.
/// </summary>
FillForms,
/// <summary>
/// Allows users to fill in forms, create, modify and delete annotations, and sign the document.
/// </summary>
FillFormsAndAnnotate
}

View File

@@ -1,8 +1,66 @@
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Domain.Interfaces;
namespace EnvelopeGenerator.Application.Common.Interfaces.Doc;
/// <summary>
///
/// </summary>
public interface IPdfPageImageRenderer
{
/// <summary>
///
/// </summary>
/// <param name="pdfBytes"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
Task<IReadOnlyList<PdfPageImageDto>> RenderPagesAsImagesAsync(byte[] pdfBytes, CancellationToken cancellationToken = default);
/// <summary>
///
/// </summary>
/// <param name="element"></param>
/// <returns></returns>
Stream ComposeSignaturePreviewPng(ISignatureInkPayload element);
/// <summary>
///
/// </summary>
/// <param name="bytes"></param>
/// <returns></returns>
public bool IsSupportedImageFormat(byte[] bytes);
}
/// <summary>
///
/// </summary>
public static class PdfPageImageRendererExtensions
{
/// <summary>
///
/// </summary>
/// <param name="renderer"></param>
/// <param name="inkBytes"></param>
/// <returns></returns>
/// <exception cref="BadRequestException"></exception>
public static byte[] EnsureSupportedImageBytes(this IPdfPageImageRenderer renderer, byte[] inkBytes)
{
if (!renderer.IsSupportedImageFormat(inkBytes))
throw new BadRequestException("Only PNG and JPEG signature formats are supported.");
return inkBytes;
}
public static byte[] ComposeSignaturePreviewPngBytes(this IPdfPageImageRenderer renderer, ISignatureInkPayload element)
{
using var stream = renderer.ComposeSignaturePreviewPng(element);
if (stream is MemoryStream memoryStream)
return memoryStream.ToArray();
using var copy = new MemoryStream();
stream.CopyTo(copy);
return copy.ToArray();
}
}

View File

@@ -0,0 +1,29 @@
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="image">Optional image stream for signature appearance.</param>
/// <param name="cancellationToken">Cancellation token for cooperative cancellation.</param>
Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, Stream? image = null, 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);
}

View File

@@ -0,0 +1,37 @@
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
/// <summary>
///
/// </summary>
public record DocumentSignedNotification
{
/// <summary>
///
/// </summary>
public required string DocumentTitle { get; init; }
/// <summary>
///
/// </summary>
public required string NameReceiver { get; init; }
/// <summary>
///
/// </summary>
public string NamePortal { get; init; } = "signFLOW Portal";
/// <summary>
///
/// </summary>
public required byte[] Attachment { get; init; }
}
/*
Umschlag abgeschlossen: '[DOCUMENT_TITLE]'
---
Guten Tag [NAME_RECEIVER],<br /> <br /> Der Vorgang <B><I>'[DOCUMENT_TITLE]'</I></B> wurde erfolgreich abgeschlossen.<br /> <br /> Sie erhalten das Dokument mit einem detaillierten Ergebnisbericht als Anhang zu dieser Email.<br /> <br /> Mit freundlichen Grüßen<br /> <br /> [NAME_PORTAL]
*/

View File

@@ -0,0 +1,16 @@
namespace EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
/// <summary>
///
/// </summary>
public interface IEnvelopeNotificationService
{
/// <summary>
///
/// </summary>
/// <param name="email"></param>
/// <param name="documentSigned"></param>
/// <param name="cancel"></param>
/// <returns></returns>
public Task Notify(string email, DocumentSignedNotification documentSigned, CancellationToken cancel = default);
}

View File

@@ -51,6 +51,7 @@ public static class DependencyInjection
services.Configure<MailParams>(config.GetSection(nameof(MailParams)));
services.Configure<AuthenticatorParams>(config.GetSection(nameof(AuthenticatorParams)));
services.Configure<TotpSmsParams>(config.GetSection(nameof(TotpSmsParams)));
services.Configure<ESignParams>(config.GetSection(nameof(ESignParams)));
services.AddHttpClientService<GtxMessagingParams>(config.GetSection(nameof(GtxMessagingParams)));
services.TryAddScoped<ISmsSender, GTXSmsSender>(); // Changed: Singleton → Scoped

View File

@@ -14,6 +14,15 @@ namespace EnvelopeGenerator.Application.DocReceiverElements.Queries;
/// </summary>
public record ReadDocReceiverElementQuery : EnvelopeReceiverQueryBase, IRequest<IEnumerable<DocReceiverElementDto>>
{
/// <summary>
///
/// </summary>
public int? Id { get; set; } = null;
/// <summary>
///
/// </summary>
public bool IncludeDocument { get; set; } = false;
}
/// <summary>
@@ -47,7 +56,10 @@ public class ReadDocReceiverElementQueryHandler : IRequestHandler<ReadDocReceive
{
var q = _repository.Query;
if(request.Envelope.Id is int envelopeId)
if (request.Id is not null)
q = q.Where(e => e.Id == request.Id);
if (request.Envelope.Id is int envelopeId)
q = q.Where(e => e.Document.EnvelopeId == envelopeId);
if (request.Envelope.Uuid is string envelopeUuid)
@@ -59,6 +71,9 @@ public class ReadDocReceiverElementQueryHandler : IRequestHandler<ReadDocReceive
if (request.Receiver.Signature is string signature)
q = q.Where(e => e.Receiver.Signature == signature);
if( request.IncludeDocument)
q = q.Include(e => e.Document);
var elements = await q.ToListAsync(cancellationToken);
return _mapper.Map<IEnumerable<DocReceiverElementDto>>(elements);

View File

@@ -2,7 +2,7 @@
using MediatR;
using System.ComponentModel.DataAnnotations;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
/// <summary>
/// Befehl zur Erstellung eines Umschlags.

View File

@@ -4,7 +4,7 @@ using MediatR;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
/// <summary>
/// Handles the creation of an envelope along with its associated document and recipients.

View File

@@ -1,7 +1,7 @@
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Dto.Receiver;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
namespace EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
/// <summary>
///

View File

@@ -21,7 +21,24 @@ namespace EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
/// </remarks>
public record ReadEnvelopeReceiverSecretQuery
: EnvelopeReceiverQueryBase<ReadEnvelopeQuery, ReadReceiverQuery>,
IRequest<EnvelopeReceiverSecretDto?>;
IRequest<EnvelopeReceiverSecretDto?>
{
/// <summary>
///
/// </summary>
public ReadEnvelopeReceiverSecretQuery() { }
/// <summary>
///
/// </summary>
/// <param name="envelopeId"></param>
/// <param name="receiverId"></param>
public ReadEnvelopeReceiverSecretQuery(int envelopeId, int receiverId)
{
Envelope.Id = envelopeId;
Receiver.Id = receiverId;
}
}
/// <summary>
/// Extension methods for dispatching <see cref="ReadEnvelopeReceiverSecretQuery"/> via <see cref="IMediator"/>.

View File

@@ -1,5 +1,5 @@
using AutoMapper;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
using EnvelopeGenerator.Application.Envelopes.Commands;
using EnvelopeGenerator.Domain.Entities;

View File

@@ -0,0 +1,62 @@
using AutoMapper;
using DigitalData.Core.Abstraction.Application.Repository;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.Envelopes.Queries;
/// <summary>
///
/// </summary>
public record ReadSignedEnvelopeQuery : IRequest<IEnumerable<EnvelopeDto>>
{
/// <summary>
///
/// </summary>
public bool? Burned { get; init; } = null;
internal int? UserId { get; private set; } = null;
/// <summary>
///
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public ReadSignedEnvelopeQuery Authorized(int userId)
{
UserId = userId;
return this;
}
}
/// <summary>
///
/// </summary>
/// <param name="Repo"></param>
/// <param name="Mapper"></param>
public class ReadSignedEnvelopeQueryHandler(IRepository<Envelope> Repo, IMapper Mapper) : IRequestHandler<ReadSignedEnvelopeQuery, IEnumerable<EnvelopeDto>>
{
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public async Task<IEnumerable<EnvelopeDto>> Handle(ReadSignedEnvelopeQuery request, CancellationToken cancel)
{
var eQuery = Repo.Query.Where(e => e.Status == Domain.Constants.EnvelopeStatus.EnvelopeCompletelySigned);
if (request.Burned is bool burned)
eQuery = burned ? eQuery.Where(e => e.DocResult != null) : eQuery.Where(e => e.DocResult == null);
if(request.UserId is int userId)
eQuery = eQuery.Where(e => e.UserId == userId);
var envelopes = await eQuery.ToListAsync(cancel);
return Mapper.Map<IEnumerable<EnvelopeDto>>(envelopes);
}
}

View File

@@ -0,0 +1,108 @@
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
using EnvelopeGenerator.Application.Common.Query;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Interfaces;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.PdfSigning;
/// <summary>
///
/// </summary>
public record FinalizeEnvelopeCommand : EnvelopeQueryBase, IRequest
{
internal int? UserId { get; private set; } = null;
/// <summary>
///
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public FinalizeEnvelopeCommand Authorized(int userId)
{
UserId = userId;
return this;
}
/// <summary>
///
/// </summary>
public bool BurnedAllowed { get; init; } = false;
/// <summary>
///
/// </summary>
public bool SaveDocResult { get; init; } = true;
}
/// <summary>
///
/// </summary>
/// <param name="Repo"></param>
/// <param name="Sender"></param>
/// <param name="NtfServer"></param>
public class FinalizeEnvelopeCommandHandler(IRepository<Envelope> Repo, ISender Sender, IEnvelopeNotificationService NtfServer) : IRequestHandler<FinalizeEnvelopeCommand>
{
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
/// <exception cref="NotFoundException"></exception>
public async Task Handle(FinalizeEnvelopeCommand request, CancellationToken cancel)
{
var envelopeQuery = Repo.Query;
if (request.Id is int id)
envelopeQuery = envelopeQuery.Where(e => e.Id == id);
else if (request.Uuid is string uuid)
envelopeQuery = envelopeQuery.Where(e => e.Uuid == uuid);
var envelope = await envelopeQuery
.Include(e => e.EnvelopeReceivers).ThenInclude(er => er.Receiver)
.Include(e => e.Documents)
.SingleOrDefaultAsync(cancel)
?? throw new NotFoundException($"Envelope is not found ID: {request.Id} UUID: {request.Uuid}");
if(request.UserId is int userId && userId != envelope.UserId)
throw new InvalidOperationException($"Envelope is not owned by user ID: {request.Id} UUID: {request.Uuid}");
// already burned
if (!request.BurnedAllowed && envelope.IsBurned())
throw new InvalidOperationException($"Envelope is already burned ID: {request.Id} UUID: {request.Uuid}");
// envelope is not completely signed
if (envelope.Status!= EnvelopeStatus.EnvelopeCompletelySigned)
throw new InvalidOperationException($"Envelope status is not completely signed ID: {request.Id} UUID: {request.Uuid}");
using var docStream = await Sender.Send(new SignPdfByEnvelopeCommand()
{
Envelope = envelope
}, cancel);
if (docStream.CanSeek)
docStream.Position = 0;
using var ms = new MemoryStream();
docStream.CopyTo(ms);
byte[] doc = ms.ToArray();
foreach (var er in envelope.EnvelopeReceivers)
{
await NtfServer.Notify(er.Receiver.EmailAddress, new DocumentSignedNotification()
{
DocumentTitle = envelope.Title,
NameReceiver = er.Name,
Attachment = doc
}, cancel);
}
if (request.SaveDocResult)
await Repo.UpdateAsync(e => e.DocResult = doc, e => e.Id == envelope.Id, cancel);
}
}

View File

@@ -0,0 +1,133 @@
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Enums;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.DocReceiverElements.Queries;
using MediatR;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Application.PdfSigning;
/// <summary>
///
/// </summary>
public record SignPdfByElementCommand : IRequest<Stream>
{
/// <summary>
///
/// </summary>
public DocReceiverElementDto? Element { get; set; }
/// <summary>
///
/// </summary>
public int? ElementId { get; init; }
/// <summary>
/// Custom document
/// </summary>
public Stream? Document { get; set; }
/// <summary>
///
/// </summary>
public string? Phone { get; set; }
/// <summary>
///
/// </summary>
public string? Reason { get; set; }
/// <summary>
///
/// </summary>
public PdfSignatureCertificationLevel CertificationLevel { get; init; } = PdfSignatureCertificationLevel.NoChangesAllowed;
}
/// <summary>
///
/// </summary>
/// <param name="sender"></param>
/// <param name="signService"></param>
/// <param name="imgRenderer"></param>
/// <param name="options"></param>
public class SignPdfCommandHandler(ISender sender, IPdfDigitalSignatureService signService, IPdfPageImageRenderer imgRenderer, IOptionsMonitor<ESignParams> options) : IRequestHandler<SignPdfByElementCommand, Stream>
{
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<Stream> Handle(SignPdfByElementCommand request, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
// ensure element
if (request.Element is null)
{
var elements = await sender.Send(new ReadDocReceiverElementQuery()
{
Id = request.ElementId!,
IncludeDocument = request.Document is null
}, cancellationToken);
request.Element = elements.FirstOrDefault() ?? throw new NotFoundException($"Element with ID {request.ElementId} not found."); ;
}
// ensure document
if(request.Document is null)
{
if (request.Element?.Document?.ByteData is not { Length: > 0 })
throw new NotFoundException("Document data is missing or empty.");
request.Document = new MemoryStream(request.Element.Document.ByteData);
}
ValidateAfterDataIsComplete(request);
// create signature image from element
var img = imgRenderer.ComposeSignaturePreviewPng(request.Element);
// sign pdf
var outputPdf = new MemoryStream();
await signService.SignPdfAsync(
inputPdf: request.Document,
outputPdf: outputPdf,
options: new PdfSignOptionsDto
{
Name = request.Element.FullName!,
Location = request.Element.Position!,
Phone = request.Phone,
Reason = request.Reason ?? options.CurrentValue.DefaultReason,
Email = request.Element.Receiver!.EmailAddress,
ApplicationName = options.CurrentValue.ApplicationName,
DocumentElementId = request.Element.Id,
CertificationLevel = request.CertificationLevel,
Appearance = new PdfSignatureAppearanceDto(
PageNumber: request.Element.Page,
Left: request.Element.Left,
Bottom: request.Element.Y,
Width: request.Element.Width,
Height: request.Element.Height,
CoordinateUnit: PdfSignatureCoordinateUnit.Inch,
CoordinateOrigin: PdfSignatureCoordinateOrigin.TopLeft)
},
image: img,
cancellationToken: cancellationToken);
return outputPdf;
}
private static void ValidateAfterDataIsComplete(SignPdfByElementCommand request)
{
if(request.Element!.FullName is null)
throw new BadRequestException($"Document is not signed. FullName is required. Element ID: {request.Element.Id}");
else if(request.Element.Position is null)
throw new BadRequestException($"Document is not signed. Position is required. Element ID: {request.Element.Id}");
}
}

View File

@@ -0,0 +1,18 @@
using FluentValidation;
namespace EnvelopeGenerator.Application.PdfSigning;
public class SignPdfByElementCommandValidator : AbstractValidator<SignPdfByElementCommand>
{
public SignPdfByElementCommandValidator()
{
RuleFor(x => x)
.Must(x => (x.Element is not null) ^ x.ElementId.HasValue)
.WithMessage("Exactly one of Element or ElementId must be provided.");
RuleFor(x => x.ElementId)
.GreaterThan(0)
.When(x => x.ElementId.HasValue)
.WithMessage("ElementId must be greater than 0.");
}
}

View File

@@ -0,0 +1,94 @@
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Query;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Exceptions;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.PdfSigning;
/// <summary>
///
/// </summary>
public record SignPdfByEnvelopeCommand : EnvelopeQueryBase, IRequest<Stream>
{
/// <summary>
///
/// </summary>
public int? ReceiverId { get; init; } = null;
internal int? UserId { get; private set; } = null;
/// <summary>
///
/// </summary>
/// <param name="userId"></param>
/// <returns></returns>
public SignPdfByEnvelopeCommand Authorized(int userId)
{
UserId = userId;
return this;
}
internal Envelope? Envelope { get; set; }
}
/// <summary>
///
/// </summary>
public class SignPdfByEnvelopeCommandHandler(IRepository<Envelope> Repo, ISender sender) : IRequestHandler<SignPdfByEnvelopeCommand, Stream>
{
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
/// <exception cref="NotImplementedException"></exception>
public async Task<Stream> Handle(SignPdfByEnvelopeCommand request, CancellationToken cancel)
{
if (request.Envelope is null)
{
var envelopeQuery = Repo.Query;
if (request.Id is int id)
envelopeQuery = envelopeQuery.Where(e => e.Id == id);
else if (request.Uuid is string uuid)
envelopeQuery = envelopeQuery.Where(e => e.Uuid == uuid);
request.Envelope = await envelopeQuery.Include(e => e.EnvelopeReceivers).Include(e => e.Documents).SingleOrDefaultAsync(cancel)
?? throw new NotFoundException($"Envelope is not found ID: {request.Id} UUID: {request.Uuid}");
}
var envelope = request.Envelope;
if (request.UserId is int userId && userId != envelope.UserId)
throw new AuthorizationException($"Envelope is not authorized for user with Id: {request.UserId} Envelope ID: {request.Id} UUID: {request.Uuid}");
if (envelope.EnvelopeReceivers.Count == 0)
throw new NotFoundException($"Envelope has no receivers to sign ID: {request.Id} UUID: {request.Uuid}");
if(envelope.Documents.FirstOrDefault() is not Document document || document.ByteData is not byte[] byteData || byteData.Length == 0)
throw new NotFoundException($"Envelope has no documents to sign ID: {request.Id} UUID: {request.Uuid}");
Stream doc = new MemoryStream(byteData);
var eReceivers = request.ReceiverId is int receiverId
? envelope.EnvelopeReceivers.Where(e => e.ReceiverId == receiverId)
: envelope.EnvelopeReceivers;
foreach (var eReceiver in eReceivers)
{
var signedDoc = await sender.Send(new SignPdfByEnvelopeReceiverCommand()
{
EnvelopeId = eReceiver.EnvelopeId,
ReceiverId = eReceiver.ReceiverId
}, cancel);
await doc.DisposeAsync();
doc = signedDoc;
}
return doc;
}
}

View File

@@ -0,0 +1,99 @@
using AutoMapper;
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Enums;
using EnvelopeGenerator.Application.Documents.Queries;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Envelopes.Queries;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Exceptions;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Application.PdfSigning;
/// <summary>
///
/// </summary>
public record SignPdfByEnvelopeReceiverCommand : IRequest<Stream>
{
/// <summary>
///
/// </summary>
public int EnvelopeId { get; init; }
/// <summary>
///
/// </summary>
public int ReceiverId { get; init; }
/// <summary>
///
/// </summary>
public Stream? Document { get; set; }
}
/// <summary>
///
/// </summary>
public class SignPdfByEnvelopeReceiverCommandHandler(IRepository<DocReceiverElement> Repo, IMapper Mapper, ISender Sender) : IRequestHandler<SignPdfByEnvelopeReceiverCommand, Stream>
{
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
/// <exception cref="NotFoundException"></exception>
public async Task<Stream> Handle(SignPdfByEnvelopeReceiverCommand request, CancellationToken cancel)
{
cancel.ThrowIfCancellationRequested();
// read elements
var elements = await Repo.Where(el => el.ReceiverId == request.ReceiverId && el.Document.EnvelopeId == request.EnvelopeId)
.Include(e => e.Receiver)
.ToListAsync(cancel) ?? throw new NotFoundException($"No elements found for Envelope ID {request.EnvelopeId} and Receiver ID {request.ReceiverId}.");
var elementDtos = Mapper.Map<List<DocReceiverElementDto>>(elements)
.OrderBy(e => e.Page)
.ThenBy(e => e.Id)
.ToList();
// read secrets for phone number
var erSecrets = await Sender.Send(new ReadEnvelopeReceiverSecretQuery()
{
Envelope = new() { Id = request.EnvelopeId },
Receiver = new() { Id = request.ReceiverId }
}, cancel) ?? throw new DataIntegrityException($"No secrets found for Envelope ID {request.EnvelopeId} and Receiver ID {request.ReceiverId}.");
// read document
if(request.Document is null)
{
var unsignedDoc = await Sender.Send(new ReadDocumentQuery(EnvelopeId: request.EnvelopeId), cancel);
var unsignedDocBytes = unsignedDoc.ByteData ?? throw new NotFoundException($"Document not found for Envelope ID {request.EnvelopeId} and Receiver ID {request.ReceiverId}.");
request.Document = new MemoryStream(unsignedDocBytes);
}
Stream doc = request.Document;
// sign document for each element
foreach (var element in elementDtos)
{
var lastElement = element == elementDtos.Last();
var signedDoc = await Sender.Send(new SignPdfByElementCommand
{
Document = doc,
Element = element,
CertificationLevel = lastElement ? PdfSignatureCertificationLevel.NoChangesAllowed : PdfSignatureCertificationLevel.FillFormsAndAnnotate,
Phone = erSecrets.PhoneNumber
}, cancel);
await doc.DisposeAsync();
doc = signedDoc;
}
return doc;
}
}

View File

@@ -67,7 +67,7 @@ namespace EnvelopeGenerator.Domain.Constants
{
public static bool IsActive(this EnvelopeStatus status)
{
return status >= EnvelopeStatus.EnvelopeCreated && status < EnvelopeStatus.EnvelopePartlySigned;
return status >= EnvelopeStatus.EnvelopeCreated && status <= EnvelopeStatus.EnvelopePartlySigned;
}
public static bool IsCompleted(this EnvelopeStatus status)

View File

@@ -11,7 +11,7 @@ using System.Collections.Generic;
namespace EnvelopeGenerator.Domain.Entities
{
[Table("TBSIG_DOCUMENT_RECEIVER_ELEMENT", Schema = "dbo")]
public class DocReceiverElement : IDocReceiverElement, IHasReceiver, IHasAddedWhen, IUpdateAuditable
public class DocReceiverElement : IDocReceiverElement, ISignatureInkPayload, IHasReceiver, IHasAddedWhen, IUpdateAuditable
{
public DocReceiverElement()
{

View File

@@ -137,7 +137,6 @@ namespace EnvelopeGenerator.Domain.Entities
= false;
#endif
[NotMapped]
[Column("DOC_RESULT")]
public byte[]
#if nullable

View File

@@ -2,6 +2,7 @@
<PropertyGroup>
<TargetFrameworks>net462;net7.0;net8.0;net9.0</TargetFrameworks>
<LangVersion>latest</LangVersion>
<AssemblyVersion>1.4.0</AssemblyVersion>
<FileVersion>1.4.0</FileVersion>
</PropertyGroup>

View File

@@ -0,0 +1,17 @@
#if NETFRAMEWORK
using System;
#endif
namespace EnvelopeGenerator.Domain.Exceptions
{
public class AuthorizationException : Exception
{
public AuthorizationException(string message) : base(message)
{
}
public AuthorizationException()
{
}
}
}

View File

@@ -0,0 +1,17 @@
#if NETFRAMEWORK
using System;
#endif
namespace EnvelopeGenerator.Domain.Exceptions
{
public class DataIntegrityException : Exception
{
public DataIntegrityException(string message) : base(message)
{
}
public DataIntegrityException() : base()
{
}
}
}

View File

@@ -3,6 +3,12 @@
public interface IEnvelope
{
int? EnvelopeTypeId { get; set; }
byte[]
#if nullable
?
#endif
DocResult { get; }
}
public static class EnvelopeExtensions
@@ -16,5 +22,10 @@
{
return envelope.EnvelopeTypeId != 2;
}
public static bool IsBurned(this IEnvelope envelope)
{
return envelope.DocResult != null;
}
}
}

View File

@@ -0,0 +1,28 @@
#if NETFRAMEWORK
using System;
#endif
namespace EnvelopeGenerator.Domain.Interfaces
{
public interface ISignatureInkPayload
{
#if nullable
byte[]? Ink { get; }
string? FullName { get; }
string? Position { get; }
string? Place { get; }
#else
byte[] Ink { get; }
string FullName { get; }
string Position { get; }
string Place { get; }
#endif
DateTime? ChangedWhen { get; }
}
}

View File

@@ -0,0 +1,23 @@
using DocumentService.Client.Configuration;
using DocumentService.Client.Extensions;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Infrastructure.Crypt.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace EnvelopeGenerator.Infrastructure.Crypt.DependencyInjection;
public static class ServiceCollectionExtensions
{
public static IServiceCollection AddCryptInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
var baseUrl = configuration["DocumentService:BaseUrl"]
?? throw new InvalidOperationException("DocumentService:BaseUrl is not configured.");
services.AddDocumentServiceClients(options => options.BaseUrl = baseUrl);
services.AddSingleton<IPdfDigitalSignatureService, PdfDigitalSignatureService>();
return services;
}
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DocumentService.Client" Version="1.3.2" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,399 +1,384 @@
# EnvelopeGenerator.Infrastructure.Crypt
This project provides PKCS#11-based cryptographic infrastructure for FES (Advanced Electronic Signature) workflows.
Umfassende technische Dokumentation der PKCS#11/SoftHSM-Integration fuer `EnvelopeGenerator`.
If you are new to HSM/SoftHSM: this README is written to let you start from zero and still understand the full path from setup to testing.
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) What this project is (and what it is not)
## 1. Architekturueberblick
This project is the cryptography layer only.
### 1.1 Ziel
- It signs hashes using private keys stored in HSM/SoftHSM.
- It reads certificates from HSM/SoftHSM.
- It verifies signatures.
- It exposes service abstractions for Application/Server integration.
`EnvelopeGenerator.Infrastructure.Crypt` stellt die kryptographische Infrastruktur fuer PDF-Signaturen bereit:
It is **not**:
- Signaturerzeugung mit Schluesseln in SoftHSM/PKCS#11
- Zertifikat-Auslese aus Token
- Signaturpruefung auf PDF-Ebene
- Health/Startup-Selbsttest fuer Betriebsfaehigkeit
- a REST API by itself,
- a UI component,
- a full document workflow orchestrator.
### 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) SoftHSM and PKCS#11 in one minute
## 2. Linux-Seite: SoftHSM + Proxy Setup
- `SoftHSMv2` is a software HSM implementation.
- It does **not** provide a REST endpoint.
- You access it through a PKCS#11 library/module (`.so`/`.dll`).
- Your app loads that library and calls PKCS#11 functions.
### 2.1 Basispakete
Two deployment patterns are possible:
Beispiel (Debian/Ubuntu):
1. **Direct local module**
- Example library path: `libsofthsm2.so`
2. **PKCS#11 proxy module (remote HSM/SoftHSM)**
- Example library path: `libpkcs11-proxy.so` or proxy DLL
Both are valid for this project.
---
## 3) How this maps to the FES diagram (Ablauf SignFlow Unternehmenszertifikat)
From the shared draw.io/PDF process:
- Step 6: Backend computes document hash and sends it with PIN context to HSM.
- Implemented by: `ISignatureProvider` / `Pkcs11SignatureProvider`
- Step 7: HSM returns digital signature (and cert context can be resolved).
- Implemented by: `Pkcs11SignatureProvider` + `ICertificateProvider`
- Step 8: Backend embeds signature + certificate into PDF and creates audit trail.
- This project provides crypto primitives for this step.
- PDF embedding and audit document composition are integrated in later layers.
So this project is the cryptographic core for diagram steps 6-8.
Actor mapping from the diagram:
- `Ersteller` (sender): creates envelope and defines recipients.
- `Signierer` (recipient/signer): completes 2FA and signs.
- `Backend` (our stack): computes hash, requests HSM signature, embeds proof in PDF.
- `HSM/SoftHSM`: stores private keys and performs cryptographic signing.
- `CA`: issues/renews certificates.
---
## 4) Service surface
Main interfaces:
- `ISignatureProvider`
- `ICertificateProvider`
- `ISignatureVerifier`
- `ICryptographicHealthCheck`
DI entry point:
- `CryptDependencyInjection.AddCryptInfrastructure(...)`
---
## 5) Required configuration
Configuration section: `Crypt:Pkcs11`
Typical keys:
```json
{
"Crypt": {
"Pkcs11": {
"LibraryPath": "/usr/lib/softhsm/libsofthsm2.so",
"SlotId": 0,
"TokenLabel": null,
"UserPin": "***",
"PrivateKeyLabel": "sign-key",
"PrivateKeyIdHex": null,
"CertificateLabel": "sign-cert",
"CertificateIdHex": null
}
}
}
```bash
sudo apt-get update && sudo apt-get install -y softhsm2 opensc p11-kit libengine-pkcs11-openssl
```
Security rule:
- Do not commit real PINs to source control.
- Use environment variables / secret vault for production.
---
## 6) The 5 test values you must provide
Integration tests need these environment variables:
- `FES_TEST_PKCS11_LIBRARY_PATH`
- `FES_TEST_PKCS11_SLOT_ID`
- `FES_TEST_PKCS11_USER_PIN`
- `FES_TEST_PKCS11_PRIVATE_KEY_LABEL`
- `FES_TEST_PKCS11_CERTIFICATE_LABEL`
Without these values, integration tests are skipped by design.
## 6.1 Which certificate/key should be created?
This is the most important business decision for IT and architecture.
Based on your shared diagram, the **primary model** is:
- one **company certificate + company key pair** in HSM,
- reused for all signers,
- signer identity proven by audit-trail + 2FA evidence.
What this means in practical terms:
- Not one key per sender.
- Not one key per recipient by default.
- One company signing identity, many signing events.
Alternative model (future/optional):
- per-signer certificate/key pair (more complex CA lifecycle).
For current fast validation and your FES flow, use the **company-level key/certificate** model.
---
## 7) How to obtain these values (IT-friendly)
## 7.1 If you use direct SoftHSM module
### A) Find module path
What it is:
- PKCS#11 shared library file used by the app to talk to SoftHSM.
Why needed:
- Without this exact file path, PKCS#11 cannot be loaded.
Linux examples:
### 2.2 SoftHSM-Modulpfad pruefen
```bash
ldconfig -p | grep softhsm
find /usr -name "libsofthsm2.so" 2>/dev/null
```
Use the resolved full path as `FES_TEST_PKCS11_LIBRARY_PATH`.
### B) List slots/tokens
What it is:
- Slot: logical container position in PKCS#11.
- Token: initialized security container in a slot.
Why needed:
- The app must know which slot/token contains the company key and certificate.
### 2.3 Token initialisieren
```bash
softhsm2-util --show-slots
```
Pick your slot as `FES_TEST_PKCS11_SLOT_ID`.
### C) Identify key and certificate labels
What it is:
- `Private key label`: name of the signing private key object.
- `Certificate label`: name of the matching X.509 certificate object.
Why needed:
- Tests and runtime use labels to select correct objects in HSM.
Use `pkcs11-tool` against your module:
```bash
pkcs11-tool --module /path/to/libsofthsm2.so --slot <slot-id> --login --pin <user-pin> --list-objects
```
From output:
- private key label -> `FES_TEST_PKCS11_PRIVATE_KEY_LABEL`
- certificate label -> `FES_TEST_PKCS11_CERTIFICATE_LABEL`
### D) User PIN
What it is:
- User authorization secret to open authenticated session on token.
Why needed:
- HSM will not allow signing with private key without login.
PIN is assigned during token initialization and must be provided by IT/security team.
### E) Optional: Initialize token and create objects (Linux quick bootstrap)
Only if token/keys are not already provisioned by IT PKI team.
```bash
# 1) Initialize token in slot 0 (example)
softhsm2-util --init-token --slot 0 --label "CompanySignToken"
# 2) Verify slot/token
softhsm2-util --init-token --slot <uninitialized-slot> --label CompanyProdToken --so-pin <so-pin> --pin <user-pin>
softhsm2-util --show-slots
# 3) Generate RSA key pair inside token (example)
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot 0 --login --pin <USER_PIN> \
--keypairgen --key-type rsa:3072 --label sign-key --id 01
# 4) Import certificate (already issued by CA)
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot 0 --login --pin <USER_PIN> \
--write-object company-sign-cert.der --type cert --label sign-cert --id 01
# 5) List objects and confirm labels
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --slot 0 --login --pin <USER_PIN> --list-objects
```
Notes:
Hinweis: Nach `--init-token` wird der Slot i. d. R. neu zugewiesen.
- Use this only in non-production unless approved.
- In production, key generation/import should follow company security policy.
## 7.2 If you use PKCS#11 proxy module
What it is:
- A local PKCS#11 bridge library that forwards calls to remote HSM/SoftHSM service.
Why needed:
- App still loads a local module path, but crypto operations happen remotely.
Ask IT for:
- proxy module path (`libpkcs11-proxy.*`)
- slot id or token label
- user pin
- private key label
- certificate label
In proxy mode, values come from proxy-backed token, not local SoftHSM token files.
---
## 7.3 Linux handover checklist for IT (copy/paste friendly)
1. Confirm module path exists (`libsofthsm2.so` or proxy module).
2. Confirm token exists and slot id is known.
3. Confirm company private key label and certificate label.
4. Confirm user PIN for test environment.
5. Share these five values securely with development team.
---
## 8) Test execution checklist (single command flow)
## 8.1 Set environment variables
PowerShell example:
```powershell
$env:FES_TEST_PKCS11_LIBRARY_PATH = "<module-path>"
$env:FES_TEST_PKCS11_SLOT_ID = "<slot-id>"
$env:FES_TEST_PKCS11_USER_PIN = "<user-pin>"
$env:FES_TEST_PKCS11_PRIVATE_KEY_LABEL = "<private-key-label>"
$env:FES_TEST_PKCS11_CERTIFICATE_LABEL = "<certificate-label>"
```
Linux (bash) example:
### 2.4 Schluesselpaar erzeugen
```bash
export FES_TEST_PKCS11_LIBRARY_PATH="/usr/lib/softhsm/libsofthsm2.so"
export FES_TEST_PKCS11_SLOT_ID="0"
export FES_TEST_PKCS11_USER_PIN="<user-pin>"
export FES_TEST_PKCS11_PRIVATE_KEY_LABEL="sign-key"
export FES_TEST_PKCS11_CERTIFICATE_LABEL="sign-cert"
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
```
Verify env values quickly:
### 2.5 Zertifikat importieren
```bash
echo "$FES_TEST_PKCS11_LIBRARY_PATH"
echo "$FES_TEST_PKCS11_SLOT_ID"
echo "$FES_TEST_PKCS11_PRIVATE_KEY_LABEL"
echo "$FES_TEST_PKCS11_CERTIFICATE_LABEL"
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
```
## 8.2 Run only crypt tests
### 2.6 Objektzustand verifizieren
```powershell
dotnet test "EnvelopeGenerator.Tests/EnvelopeGenerator.Tests.csproj" -f net8.0 --filter "FullyQualifiedName~EnvelopeGenerator.Tests.Application.Crypt"
```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
```
Expected:
### 2.7 Proxy-Daemon endpoint stabilisieren
- Unit tests pass always.
- Integration tests pass when env values are correct.
- Integration tests skip when env values are missing.
Ein wiederkehrendes Betriebsproblem war ein zufaelliger/local-only Listener. Fix:
If all five values are correct:
```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
```
- integration tests run automatically,
- no code change is required,
- only environment setup is required.
Pruefung:
```bash
systemctl show pkcs11-proxy.service -p Environment --no-pager
ss -ltnp | grep 5657
```
---
## 9) Common failure meanings
## 3. Web-API/Server-Seite: Installation und Asset-Bereitstellung
- `CRYPT_PROVIDER_UNAVAILABLE`
- invalid library path, missing native dependency, provider cannot load
- `CRYPT_SLOT_NOT_FOUND`
- wrong slot id or token label
- `CRYPT_LOGIN_FAILED`
- wrong PIN
- `CRYPT_KEY_NOT_FOUND`
- private key label/id mismatch
- `CRYPT_CERT_NOT_FOUND`
- certificate label/id mismatch
### 3.1 Bezugsquellen der Windows-Artefakte
This is exactly what IT needs for first-level diagnostics.
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.
---
## 10) Integration in the full stack architecture
## 4. Implementierte Services, Modelle, DTOs
Current role by layer:
### 4.1 DI und Startverhalten
- `Infrastructure.Crypt` (this project): PKCS#11 crypto operations
- `Application`: orchestration use-cases and business process steps
- `Server`: API endpoints and presentation concerns
- `AddCryptInfrastructure(...)` in `DependencyInjection/ServiceCollectionExtensions.cs`
- bindet `Pkcs11Options`
- registriert `IPdfDigitalSignatureService`
- registriert Startup-Self-Test Hosted Service
Recommended integration path:
### 4.2 Konfigurationsmodell
1. Stabilize crypt tests and environment setup (this stage).
2. Integrate `ISignatureProvider` + `ICertificateProvider` into Application command flow.
3. Connect Application flow to Server endpoints.
4. Add PDF embedding and audit-trail generation for full step-8 compliance.
5. Add archiving/distribution orchestration for steps 9-10.
- `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`
---
## 11) Important note about signing semantics
## 5. Betriebserfahrungen und Challenges
PKCS#11 mechanisms differ in whether they expect raw data or a precomputed digest.
### 5.1 Interop vs Tool-Verhalten
Current implementation signs with `CKM_SHA256_RSA_PKCS` and receives hash bytes from caller.
This must be validated in your real provider environment.
Beobachtetes Muster in der realen Umgebung:
If provider expects raw data for this mechanism, integration may require adjustment.
This is why integration tests are critical.
- `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)
---
## 12) Quick FAQ
## 6. Health, Monitoring und Verifikation
**Q: Do I need REST calls to SoftHSM?**
### 6.1 API Health
No. SoftHSM is not a REST server. Use PKCS#11 library calls.
- Endpoint: `GET /api/health/crypt`
- Liefert:
- `success`, `mode`, `summary`, `interopError`, `toolError`, `checkedAtUtc`
**Q: Can I use proxy and still call it SoftHSM setup?**
Empfehlung:
Yes. Proxy can forward to SoftHSM-backed token infrastructure.
- In Monitoring aufnehmen und bei `mode=pkcs11-tool` ueber laengere Zeit Warnung triggern.
**Q: Once I provide the 5 values, can tests run directly?**
### 6.2 Smoke-Test (empfohlen vor Release)
Yes. Set env vars and run the single `dotnet test` command above.
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
**Q: Will one company cert/key be enough for this phase?**
---
Yes, for the diagram-aligned company-signing model, one company key/certificate is enough for initial implementation and validation.
## 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/`

View File

@@ -0,0 +1,77 @@
using DocumentService.Client.Interfaces;
using DocumentService.Client.Models.Requests;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using ClientCertLevel = DocumentService.Client.Models.Requests.PdfSignatureCertificationLevel;
using ClientCoordUnit = DocumentService.Client.Models.Requests.PdfSignatureCoordinateUnit;
using ClientCoordOrigin = DocumentService.Client.Models.Requests.PdfSignatureCoordinateOrigin;
namespace EnvelopeGenerator.Infrastructure.Crypt.Services;
/// <summary>
/// Proxy that forwards PDF signing to the remote DocumentService API via <see cref="IPdfSignatureClient"/>.
/// </summary>
public sealed class PdfDigitalSignatureService(IPdfSignatureClient signatureClient) : IPdfDigitalSignatureService
{
public async Task SignPdfAsync(Stream inputPdf, Stream outputPdf, PdfSignOptionsDto? options = null, Stream? image = null, CancellationToken cancellationToken = default)
{
var pdfBytes = await ReadAllBytesAsync(inputPdf, cancellationToken);
var imageBytes = image is null ? null : await ReadAllBytesAsync(image, cancellationToken);
using var signed = await signatureClient.SignAsync(pdfBytes, Map(options), imageBytes, cancellationToken);
await signed.CopyToAsync(outputPdf, cancellationToken);
}
public Task<PdfSignatureCheckResultDto> CheckPdfSignatureAsync(Stream inputPdf, CancellationToken cancellationToken = default)
=> throw new NotSupportedException("Signature checking is handled by DocumentService and is not available through this proxy.");
private static SignPdfOptions Map(PdfSignOptionsDto? o) => new()
{
DocumentElementId = o?.DocumentElementId ?? 0,
Name = o?.Name ?? string.Empty,
Reason = o?.Reason,
Location = o?.Location,
Email = o?.Email,
Phone = o?.Phone,
ApplicationName = o?.ApplicationName,
CertificationLevel = (ClientCertLevel)(o?.CertificationLevel ?? EnvelopeGenerator.Application.Common.Enums.PdfSignatureCertificationLevel.NoCertification),
Appearance = o?.Appearance is null ? null : MapAppearance(o.Appearance)
};
private static PdfSignatureAppearance MapAppearance(PdfSignatureAppearanceDto a) => new()
{
PageNumber = a.PageNumber,
Left = a.Left,
Bottom = a.Bottom,
Width = a.Width,
Height = a.Height,
ShowLabels = a.ShowLabels,
ShowName = a.ShowName,
ShowDate = a.ShowDate,
ShowReason = a.ShowReason,
ShowLocation = a.ShowLocation,
ShowDistinguishedName = a.ShowDistinguishedName,
DateTimeFormat = a.DateTimeFormat,
CoordinateUnit = (ClientCoordUnit)a.CoordinateUnit,
CoordinateOrigin = (ClientCoordOrigin)a.CoordinateOrigin,
Font = new PdfSignatureFont
{
FamilyName = a.Font.FamilyName,
Size = a.Font.Size,
IsBold = a.Font.IsBold,
IsItalic = a.Font.IsItalic,
IsUnderline = a.Font.IsUnderline,
Color = new PdfSignatureColor { R = a.Font.Color.R, G = a.Font.Color.G, B = a.Font.Color.B }
}
};
private static async Task<byte[]> ReadAllBytesAsync(Stream stream, CancellationToken cancellationToken)
{
if (stream is MemoryStream memory && memory.TryGetBuffer(out var buffer))
return buffer.AsSpan(0, (int)memory.Length).ToArray();
using var copy = new MemoryStream();
await stream.CopyToAsync(copy, cancellationToken);
return copy.ToArray();
}
}

View File

@@ -1,7 +1,9 @@
using DevExpress.Drawing;
using DevExpress.Pdf;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Domain.Interfaces;
namespace EnvelopeGenerator.Infrastructure.Doc;
@@ -54,4 +56,125 @@ public class PdfPageImageRenderer : IPdfPageImageRenderer
return Task.FromResult<IReadOnlyList<PdfPageImageDto>>(results);
}
public Stream ComposeSignaturePreviewPng(ISignatureInkPayload element)
{
const double sigW = 1.77 * 72;
const double sigH = 1.96 * 72;
const double imgRatio = 0.52;
const double lineH = 11.5;
const double bgPad = 3.0;
const double padding = 3.0;
var date = element.ChangedWhen!.Value.ToString("dd.MM.yyyy");
var position = string.IsNullOrWhiteSpace(element.Position) ? null : element.Position.Trim();
var x = bgPad;
var y = bgPad;
var imgH = sigH * imgRatio;
var lineY = y + imgH + 1.0;
var textY = lineY + 1.5;
var row1Y = textY;
var row2Y = row1Y + lineH;
var row3Y = position is null ? row2Y : row2Y + lineH;
var contentBottom = row3Y + lineH;
var pageWidth = sigW + (bgPad * 2);
var pageHeight = contentBottom + bgPad;
var inkBytes = this.EnsureSupportedImageBytes(element.Ink!);
using var processor = new PdfDocumentProcessor();
processor.CreateEmptyDocument();
var page = processor.AddNewPage(new PdfRectangle(0, 0, pageWidth, pageHeight));
using var graphics = processor.CreateGraphics();
using var bgBrush = new DXSolidBrush(System.Drawing.Color.FromArgb(255, 255, 253, 240));
using var linePen = new DXPen(System.Drawing.Color.FromArgb(180, 100, 100, 120), 0.5f);
using var nameBrush = new DXSolidBrush(System.Drawing.Color.FromArgb(255, 20, 20, 20));
using var infoBrush = new DXSolidBrush(System.Drawing.Color.FromArgb(255, 80, 80, 80));
var fontBold = new DXFont("Arial", 7.5f, DXFontStyle.Bold);
var fontNormal = new DXFont("Arial", 6.5f, DXFontStyle.Regular);
graphics.FillRectangle(bgBrush, new System.Drawing.RectangleF(0, 0, (float)pageWidth, (float)pageHeight));
var imageArea = new System.Drawing.RectangleF((float)x, (float)y, (float)sigW, (float)imgH);
var imageRect = FitImageContain(imageArea, inkBytes);
graphics.DrawImage(inkBytes, imageRect);
graphics.DrawLine(linePen, (float)(x + 2), (float)lineY, (float)(x + sigW - 2), (float)lineY);
graphics.DrawString(
element.FullName!.Trim(),
fontBold,
nameBrush,
new System.Drawing.RectangleF((float)(x + padding), (float)row1Y, (float)(sigW - (padding * 2)), (float)lineH));
if (position is not null)
{
graphics.DrawString(
position,
fontNormal,
infoBrush,
new System.Drawing.RectangleF((float)(x + padding), (float)row2Y, (float)(sigW - (padding * 2)), (float)lineH));
}
graphics.DrawString(
$"{element.Place!.Trim()}, {date}",
fontNormal,
infoBrush,
new System.Drawing.RectangleF((float)(x + padding), (float)row3Y, (float)(sigW - (padding * 2)), (float)lineH));
graphics.AddToPageForeground(page, 72, 72);
using var pdfStream = new MemoryStream();
processor.SaveDocument(pdfStream);
pdfStream.Position = 0;
using var rasterProcessor = new PdfDocumentProcessor();
rasterProcessor.LoadDocument(pdfStream);
var renderOptions = PdfPageRenderingParameters.CreateWithResolution(400f);
using DXBitmap bitmap = rasterProcessor.CreateDXBitmap(1, renderOptions);
var pngStream = new MemoryStream();
bitmap.Save(pngStream, DXImageFormat.Png);
pngStream.Position = 0;
return pngStream;
}
private static System.Drawing.RectangleF FitImageContain(System.Drawing.RectangleF area, byte[] imageBytes)
{
using var imageStream = new MemoryStream(imageBytes, writable: false);
using var image = DXImage.FromStream(imageStream);
if (image.Width <= 0 || image.Height <= 0)
return area;
var scale = Math.Min(area.Width / image.Width, area.Height / image.Height);
var drawW = image.Width * scale;
var drawH = image.Height * scale;
var drawX = area.X + ((area.Width - drawW) / 2f);
var drawY = area.Y + ((area.Height - drawH) / 2f);
return new System.Drawing.RectangleF(drawX, drawY, drawW, drawH);
}
/// <summary>
///
/// </summary>
/// <param name="bytes"></param>
/// <returns></returns>
public bool IsSupportedImageFormat(byte[] bytes)
{
var isPng = bytes.Length >= 4 &&
bytes[0] == 0x89 && bytes[1] == 0x50 && bytes[2] == 0x4E && bytes[3] == 0x47;
if (isPng)
return true;
var isJpeg = bytes.Length >= 3 &&
bytes[0] == 0xFF && bytes[1] == 0xD8 && bytes[2] == 0xFF;
return isJpeg;
}
}

View File

@@ -0,0 +1,43 @@
using DigitalData.MessagingService.Client;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.RabbitMQ;
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace EnvelopeGenerator.Infrastructure.Notification;
public static class DependencyInjection
{
public static IServiceCollection AddNotificationInfrastructure(this IServiceCollection services, IConfiguration configuration)
{
var rMqConfig = configuration.GetSection("RabbitMQ").Get<RabbitMqConfiguration>()
?? throw new ArgumentNullException(nameof(RabbitMqConfiguration), "RabbitMQ configuration is missing");
EmailSender.ConnectRabbitMq(config =>
{
config.HostName = rMqConfig.HostName;
config.Port = rMqConfig.Port;
config.UserName = rMqConfig.UserName;
config.Password = rMqConfig.Password;
config.VirtualHost = "/";
config.QueueName = "emailprofiler.email.outbox";
config.ExchangeName = "emailprofiler.emails";
config.RoutingKey = "email.outbox";
config.DlqQueueName = "emailprofiler.email.outbox.dlq";
config.DlqExchangeName = "emailprofiler.emails.dlq";
config.DlqRoutingKey = "email.outbox.dlq";
});
services.Configure<EmailAccount>(configuration.GetSection("NotificationEmailAccount"));
services.AddScoped<IEnvelopeNotificationService, EnvelopeNotificationService>();
return services;
}
}

View File

@@ -0,0 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net8.0;</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="DigitalData.MessagingService.Client" Version="1.0.5" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,44 @@
using DigitalData.Core.Abstraction.Application.Repository;
using DigitalData.Core.Exceptions;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Client;
using DigitalData.MessagingService.Domain.Entities;
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
using EnvelopeGenerator.Domain.Entities;
using MediatR;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace EnvelopeGenerator.Infrastructure.Notification;
public class EnvelopeNotificationService(IOptions<EmailAccount> EmailAccountOptions, IRepository<EmailTemplate> TempRepo) : IEnvelopeNotificationService
{
public async Task Notify(string email, DocumentSignedNotification documentSigned, CancellationToken cancel = default)
{
var template = await TempRepo.Where(t => t.Name == "DocumentCompleted").FirstOrDefaultAsync(cancel)
?? throw new NotFoundException("DocumentCompleted email template not found");
var subjectTemplate = Replace(template.Subject, documentSigned);
var bodyTemplate = Replace(template.Body, documentSigned);
await EmailSender.Send(new EmailContext()
{
Body = bodyTemplate,
Subject = subjectTemplate,
Recipients = [email],
Sender = EmailAccountOptions.Value,
Attachments = [new EmailAttachmentDto()
{
FileName = "unterzeichnetes-dokument.pdf",
Content = documentSigned.Attachment
}]
});
}
private static string Replace(string template, DocumentSignedNotification documentSigned)
{
return template
.Replace("[DOCUMENT_TITLE]", documentSigned.DocumentTitle)
.Replace("[NAME_PORTAL]", documentSigned.NamePortal)
.Replace("[NAME_RECEIVER]", documentSigned.NameReceiver);
}
}

View File

@@ -22,7 +22,7 @@
</main>
<footer class="receiver-footer">
<div class="receiver-footer__left">
<span>&copy; SignFlow 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
<span>&copy; signFLOW 2023-2024 <a href="https://digitaldata.works" target="_blank" rel="noopener">Digital Data GmbH</a></span>
<span class="receiver-footer__sep">&#124;</span>
<a href="@GetPrivacyPolicyPath()" target="_blank" rel="noopener">@L("PrivacyPolicy", "Datenschutz")</a>
</div>

View File

@@ -26,7 +26,7 @@
</svg>
}
<div>
<h1 class="home-hero-header__title">SignFlow</h1>
<h1 class="home-hero-header__title">signFLOW</h1>
<p class="home-hero-header__subtitle">@L("Index_Subtitle", "Willkommen im eSign-Portal")</p>
</div>
</div>

View File

@@ -19,6 +19,7 @@
@inject EnvelopeGenerator.Server.Client.Services.ThemeService ThemeService
@inject EnvelopeGenerator.Server.Client.Services.UserPreferencesService UserPreferencesService
@inject EnvelopeGenerator.Server.Client.Services.CustomImagesService CustomImagesService
@inject EnvelopeGenerator.Server.Client.Services.PdfSignatureService PdfSignatureService
@inject EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService CreateEnvelopeBootstrapStorageService
@inject ILogger<SenderPage> Logger
@inject NavigationManager Navigation
@@ -424,7 +425,7 @@
@* ── PDF Vorschau Popup ────────────────────────────────────────────────── *@
<DxPopup @bind-Visible="@_previewVisible"
HeaderText="@($"{L("Preview", "Vorschau")} — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
HeaderText="@($"{(_activeTab == "completed" && _selectedEnvelope?.Status == EnvelopeStatus.EnvelopeCompletelySigned ? L("SignedPdf", "Signiertes PDF") : L("Preview", "Vorschau"))} — {_selectedEnvelope?.Title} (#{_selectedEnvelope?.Id})")"
Width="80vw"
MaxWidth="80vw"
Height="90vh"
@@ -1024,12 +1025,14 @@
private async Task OpenPreviewAsync(EnvelopeDto envelope)
{
var showSigned = _activeTab == "completed" && envelope.Status == EnvelopeStatus.EnvelopeCompletelySigned;
// Unmount the viewer first — let the previous instance fully dispose
_previewViewerMounted = false;
_previewPdfBytes = null;
_previewPageCount = 0;
_previewPagePreviews = [];
_previewFileName = $"envelope-{envelope.Id}.pdf";
_previewFileName = showSigned ? $"envelope-{envelope.Id}-signed.pdf" : $"envelope-{envelope.Id}.pdf";
_previewErrorMessage = null;
_previewLoading = true;
_previewVisible = true;
@@ -1037,7 +1040,10 @@
try
{
_previewPdfBytes = await DocumentService.GetSenderDocumentBytesByEnvelopeIdAsync(envelope.Id);
_previewPdfBytes = showSigned
? await PdfSignatureService.GetSignedPdfAsync(envelope.Id)
: await DocumentService.GetSenderDocumentBytesByEnvelopeIdAsync(envelope.Id);
if (_previewPdfBytes is null || _previewPdfBytes.Length == 0)
{
_previewErrorMessage = L("DocumentCouldNotBeLoaded", "Dokument konnte nicht geladen werden");

View File

@@ -53,6 +53,7 @@ builder.Services.AddScoped<ThemeService>();
// Logo configuration (loaded once after login, in-memory cached)
builder.Services.AddScoped<CustomImagesService>();
builder.Services.AddScoped<PdfSignatureService>();
// DevExpress WASM
builder.Services.AddDevExpressWebAssemblyBlazorPdfViewer();

View File

@@ -2,7 +2,7 @@ using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
using EnvelopeGenerator.Server.Client.Models;
namespace EnvelopeGenerator.Server.Client.Services;

View File

@@ -0,0 +1,24 @@
using System.Net;
using System.Net.Http.Json;
namespace EnvelopeGenerator.Server.Client.Services;
public class PdfSignatureService(IHttpClientFactory httpClientFactory)
{
/// <summary>
/// Calls POST /api/PdfSignature with the envelope id and returns the fully signed PDF bytes.
/// </summary>
public async Task<byte[]> GetSignedPdfAsync(int envelopeId, CancellationToken cancel = default)
{
using var http = httpClientFactory.CreateClient("EnvelopeGenerator.Server");
var response = await http.PostAsJsonAsync("/api/PdfSignature", new { Id = envelopeId }, cancel);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException(
$"Failed to load signed PDF. Status: {(int)response.StatusCode} ({response.ReasonPhrase ?? "Unknown"})",
null,
response.StatusCode);
return await response.Content.ReadAsByteArrayAsync(cancel);
}
}

View File

@@ -56,7 +56,7 @@
title="@L("Complete", "Abschließen")"
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.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"/>
<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" />
</svg>
<span class="pdf-toolbar__btn-text">@L("Complete", "Abschließen")</span>
</button>
@@ -114,7 +114,7 @@
<div style="display: flex; align-items: flex-start; gap: 1rem; padding: 0.5rem 0;">
<div style="flex-shrink: 0; width: 40px; height: 40px; background: #d1fae5; border-radius: 50%; display: flex; align-items: center; justify-content: center;">
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="#065f46" 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"/>
<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" />
</svg>
</div>
<div>
@@ -169,7 +169,7 @@
SignatureCaptureDto? _sig;
// ----- Submit / logout state -----
bool _isLoggingOut = false;
bool _isLoggingOut = false;
bool _submitConfirmVisible = false;
string? _submitErrorMessage;
@@ -262,7 +262,7 @@
}
_envelopeReceiver = await PageDataService.GetEnvelopeReceiverAsync(EnvelopeKey);
_signatures = await PageDataService.GetSignaturesAsync(_receiverUser);
_signatures = await PageDataService.GetSignaturesAsync(_receiverUser);
// Burn signature image + info onto PDF via PdfSharp
if (_sig is not null && _signatures.Count > 0)
@@ -272,13 +272,13 @@
{
PaperKind = DevExpress.Drawing.Printing.DXPaperKind.A4,
Landscape = false,
Margins = new System.Drawing.Printing.Margins(0, 0, 0, 0),
Margins = new System.Drawing.Printing.Margins(0, 0, 0, 0),
};
var detail = new DetailBand();
report.Bands.Add(detail);
detail.Controls.Add(new XRPdfContent
{
Source = pdfBytes,
Source = pdfBytes,
GenerateOwnPages = true,
});
_report = report;
@@ -331,32 +331,32 @@
var imgBytes = DataUrlToBytes(sig.DataUrl);
if (imgBytes is not { Length: > 0 }) return pdfBytes;
using var inputMs = new System.IO.MemoryStream(pdfBytes);
using var inputMs = new System.IO.MemoryStream(pdfBytes);
using var outputMs = new System.IO.MemoryStream();
var document = PdfSharp.Pdf.IO.PdfReader.Open(
inputMs, PdfSharp.Pdf.IO.PdfDocumentOpenMode.Modify);
const double sigW = 1.77 * 72; // 127.44 pt
const double sigH = 1.96 * 72; // 141.12 pt
const double sigW = 1.77 * 72; // 127.44 pt
const double sigH = 1.96 * 72; // 141.12 pt
const double imgRatio = 0.52; // top 52% = image
const double lineH = 11.5; // fixed row height matching font size (bold 7.5pt + normal 6.5pt)
const double bgPad = 3.0; // background box padding around content (pt)
const double lineH = 11.5; // fixed row height matching font size (bold 7.5pt + normal 6.5pt)
const double bgPad = 3.0; // background box padding around content (pt)
var black = PdfSharp.Drawing.XColor.FromArgb(255, 20, 20, 20);
var darkGray = PdfSharp.Drawing.XColor.FromArgb(255, 80, 80, 80);
var black = PdfSharp.Drawing.XColor.FromArgb(255, 20, 20, 20);
var darkGray = PdfSharp.Drawing.XColor.FromArgb(255, 80, 80, 80);
var lineColor = PdfSharp.Drawing.XColor.FromArgb(180, 100, 100, 120);
var bgColor = PdfSharp.Drawing.XColor.FromArgb(255, 255, 253, 240);
var bgBrush = new PdfSharp.Drawing.XSolidBrush(bgColor);
var fontBold = new PdfSharp.Drawing.XFont("Arial", 7.5, PdfSharp.Drawing.XFontStyleEx.Bold);
var fontBold = new PdfSharp.Drawing.XFont("Arial", 7.5, PdfSharp.Drawing.XFontStyleEx.Bold);
var fontNormal = new PdfSharp.Drawing.XFont("Arial", 6.5, PdfSharp.Drawing.XFontStyleEx.Regular);
var linePen = new PdfSharp.Drawing.XPen(lineColor, 0.5);
var linePen = new PdfSharp.Drawing.XPen(lineColor, 0.5);
var fmtLeft = new PdfSharp.Drawing.XStringFormat
{
Alignment = PdfSharp.Drawing.XStringAlignment.Near,
Alignment = PdfSharp.Drawing.XStringAlignment.Near,
LineAlignment = PdfSharp.Drawing.XLineAlignment.Near,
};
@@ -375,7 +375,7 @@
double y = field.Y;
// --- Calculate layout positions first (needed for bg rect) ---
double imgH = sigH * imgRatio;
double imgH = sigH * imgRatio;
double lineY = y + imgH + 1.0; // 1pt gap between image and separator
double textY = lineY + 1.5; // 1.5pt gap below separator line
double padding = 3;
@@ -419,7 +419,7 @@
// Row 3: Place, Date
var placeDate = $"{sig.Place}, {date}";
var dateRect = new PdfSharp.Drawing.XRect(x + padding, row3Y, sigW - padding * 2, lineH);
var dateRect = new PdfSharp.Drawing.XRect(x + padding, row3Y, sigW - padding * 2, lineH);
gfx.DrawString(placeDate, fontNormal, new PdfSharp.Drawing.XSolidBrush(darkGray), dateRect, fmtLeft);
}

View File

@@ -0,0 +1,99 @@
using DigitalData.Core.Abstraction.Application.Repository;
using DevExpress.Drawing;
using DevExpress.Pdf;
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Domain.Entities;
using EnvelopeGenerator.Domain.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
/// Temporary development endpoint to download stored signature ink image by element id.
/// </summary>
[ApiController]
[AllowAnonymous]
[Route("api/dev/signature-ink")]
public class DevSignatureInkController(
IRepository<DocReceiverElement> repository,
IWebHostEnvironment environment,
IConfiguration configuration,
IPdfPageImageRenderer renderer) : ControllerBase
{
/// <summary>
/// Downloads the signature image stored in INK for the given DocumentReceiverElement id.
/// </summary>
[HttpGet("{id:int}")]
public async Task<IActionResult> DownloadInk([FromRoute] int id, CancellationToken cancel)
{
if (!IsEndpointEnabled())
return NotFound();
var element = await repository.Query
.AsNoTracking()
.Where(e => e.Id == id)
.Select(e => new DocReceiverElementDto
{
Id = e.Id,
Ink = e.Ink
})
.SingleOrDefaultAsync(cancel);
if (element is null)
return NotFound($"DocumentReceiverElement not found for id={id}.");
if (element.Ink is not { Length: > 0 })
return NotFound($"No signature image (Ink) found for id={id}.");
var image = element.ToImage();
return File(image.Bytes, image.ContentType, $"signature-ink-{id}{image.FileExtension}");
}
/// <summary>
/// Downloads a composed signature image (background + ink + signer metadata) for the given element id.
/// </summary>
[HttpGet("{id:int}/composed")]
public async Task<IActionResult> DownloadComposedInk([FromRoute] int id, CancellationToken cancel)
{
if (!IsEndpointEnabled())
return NotFound();
var element = await repository.Query
.AsNoTracking()
.Where(e => e.Id == id)
.Select(e => new DocReceiverElementDto
{
Id = e.Id,
Ink = e.Ink,
FullName = e.FullName,
Position = e.Position,
Place = e.Place,
ChangedWhen = e.ChangedWhen
})
.SingleOrDefaultAsync(cancel);
if (element is null)
return NotFound($"DocumentReceiverElement not found for id={id}.");
if (element.Ink is not { Length: > 0 })
return NotFound($"No signature image (Ink) found for id={id}.");
if (element.ChangedWhen is null)
return Conflict($"DocumentReceiverElement id={id} has null ChangedWhen. Element is not signed yet or data integrity is broken.");
if (string.IsNullOrWhiteSpace(element.FullName) || string.IsNullOrWhiteSpace(element.Place))
return Conflict($"DocumentReceiverElement id={id} is missing signer metadata (FullName or Place).");
if (!renderer.IsSupportedImageFormat(element.Ink!))
return StatusCode(StatusCodes.Status415UnsupportedMediaType);
var composedStream = renderer.ComposeSignaturePreviewPng(element);
return File(composedStream, "image/png", $"signature-composed-{id}.png");
}
private bool IsEndpointEnabled()
=> environment.IsDevelopment() || configuration.GetValue<bool>("DevEndpoints:EnableSignatureInkInProduction");
}

View File

@@ -5,6 +5,7 @@ using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using EnvelopeGenerator.Application.PdfSigning;
namespace EnvelopeGenerator.Server.Controllers;
@@ -123,4 +124,17 @@ public class EnvelopeController : ControllerBase
return Ok(result);
}
/// <summary>
///
/// </summary>
/// <param name="command"></param>
/// <returns></returns>
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
[HttpPost("finalize")]
public async Task<IActionResult> FinalizeEnvelopeAsync([FromQuery] FinalizeEnvelopeCommand command)
{
await _mediator.Send(command.Authorized(User.GetId()));
return Ok();
}
}

View File

@@ -1,5 +1,4 @@
using AutoMapper;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
using EnvelopeGenerator.Application.EnvelopeReceivers.Queries;
using EnvelopeGenerator.Application.Envelopes.Queries;
using EnvelopeGenerator.Domain.Entities;
@@ -15,6 +14,7 @@ using EnvelopeGenerator.Application.Common.Dto.Receiver;
using EnvelopeGenerator.Application.Common.Interfaces.SQLExecutor;
using EnvelopeGenerator.Server.Extensions;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
namespace EnvelopeGenerator.Server.Controllers;
@@ -214,7 +214,7 @@ public class EnvelopeReceiverController : ControllerBase
SELECT @OUT_SUCCESS as [@OUT_SUCCESS];";
foreach (var rcv in res.SentReceiver)
foreach (var sign in request.Receivers.Where(r => r.EmailAddress == rcv.EmailAddress).FirstOrDefault()?.DocReceiverElements ?? Enumerable.Empty<Application.EnvelopeReceivers.Commands.DocReceiverElementCreateDto>())
foreach (var sign in request.Receivers.Where(r => r.EmailAddress == rcv.EmailAddress).FirstOrDefault()?.DocReceiverElements ?? Enumerable.Empty<DocReceiverElementCreateDto>())
{
using SqlConnection conn = new(_cnnStr);
conn.Open();

View File

@@ -0,0 +1,132 @@
using EnvelopeGenerator.Application.Common.Dto;
using EnvelopeGenerator.Application.Common.Interfaces.Services;
using EnvelopeGenerator.Application.PdfSigning;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Server.Extensions;
using MediatR;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace EnvelopeGenerator.Server.Controllers;
/// <summary>
///
/// </summary>
/// <param name="SignatureService"></param>
/// <param name="Mediator"></param>
[ApiController]
[Route("api/[controller]")]
public class PdfSignatureController(IPdfDigitalSignatureService SignatureService, IMediator Mediator) : ControllerBase
{
/// <summary>
///
/// </summary>
/// <param name="command"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[HttpPost]
[Authorize(AuthenticationSchemes = AuthScheme.Sender)]
public async Task<IActionResult> SignDocument(SignPdfByEnvelopeCommand command, CancellationToken cancel)
{
var senderId = User.SenderId();
var result = await Mediator.Send(command.Authorized(senderId), cancel);
result.Position = 0;
var fileName = Path.GetFileNameWithoutExtension($"e{command.Id.ToString() ?? command.Uuid}_s{senderId}");
return File(result, "application/pdf", $"{fileName}_signed.pdf");
}
/// <summary>
/// Gets the document for the specified envelope key.
/// </summary>
/// <param name="envelopeKey"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[Authorize(Policy = AuthPolicy.Receiver)]
[HttpPost("{envelopeKey}")]
public async Task<IActionResult> SignDocumentByReceiver(string envelopeKey, CancellationToken cancel)
{
int envelopeId = User.EnvelopeId();
int receiverId = User.ReceiverId();
var senderDoc = await Mediator.Send(new SignPdfByEnvelopeReceiverCommand
{
EnvelopeId = envelopeId,
ReceiverId = receiverId
}, cancel);
senderDoc.Position = 0;
var fileName = Path.GetFileNameWithoutExtension($"e{envelopeId}_r{receiverId}");
return File(senderDoc, "application/pdf", $"{fileName}_signed.pdf");
}
#region Test Endpoints
/// <summary>
///
/// </summary>
public class CheckPdfSignatureForm
{
/// <summary>
///
/// </summary>
public virtual required IFormFile File { get; init; }
}
/// <summary>
///
/// </summary>
public sealed class SignPdfForm : CheckPdfSignatureForm
{
/// <summary>
///
/// </summary>
public required IFormFile SignatureImage { get; init; }
/// <summary>
///
/// </summary>
public PdfSignOptionsDto Options { get; init; } = new PdfSignOptionsDto();
}
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[NonAction]
[AllowAnonymous]
[HttpPost("sign")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> Sign([FromForm] SignPdfForm request, CancellationToken cancel)
{
await using var signatureImage = request.SignatureImage.OpenReadStream();
await using var input = request.File.OpenReadStream();
var output = new MemoryStream();
await SignatureService.SignPdfAsync(input, output, request.Options, signatureImage, cancel);
output.Position = 0;
var fileName = Path.GetFileNameWithoutExtension(request.File.FileName);
return File(output, "application/pdf", $"{fileName}-signed.pdf");
}
/// <summary>
///
/// </summary>
/// <param name="request"></param>
/// <param name="cancel"></param>
/// <returns></returns>
[NonAction]
[AllowAnonymous]
[HttpPost("is-signed")]
[Consumes("multipart/form-data")]
public async Task<IActionResult> IsSigned([FromForm] CheckPdfSignatureForm request, CancellationToken cancel)
{
await using var input = request.File.OpenReadStream();
var result = await SignatureService.CheckPdfSignatureAsync(input, cancel);
return Ok(result);
}
#endregion
}

View File

@@ -10,18 +10,20 @@
<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.7.0-beta</Version>
<FileVersion>1.7.0.0</FileVersion>
<AssemblyVersion>1.7.0.0</AssemblyVersion>
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\EnvelopeGenerator.Infrastructure.Notification\EnvelopeGenerator.Infrastructure.Notification.csproj" />
<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>
@@ -47,7 +49,6 @@
<PackageReference Include="NLog.Web.AspNetCore" Version="5.3.0" />
<PackageReference Include="PDFsharp" Version="6.2.4" />
<PackageReference Include="Scalar.AspNetCore" Version="2.2.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="8.1.1" />
<PackageReference Include="DigitalData.EmailProfilerDispatcher.Abstraction" Version="3.2.0" />
<PackageReference Include="System.DirectoryServices" Version="8.0.0" />

View File

@@ -1,6 +1,7 @@
namespace EnvelopeGenerator.Server.Middleware;
using DigitalData.Core.Exceptions;
using EnvelopeGenerator.Domain.Exceptions;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using System.Net;
@@ -74,6 +75,16 @@ public class ExceptionHandlingMiddleware
message = forbiddenEx.Message;
break;
case AuthorizationException authorizationEx:
context.Response.StatusCode = (int)HttpStatusCode.Forbidden;
message = authorizationEx.Message;
break;
case DataIntegrityException dataIntegrityEx:
context.Response.StatusCode = (int)HttpStatusCode.Conflict;
message = dataIntegrityEx.Message;
break;
default:
logger.LogError(exception, "Unhandled exception occurred.");
context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;

View File

@@ -1,33 +1,35 @@
using DevExpress.Blazor;
using DigitalData.Auth.Claims;
using DigitalData.Auth.Client;
using DigitalData.Core.Abstractions;
using DigitalData.Core.Abstractions.Security.Extensions;
using DigitalData.Core.API;
using DigitalData.Core.Application;
using DigitalData.EmailProfilerDispatcher;
using DigitalData.UserManager.DependencyInjection;
using EnvelopeGenerator.Application;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Infrastructure;
using EnvelopeGenerator.Infrastructure.Crypt.DependencyInjection;
using EnvelopeGenerator.Infrastructure.Doc;
using EnvelopeGenerator.Infrastructure.Notification;
using EnvelopeGenerator.Server;
using EnvelopeGenerator.Server.Client.Services;
using EnvelopeGenerator.Server.Components;
using EnvelopeGenerator.Server.Models;
using EnvelopeGenerator.Server.Options;
using DevExpress.Blazor;
using EnvelopeGenerator.Server.Client.Services;
using DigitalData.Core.API;
using DigitalData.Core.Application;
using EnvelopeGenerator.Infrastructure;
using EnvelopeGenerator.Domain.Constants;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Localization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using System.Globalization;
using Scalar.AspNetCore;
using Microsoft.OpenApi.Models;
using DigitalData.UserManager.DependencyInjection;
using EnvelopeGenerator.Application;
using DigitalData.Auth.Client;
using DigitalData.Core.Abstractions;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using DigitalData.Core.Abstractions.Security.Extensions;
using NLog.Web;
using Microsoft.OpenApi.Models;
using NLog;
using DigitalData.Auth.Claims;
using EnvelopeGenerator.Server;
using DigitalData.EmailProfilerDispatcher;
using EnvelopeGenerator.Application.Common.Interfaces.Doc;
using EnvelopeGenerator.Infrastructure.Doc;
using NLog.Web;
using Scalar.AspNetCore;
using System.Globalization;
var logger = LogManager.Setup().LoadConfigurationFromAppSettings().GetCurrentClassLogger();
logger.Info("EnvelopeGenerator.Server logging initialized!");
@@ -38,6 +40,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);
@@ -323,7 +326,8 @@ try
});
opt.AddSQLExecutor(executor => executor.ConnectionString = connStr);
})
.AddEnvelopeGeneratorServices(config);
.AddEnvelopeGeneratorServices(config)
.AddNotificationInfrastructure(config);
#pragma warning restore CS0618
builder.Services.AddDispatcher<EGDbContext>();
@@ -342,6 +346,7 @@ try
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.UserPreferencesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ThemeService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CustomImagesService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfSignatureService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.LocalizationService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.ReceiverService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.PdfRenderService>();
@@ -349,13 +354,14 @@ try
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.CreateEnvelopeBootstrapStorageService>();
// Business Services (Server specific)
builder.Services.AddScoped<DocumentService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Client.Services.DocumentService>();
builder.Services.AddScoped<AuthService>();
builder.Services.AddScoped<AnnotationService>();
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>();
@@ -369,6 +375,7 @@ try
builder.Services.AddScoped<EnvelopeGenerator.Server.Services.IEnvelopeAuthService, EnvelopeGenerator.Server.Services.EnvelopeAuthService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Services.EnvelopeReceiverAuthorizationService>();
builder.Services.AddScoped<EnvelopeGenerator.Server.Services.EnvelopeReceiverPageDataService>();
builder.Services.AddHostedService<EnvelopeGenerator.Server.Services.EnvelopeFinalizationWorker>();
// DevExpress Server-Side Services (CRITICAL for DxPdfViewer)
builder.Services.AddDevExpressBlazor();

View File

@@ -0,0 +1,64 @@
using EnvelopeGenerator.Application.PdfSigning;
using EnvelopeGenerator.Domain.Constants;
using EnvelopeGenerator.Infrastructure;
using MediatR;
using Microsoft.EntityFrameworkCore;
namespace EnvelopeGenerator.Server.Services;
public sealed class EnvelopeFinalizationWorker(
IServiceScopeFactory scopeFactory,
ILogger<EnvelopeFinalizationWorker> logger) : BackgroundService
{
private static readonly TimeSpan PollInterval = TimeSpan.FromMinutes(1);
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
try
{
using var scope = scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<EGDbContext>();
var sender = scope.ServiceProvider.GetRequiredService<ISender>();
var envelopeIds = await dbContext.Envelopes
.AsNoTracking()
.Where(e => e.Status == EnvelopeStatus.EnvelopeCompletelySigned && e.DocResult == null)
.Select(e => e.Id)
.ToListAsync(stoppingToken);
foreach(var envelopeId in envelopeIds)
{
try
{
await sender.Send(new FinalizeEnvelopeCommand()
{
Id = envelopeId,
BurnedAllowed = false,
SaveDocResult = true,
}, stoppingToken);
}
catch(Exception ex)
{
// TODO: integrate rabbit mq and handle exception (such as delete)
logger.LogError(ex, "Unexpected exception happened during finalization of envelope. Envelope ID: {EnvelopeId}", envelopeId);
}
}
// continue here
logger.LogDebug("EnvelopeFinalizationWorker found {Count} signed envelope(s) without DOC_RESULT.", envelopeIds.Count);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
logger.LogError(ex, "EnvelopeFinalizationWorker failed while reading envelopes.");
}
await Task.Delay(PollInterval, stoppingToken);
}
}
}

View File

@@ -1,10 +1,11 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
},
,
"AuthClientParams": {
"Url": "http://172.24.12.39:9090/auth-hub",
"PublicKeys": [

View File

@@ -2,6 +2,9 @@
"UseSwagger": true,
"UseScalar": true,
"UseDbMigration": false,
"DevEndpoints": {
"EnableSignatureInkInProduction": false
},
"DiPMode": true,
"Logging": {
"LogLevel": {
@@ -55,6 +58,9 @@
"MainCanvasEnableHiDPI": true,
"ZoomStepPercentage": 5
},
"DocumentService": {
"BaseUrl": "https://localhost:7186"
},
"PSPDFKitLicenseKey": "SXCtGGY9XA-31OGUXQK-r7c6AkdLGPm2ljuyDr1qu0kkhLvydg-Do-fxpNUF4Rq3fS_xAnZRNFRHbXpE6sQ2BMcCSVTcXVJO6tPviexjpiT-HnrDEySlUERJnnvh-tmeOWprxS6BySPnSILkmaVQtUfOIUS-cUbvvEYHTvQBKbSF8di4XHQFyfv49ihr51axm3NVV3AXwh2EiKL5C5XdqBZ4sQ4O7vXBjM2zvxdPxlxdcNYmiU83uAzw7B83O_jubPzya4CdUHh_YH7Nlp2gP56MeG1Sw2JhMtfG3Rj14Sg4ctaeL9p6AEWca5dDjJ2li5tFIV2fQSsw6A_cowLu0gtMm5i8IfJXeIcQbMC2-0wGv1oe9hZYJvFMdzhTM_FiejM0agemxt3lJyzuyP8zbBSOgp7Si6A85krLWPZptyZBTG7pp7IHboUHfPMxCXqi-zMsqewOJtQBE2mjntU-lPryKnssOpMPfswwQX7QSkJYV5EMqNmEhQX6mEkp2wcqFzMC7bJQew1aO4pOpvChUaMvb1vgRek0HxLag0nwQYX2YrYGh7F_xXJs-8HNwJe8H0-eW4x4faayCgM5rB5772CCCsD9ThZcvXFrjNHHLGJ8WuBUFm6LArvSfFQdii_7j-_sqHMpeKZt26NFgivj1A==",
"Content-Security-Policy": [ // The first format parameter {0} will be replaced by the nonce value.
"default-src 'self'",
@@ -272,5 +278,9 @@
"MarginTopRatio": -0.1
}
]
},
"ESignParams": {
"ApplicationName": "signFLOW",
"DefaultReason": " Bestätigt"
}
}

View File

@@ -0,0 +1 @@
sftp -P 22 root@172.24.12.64

View File

@@ -0,0 +1 @@
ssh root@172.24.12.64

View File

@@ -5,7 +5,7 @@ using DigitalData.UserManager.Domain.Entities;
using EnvelopeGenerator.Application;
using EnvelopeGenerator.Application.Common.Configurations;
using EnvelopeGenerator.Application.Common.Extensions;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands;
using EnvelopeGenerator.Application.EnvelopeReceivers.Commands.CreateEnvelopeReceiver;
using EnvelopeGenerator.Application.Envelopes.Commands;
using EnvelopeGenerator.Application.Histories.Commands;
using EnvelopeGenerator.Application.Receivers.Commands;

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFrameworks>net7.0;net8.0</TargetFrameworks>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
@@ -17,7 +17,7 @@
</Content>
</ItemGroup>
<ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net7.0'">
<PackageReference Include="Bogus" Version="35.6.3" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="DigitalData.Core.Abstraction.Application" Version="1.6.0" />
@@ -28,21 +28,60 @@
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="7.0.20" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="7.0.20" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="7.0.20" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.6" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.6" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit.Analyzers" Version="3.9.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.5" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Bogus" Version="35.6.3" />
<PackageReference Include="coverlet.collector" Version="6.0.0" />
<PackageReference Include="DigitalData.Core.Abstraction.Application" Version="1.6.0" />
<PackageReference Include="DigitalData.Core.Abstractions" Version="4.3.0" />
<PackageReference Include="DigitalData.Core.API" Version="2.2.1" />
<PackageReference Include="DigitalData.Core.Application" Version="3.4.0" />
<PackageReference Include="HtmlSanitizer" Version="9.0.892" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.17" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.17" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="8.0.17" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.17">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.17" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.82.1" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
<PackageReference Include="NUnit" Version="3.14.0" />
<PackageReference Include="NUnit.Analyzers" Version="3.9.0" />
<PackageReference Include="NUnit3TestAdapter" Version="4.5.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.12" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\EnvelopeGenerator.Application\EnvelopeGenerator.Application.csproj" />
<ProjectReference Include="..\EnvelopeGenerator.Infrastructure\EnvelopeGenerator.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<ProjectReference Include="..\EnvelopeGenerator.Infrastructure.Crypt\EnvelopeGenerator.Infrastructure.Crypt.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net7.0'">
<Compile Remove="Application\Crypt\**\*.cs" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
<Using Include="NUnit.Framework" />
</ItemGroup>

View File

@@ -40,6 +40,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Dependenc
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "EnvelopeGenerator.Server", "EnvelopeGenerator.Server", "{BF1700D5-592E-4FFA-84E8-5480E289A1F0}"
ProjectSection(SolutionItems) = preProject
EnvelopeGenerator.Server\cnn_sftp_soft_hsm.bat = EnvelopeGenerator.Server\cnn_sftp_soft_hsm.bat
EnvelopeGenerator.Server\cnn_ssh_soft_hsm.bat = EnvelopeGenerator.Server\cnn_ssh_soft_hsm.bat
EnvelopeGenerator.Server\publish.bat = EnvelopeGenerator.Server\publish.bat
SENDER_SAVE_EDIT_MIGRATION_PLAN.md = SENDER_SAVE_EDIT_MIGRATION_PLAN.md
EndProjectSection
@@ -54,6 +56,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Service",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Infrastructure.Doc", "EnvelopeGenerator.Infrastructure.Doc\EnvelopeGenerator.Infrastructure.Doc.csproj", "{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Infrastructure.Crypt", "EnvelopeGenerator.Infrastructure.Crypt\EnvelopeGenerator.Infrastructure.Crypt.csproj", "{B33A3B17-A4F8-4307-9C51-DDFE1B292DE7}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EnvelopeGenerator.Infrastructure.Notification", "EnvelopeGenerator.Infrastructure.Notification\EnvelopeGenerator.Infrastructure.Notification.csproj", "{6F328677-0E90-40A7-9CA9-CE050182D7FC}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -120,6 +126,14 @@ Global
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Debug|Any CPU.Build.0 = Debug|Any CPU
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Release|Any CPU.ActiveCfg = Release|Any CPU
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A}.Release|Any CPU.Build.0 = Release|Any CPU
{B33A3B17-A4F8-4307-9C51-DDFE1B292DE7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B33A3B17-A4F8-4307-9C51-DDFE1B292DE7}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B33A3B17-A4F8-4307-9C51-DDFE1B292DE7}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B33A3B17-A4F8-4307-9C51-DDFE1B292DE7}.Release|Any CPU.Build.0 = Release|Any CPU
{6F328677-0E90-40A7-9CA9-CE050182D7FC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6F328677-0E90-40A7-9CA9-CE050182D7FC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6F328677-0E90-40A7-9CA9-CE050182D7FC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6F328677-0E90-40A7-9CA9-CE050182D7FC}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -144,6 +158,8 @@ Global
{83ED2617-B398-4859-8F59-B38F8807E83E} = {9943209E-1744-4944-B1BA-4F87FC1A0EEB}
{6B87FB5F-F4EA-45BE-A218-385DC2CE8E80} = {E3C758DC-914D-4B7E-8457-0813F1FDB0CB}
{3B6EFE8D-F1EE-4666-87A0-9F90149CC61A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{B33A3B17-A4F8-4307-9C51-DDFE1B292DE7} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{6F328677-0E90-40A7-9CA9-CE050182D7FC} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {73E60370-756D-45AD-A19A-C40A02DACCC7}