(feature): architecture improvements and session persistance

This commit is contained in:
Maksym Sadovnychyy 2026-05-01 09:47:52 +02:00
parent 098fa91515
commit 4c92f6c25b
61 changed files with 1520 additions and 638 deletions

View File

@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.4.1] - 2026-04-30
### Breaking
- **Engine query ports (Vault-style):** **`IUserQueryService`**, **`IApiKeyQueryService`**, and **`IApiKeyEntityScopeQueryService`** no longer expose async paged **`Search…Async`** with string filters. They now use synchronous **`Search`** / **`Count`** with optional **`Expression<Func<TDto, bool>>?`** predicates (Linq2Db-translatable), **`skip` / `limit`**, and **`Result`** types—matching the thin-search wiring in **`IdentityService`** / **`ApiKeyService`**. Custom Engine hosts must update call sites and registrations.
- **ACME session persistence:** **`IAcmeSessionStore`**, **`AcmePostgresSessionStore`**, **`AcmeSessionSnapshot`**, and **`AcmeSessionJsonSerializer`** are removed. **`ILetsEncryptService`** now depends on **`IAcmeSessionPersistanceService`** (**`AcmeSessionPersistanceServiceLinq2Db`**) for **`acme_sessions`** JSON load/save.
- **`ICertsFlowDomainService`:** Constructor takes **`IRegistrationCacheDomainService`** instead of **`IRegistrationCachePersistanceService`** (registration cache orchestration moved behind **`RegistrationCacheDomainService`**).
### Added
- **`ExpressionCompose`** (`QueryServices/ExpressionCompose.cs`) for composing nested Linq2Db predicates (Vault parity).
- **`IRegistrationCacheDomainService`** / **`RegistrationCacheDomainService`**, **`RegistrationCachePayloadDocument`**, and **`RegistrationCachePayloadJsonTests`** (Engine unit tests) for registration-cache JSON handling; **`RegistrationCacheDto`** now extends **`DtoDocumentBase<Guid>`** with **`AccountId`** as an alias of **`Id`**; persistence and mapping updates in **`RegistrationCachePersistanceServiceLinq2Db`** / **`CertsLinq2DbMapping`**.
- **`IAcmeSessionPersistanceService`**, **`AcmeSessionPersistanceServiceLinq2Db`**, and **`AcmeSessionPayloadMapper`** for PostgreSQL-backed ACME **`State`** persistence.
- **`ApiKeyEntityScopeDto`** and **`ApiKeyEntityScopeQueryServiceStub`** adjustments for entity-scope search parity.
- **Docs:** **`assets/docs/ARCHITECTURE_LAYERING.md`** (layering, spine flows, Pattern A/B); **[CONTRIBUTING.md](CONTRIBUTING.md)** links to it and documents **`dotnet test`** for **`MaksIT.CertsUI.Engine.Tests`** / **`MaksIT.CertsUI.Tests`**.
### Changed
- **`LetsEncryptService`:** Uses **`IAcmeSessionPersistanceService`**; helper updates in **`LetsEncryptService.Helpers.cs`**.
- **`CertsFlowDomainService`:** **`PurgeStaleHttpChallengesAsync`** (HTTP-01 cleanup); **`AutoRenewal`** calls it before renewal work.
- **`CacheService`:** Thin façade over **`IRegistrationCacheDomainService`** (host API unchanged for callers).
- **`IdentityService`** / **`ApiKeyService`:** Build predicates and call **`Count`** + **`Search`** on **`IUserQueryService`** / **`IApiKeyQueryService`** / **`IApiKeyEntityScopeQueryService`**.
- **Engine:** Dropped **`Newtonsoft.Json`** package reference from **`MaksIT.CertsUI.Engine`** (STJ-only JSON paths).
- **Web UI:** **`axiosConfig`** **`getData`** / **`postData`** (and related helpers) return **`{ payload, status, ok }`** so callers can distinguish HTTP status; forms and slices updated (**`SearchUser`**, **`SearchApiKey`**, **`Utilities`**, **`EditUser`**, **`Home`**, **`FileUploadComponent`**, **`identitySlice`**, etc.).
- **Integration tests:** **`InMemoryUserStore`**, **`CacheServiceTests`**, **`CertsFlowServiceTests`**, **`ApiKeyQueryServiceIntegrationTests`**, **`AccountServicePatchAccountIntegrationTests`** aligned with the new ports.
## [3.4.0] - 2026-04-27
### Breaking

View File

@ -12,6 +12,12 @@ Useful contributions include bug fixes, documentation improvements, Helm chart u
Large or architectural changes are best discussed first (see [Contact](#contact)) so effort aligns with project goals.
## Architecture and code layout
**Where the rules live:** layering, folder responsibilities, persistence vs host boundaries, DI lifetimes, and an AI/contributor checklist are documented in **[assets/docs/ARCHITECTURE_LAYERING.md](assets/docs/ARCHITECTURE_LAYERING.md)**. Read that before adding new Engine persistence, services, or cross-project dependencies.
**Summary:** `MaksIT.CertsUI.Engine` holds domain, PostgreSQL persistence (Linq2Db), migrations, and ACME engine code; it returns `MaksIT.Results` types, not HTTP responses. `MaksIT.CertsUI` is the web host (controllers, app services, ProblemDetails). Topic-specific design notes also live under [assets/docs/](assets/docs/) (HA, auth, proxy, etc.).
## Development setup
### Prerequisites
@ -34,7 +40,7 @@ Use `Debug` while iterating locally if you prefer.
Follow [README.md](README.md) for Podman Compose, Docker Compose, or Kubernetes (Helm). That is the supported way to exercise the WebAPI, WebUI, and reverse proxy together.
There is no separate automated test project in this repository today; manual verification through the WebUI and your compose or cluster setup is the practical check for most changes.
**Automated tests:** from the repo root, `dotnet test src/MaksIT.CertsUI.Engine.Tests` and `dotnet test src/MaksIT.CertsUI.Tests` (the latter may require a reachable PostgreSQL when integration tests run). For UI-only or deployment changes, manual verification through the WebUI and compose or cluster setup still applies.
## Pull requests

View File

@ -0,0 +1,344 @@
# Architecture layering (Certs UI)
How **MaksIT.CertsUI** (host) and **MaksIT.CertsUI.Engine** (library) split work so HTTP, business rules, and PostgreSQL stay in the right place. Complements `assets/docs/` (HA, auth, proxy).
**Branches, PRs, changelog:** [CONTRIBUTING.md](../../CONTRIBUTING.md).
---
## At a glance
| | **Host** `MaksIT.CertsUI` | **Engine** `MaksIT.CertsUI.Engine` |
|--|---------------------------|-------------------------------------|
| **Owns** | Controllers, app `Services/`, auth, DI, `ToActionResult()`, ProblemDetails | `Domain/`, `DomainServices/`, `Persistance/`, `QueryServices/`, integration `Services/` (e.g. ACME HTTP) |
| **Must not** | Linq2Db, raw SQL, `IPersistanceService` / `IQueryService` in controllers | `IActionResult`, HTTP types, host-only policy |
| **Returns** | HTTP responses | `Result` / `Result<T>` (`MaksIT.Results`) |
**Single spine (request direction):**
```text
Controller → App Service → IDomainService → IPersistanceService OR IQueryService → Linq2Db → PostgreSQL
```
**Shortcut (thin paged search in this repo):** **Controller → App Service → `I*QueryService` → …** with **no** **`IDomainService`** hop—see [Pattern B](#pattern-b-thin-search-vault-style) (`IdentityService.SearchUsersAsync`, `ApiKeyService.Search…`).
**App Service** is drawn as a **box that contains** **`Mappers/`** on both sides of the Engine call: **Request / wire models → domain or engine inputs** (outbound), then **engine `Result` / `Query/` / domain → Response DTOs** (inbound). Mapping types live under **`MaksIT.CertsUI/Mappers/`**; orchestration and **`Result`** handling live in **`Services/`**.
Details: **[Request flow](#request-flow-to-database)** → **[Response flow](#response-flow-to-client)**. Reads: **[Query flow](#query-flow-reads)** (full stack vs thin search).
ACME HTTP and similar integration run **inside** the `IDomainService` step—not a parallel stack.
**Aligned with MaksIT.Vault:** thin host `Services/` call **`I*DomainService`** (or **`I*QueryService`** for thin search). Hosted jobs use a **scope** and **Engine `IDomainService`** (and may call a **thin host façade** such as **`ICertsFlowService`**, which forwards to **`ICertsFlowDomainService`**); they **do not** resolve **`IPersistanceService`** / **`IQueryService`** directly from the worker class.
---
**Diagram convention:** **Every** spine figure is **linear**: a **single** **`-->`** chain (**no forks**). **Outbound** uses **`flowchart TB`** (**1** at top → **8 · PostgreSQL** at bottom). **Inbound** (response) uses **`flowchart BT`** (**8 → … → 1** toward HTTP). **Step 3 · App Service** appears as **one or more consecutive nodes** on that chain (labels name the beats).
## Request flow (to database)
```mermaid
%% Spine outbound: request 1 to 8
flowchart TB
o1["1 Controller"] --> o2["2 Request"] --> o_map["3 · Mappers: Request to domain or engine inputs"] --> o_orch["3 · Orchestration RBAC, call IDomainService"] --> o5["5 DomainService"] --> o6["6 Persist or Query port"] --> o7["7 Linq2Db + Dto"] --> o8[(8 PostgreSQL)]
```
| Step | Where | Role |
|:----:|-------|------|
| 1 | Host | Call **one** app service; `Result``ToActionResult()` (or `Content` for ACME token). |
| 2 | Host | Route + `MaksIT.Models` (or host models). |
| 3 | Host | **`App Service`**: **`Mappers/`** · Request → domain / engine inputs; orchestration; then **`IDomainService`** *or* (thin search) **`I*QueryService`**—see **Response** for **`Mappers/`** → Response DTOs. **JWT** (and similar) is enforced at the **controller**; extra predicate scope (e.g. org) belongs in the app service when you add it. |
| 5 | Engine | **`IDomainService`**: rules + orchestration; may call Engine **`Services/`** (HTTP). |
| 6 | Engine | **`IPersistanceService`** *or* **`IQueryService`** (one style per hop). |
| 7 | Engine | Linq2Db + **`Dto/`**; **persist mappers** for row / JSON columns. |
| 8 | DB | PostgreSQL. |
**Shortcuts (still one line, not a fork):**
- **Search / list:** sometimes **3 → 6** with no extra domain logic: app service → **`IQueryService`** → Linq2Db (middle steps **skipped**) — see **[Query flow — Pattern B](#pattern-b-thin-search-vault-style)**.
- **Hosted:** **`IServiceScopeFactory`** → e.g. **`IRegistrationCacheDomainService`**, **`ICertsFlowDomainService`**, **`ICertsFlowService`** (`AutoRenewal`); never inject **`IPersistanceService`** / **`IQueryService`** on the **hosted** class itself.
**Variants at steps 67 only:**
| | Step 6 | Step 7 |
|--|--------|--------|
| **Write** | `IPersistanceService` | Domain→Dto if needed, then write |
| **Read by key** | `IPersistanceService` | Read Dto, Dto→Domain, return |
| **Read search** | `IQueryService` | Projection → domain or `Query/` type |
---
## Response flow (to client)
Data and **`Result<T>`** unwind **stage by stage** toward HTTP. Nothing “teleports” from Linq2Db to the controller: each layer maps what it owns.
**Spine (database → wire):** **data and `Result<T>`** walk **8→1** (PostgreSQL → … → Controller) in **one line**. Steps **85** are Engine-side (same as outbound). **3 · App Service** is **three beats on that line**: from Engine → unwrap **`Result`** → orchestrate → **`Mappers/`** · domain / **`Query/`** → **`MaksIT.Models`** / response DTOs. The Mermaid figure uses **`flowchart BT`** so arrows run **toward HTTP** (not toward the DB).
```text
PostgreSQL → … → IDomainService → App Service (internal: Mappers · → Response DTOs) → Controller → ToActionResult() / Content / ProblemDetails
```
| Step | Direction | Responsibility |
|:----:|-------------|----------------|
| 8→7 | Engine | **Persist path:** materialize **`Dto/`**; **`Persistance/Mappers`**: **Dto → domain**. **Query path:** materialize **`Dto/`** (or joined Dtos); **`QueryServices/.../Linq2Db`**: **Dto → `Query/`** (e.g. `MapToQueryResult`). |
| 7→6 | Engine | Port returns domain, **`Query/`** read model, or **`Result<T>`** payload. |
| 6→5 | Engine | **`IDomainService`** may enrich, validate, or aggregate before returning **`Result`**. |
| 5→3 | Host | **`App Service`**: orchestration · RBAC · unwrap **`Result`**; **`MaksIT.CertsUI/Mappers`** · engine outputs / **`Query/`** / domain → response DTOs (**three consecutive nodes for step 3** in diagram). |
| 3→1 | Host | **`Controller`**: **`ToActionResult(result)`** or **`Content(...)`** (ACME). |
```mermaid
%% Spine inbound: response 8 to 1 toward HTTP
flowchart BT
i8[(8 PostgreSQL)] --> i7["7 Linq2Db + Dto"] --> i6["6 Persist or Query port"] --> i5["5 DomainService"] --> i_from["3 · From Engine: Result, domain, Query"] --> i_orch["3 · Orchestration RBAC, unwrap Result"] --> i_map["3 · Mappers to Response DTOs"] --> i2["2 Request binding context"] --> i1["1 Controller ToActionResult or Content"]
```
**Thin search shortcut:** if the request skipped **`IDomainService`**, the response still walks **7 → 6 → (skip 5) → 3 → 1**: **`Query/`** (already mapped from **`Dto/`** in step 7) → **`App Service`** (mapper inside step **3**) → **`Result`** → **`ToActionResult()`**.
---
## Query flow (reads)
Reads always hit **PostgreSQL through Linq2Db**; only **who calls the read port** changes.
**Mapping to query results (Engine read model):** inside **`QueryServices/.../Linq2Db`**, Linq2Db materializes **`Dto/`** table rows (or joins). Implementations then **map `Dto` → types under `Query/`** (e.g. `UserQueryResult`, `ApiKeyQueryResult`) before returning **`Result<List<…>>`** from **`Search`** (Vault-style). That **Dto → `Query/`** step is the **query-side read mapper**—not web API mappers and not **`Persistance/Mappers`** (those are for writes / JSON columns / domain load). See e.g. **`UserQueryServiceLinq2Db`** (`MapToQueryResult`).
**Predicates (Vault parity):** **`IUserQueryService`**, **`IApiKeyQueryService`**, and **`IApiKeyEntityScopeQueryService`** take **`Expression<Func<TDto, bool>>?`** plus **`skip` / `limit`** and a separate **`Count`** with the same predicate. The **host** builds translatable predicates (today: simple filters such as **`Contains`** on username/description). **`ExpressionCompose`** (`QueryServices/ExpressionCompose.cs`) is available when you need composed predicates through navigation (Vault parity)—not required for the current search callers.
**Inside `IQueryService` (Linq2Db implementation):**
```text
PostgreSQL → Linq2Db (materialize Dto / joins) → optional Where(predicate) → map Dto → Query/ types → Result<List<>> to caller; Count uses the same predicate
```
The caller is either **`IDomainService`** (Pattern A) or **app `Service`** (Pattern B).
### Pattern A: Domain-centered read (`IDomainService`)
Use when a use case must go through **one Engine orchestration place** (invariants, multiple ports, ACME side effects, load-by-key).
**In this repo, Identity and API keys use persistence for domain loads, not the query port:** e.g. **`ReadUserByIdAsync`** and **`ReadAPIKeyAsync`** go **App → `I*DomainService` → `I*PersistanceService` → Linq2Db** (**Dto → domain** via **`Persistance/Mappers`**), not **`I*QueryService`**.
**When listing is owned by the domain** (not implemented for Identity/API key search here), the shape is:
```text
Controller → App Service → IDomainService → IQueryService (PostgreSQL → … → Query/) → Result back up
```
**Response:** if the domain sits in the middle, unwind through **`IDomainService`**; if the request used **Pattern B**, **`IDomainService`** is skipped on the return path too (see [Response flow](#response-flow-to-client)).
```mermaid
%% Query Pattern A outbound
flowchart TB
a1["1 Controller"] --> a2["2 Request"] --> a_map["3 · Mappers: Request to domain or engine inputs"] --> a_orch["3 · Orchestration RBAC, call IDomainService"] --> a5["5 DomainService"] --> a6["6 Persist or Query port"] --> a7["7 Linq2Db + Dto"] --> a8[(8 PostgreSQL)]
```
### Pattern B: Thin search (Vault-style)
Use when **only filtering + paging + projection** are needed and **no extra engine rules** apply. **This repo:** **`IdentityService.SearchUsersAsync`** and **`ApiKeyService.Search…`** (JWT on the **controller**; predicate built in the app service).
```text
Controller → App Service → IQueryService (impl: PostgreSQL → Linq2Db Dto → map → Query/) → Result back up
```
**Response:** **`IQueryService`** returns **`Result<List<QueryType>?>`** from **`Search`** (rows already mapped from **`Dto/`**); **`Count`** supplies **`TotalRecords`**. **App service** maps **`Query/`** → **`MaksIT.Models`** / paged API DTOs → controller → **`ToActionResult()`**.
```mermaid
%% Query Pattern B outbound thin search
flowchart TB
b1["1 Controller"] --> b2["2 Request"] --> b_map["3 · Mappers predicates RBAC"] --> b_qry["3 · IQueryService"] -->|"skip DomainService"| b6["6 Persist or Query port"] --> b7["7 Linq2Db + Dto"] --> b8[(8 PostgreSQL)]
```
**Pick A vs B:** **B** is what **Identity** and **API key** **search** use today. Prefer **A** (domain calls **`IQueryService`**) when list rules must live in Engine; **get-by-id** here stays **domain → `IPersistanceService`**, not **`IQueryService`**.
---
## Layers (detail)
### Host — `MaksIT.CertsUI`
| Layer | Responsibility |
|-------|----------------|
| **Controllers** | Thin: app service + `ToActionResult()`. No business rules, no Linq2Db. |
| **Models** | Often **`MaksIT.Models`** for shared API shapes with **MaksIT.WebUI**. |
| **App `Services/`** | **Only** controller entry for a use case: orchestration · RBAC · **`IDomainService`** / **`IQueryService`**; **invokes** **`Mappers/`** before Engine (**Request** → domain / engine inputs) and after Engine (**`Result`** / **`Query/`** / domain → **Response** DTOs). |
| **Web `Mappers/`** (`Mappers/`) | Types used **from** app services: **Request** → domain / engine inputs; engine outputs → **Response** DTOs. Not **`Engine/Persistance/Mappers`** (table / JSON payloads). |
### Engine — `MaksIT.CertsUI.Engine`
| Layer | Responsibility |
|-------|----------------|
| **`DomainServices/`** | Use cases: **`IPersistanceService`**, Engine **`Services/`**; may also call **`IQueryService`** when a use case should own search (Identity/API key **search** in this repo is **Pattern B** from the host). No HTTP return types. |
| **`Persistance/`** | Writes (and load-by-key APIs): **`I*PersistanceService`** + Linq2Db, **`Dto/`**, **`Persistance/Mappers`**. |
| **`QueryServices/`** | Reads: **`I*QueryService`** + Linq2Db + **`Query/`**. |
| **`Services/`** (Engine) | Integration **used by** `DomainServices` (e.g. **`ILetsEncryptService`**). Not a second app-service layer. |
| **`Domain/`** | Entities / value objects: no Linq2Db, HTTP, or host types. |
**Engine slice (same idea as the spine):**
| | Purpose | Examples |
|--|---------|----------|
| **DomainServices** | Orchestrate persistence + integration; **`IQueryService`** only when domain owns search (not Identity/API key search here) | `CertsFlowDomainService`, `IdentityDomainService`, `RegistrationCacheDomainService` |
| **QueryServices** | Read port | `IUserQueryService` + `UserQueryServiceLinq2Db` |
| **Persistance** | Write port (+ loads exposed as persistence API) | `IRegistrationCachePersistanceService` |
| **Services** | Outbound HTTP / protocol | `ILetsEncryptService` |
**Guideline:** **`GetTable<>` / SQL** only in **`.../Linq2Db`** under Persistance and QueryServices.
---
## Solution map
| Project | Role |
|---------|------|
| **MaksIT.CertsUI** | ASP.NET: controllers, app services, mappers, DI, pipeline. |
| **MaksIT.CertsUI.Engine** | Domain, domain services, persistence, queries, migrations, ACME integration. |
| **MaksIT.Models** | Shared request/response for WebUI + API. |
| **MaksIT.WebUI** | React SPA. |
| **ReverseProxy** | Optional YARP. |
| **\*Tests** | Unit (Engine) and integration (host + DB). |
**Golden rule:** HTTP and status codes stay in the **host**; PostgreSQL and reusable orchestration stay in the **Engine** behind **`DomainServices`** (and persist/query ports).
---
## Folder layout
### `src/MaksIT.CertsUI/`
Host-only: composition, HTTP, auth, thin façades into Engine.
```
MaksIT.CertsUI/
├── Program.cs # Pipeline, DI (AddCertsEngine, filters, hosted services)
├── Configuration.cs # IOptions settings + secrets
├── Controllers/ # Thin; Result → ToActionResult (or Content for ACME)
├── Services/ # App services → IDomainService / IQueryService (search)
├── Mappers/ # Request/Response ↔ domain or Query/ (not Engine persist mappers)
├── Authorization/ # JWT, API key, filters, HttpContext helpers
├── Abstractions/ # e.g. ServiceBase, BaseAsyncAuthorizationFilter
├── HostedServices/ # Scope + engine domain (+ thin ICertsFlowService, etc.); not IPersistanceService on the worker
├── Infrastructure/ # e.g. IRuntimeInstanceIdProvider for HA
└── Properties/
```
*(Omitted from tree: `bin/`, `obj/`, `appsettings*.json`, Docker.)*
### `src/MaksIT.CertsUI.Engine/` (`Engine/` in prose)
```
Engine/
├── Domain/
├── DomainServices/
├── Dto/
├── Data/ # CertsLinq2DbMapping
├── Persistance/
│ ├── Mappers/
│ └── Services/ … Linq2Db/
├── Query/
├── QueryServices/ # I*QueryService + ExpressionCompose; Linq2Db/… implementations
├── Services/ # LetsEncrypt etc. — called from DomainServices
├── Infrastructure/
├── FluentMigrations/
├── RuntimeCoordination/ # HA — see HA_ARCHITECTURE.md
├── Extensions/ # AddCertsEngine
└── Facades/ …
```
**Spelling:** most paths use **`Persistance`** (historic). A few types say **`Persistence`** — keep per-file consistency; do not mass-rename in small PRs.
**Mapper kinds:**
| Where | Maps |
|-------|------|
| **`MaksIT.CertsUI/Mappers`** | API request/response ↔ domain or **`Query/`** |
| **`Engine/Persistance/Mappers`** | Domain ↔ table row / JSON column (`Dto`) |
| **`Engine/QueryServices/.../Linq2Db`** (inline or private helpers) | **`Dto/`** rows / joins → **`Query/`** read models (e.g. `UserQueryResult`) |
---
## As-built routes (quick map)
Each row fits the **spine** above; only **persist vs query** and **skipped** steps differ.
### API + Engine
| Area | Controller | App service | Engine |
|------|------------|-------------|--------|
| ACME steps | `CertsFlowController` | `CertsFlowService` | `ICertsFlowDomainService` |
| ACME token | `WellKnownController` | `CertsFlowService` | `ICertsFlowDomainService``Content(text/plain)` |
| Identity CRUD / login | `IdentityController` | `IdentityService` | `IIdentityDomainService` |
| Identity search | `IdentityController` | `IdentityService` | `IUserQueryService` → mapper (Vault-style) |
| API keys | `APIKeyController` | `ApiKeyService` | `IApiKeyDomainService` / `IApiKeyQueryService` |
| Registration cache | `CacheController` | `CacheService` | `IRegistrationCacheDomainService` |
| Accounts / certs | `AccountController` | `AccountService` | `ICertsFlowService``ICertsFlowDomainService` |
### Other
| Area | Entry | Notes |
|------|-------|------|
| Renewal | `AutoRenewal` | Scoped `IRegistrationCacheDomainService`, `ICertsFlowDomainService`, `ICertsFlowService` (calls `FullFlow` / cache load) |
| Agent | `AgentController``AgentService` | Outbound HTTP only; no Engine domain |
| Debug | `DebugController` | `IRuntimeInstanceId`; bypasses app services |
---
## Hard rules
1. **Controllers** → app **`Services/`** only (except trivial debug). No **`IPersistanceService`**, **`IQueryService`**, **`ILetsEncryptService`** on controllers.
2. **App services****`IDomainService`** for use cases; **`CacheService`** → **`IRegistrationCacheDomainService`**. Search may use **`I*QueryService`** (see Identity / API keys).
3. **`Domain/`** → no Persistance, Linq2Db, `HttpClient`, host types.
4. **`DomainServices/`** → ports + Engine **`Services/`**; no raw SQL / `GetTable<>` (only in Linq2Db types).
5. **Persistance Linq2Db****`ICertsDataConnectionFactory`**, **`Dto/`**, **`Data/CertsLinq2DbMapping`**.
6. **Engine JSON** (DB / zip) → **`MaksIT.Core`** `ToJson()` / `ToObject<T>()` (STJ); no new Newtonsoft in Engine.
7. **HTTP status** → host `ToActionResult()`; Engine stays on **`Result`**.
**Variance:** “DomainService on every read” is not required for **thin paged search**; adding a domain wrapper is optional if shared rules appear.
---
## Host details
- **Controllers:** inject **`Services/*`**; **`WellKnownController`** uses **`Content(..., "text/plain")`** for ACME.
- **App services:** inject Engine ports (**`IDomainService`**, **`IQueryService`** when searching), **`IOptions`**, other app services; **call `Mappers/`** for **Request → engine-shaped inputs** and **`Result` / `Query/` / domain → API response models**—keep mapping logic in **`MaksIT.CertsUI/Mappers/`**, orchestration in **`Services/`**.
---
## Dependency injection (Engine)
Central: **`Extensions/ServiceCollectionExtensions.cs`** (`AddCertsEngine`).
- **`ICertsDataConnectionFactory`** — Scoped
- **Persistence / query Linq2Db** — Scoped
- **`ILetsEncryptService`** — typed `HttpClient`
- **`IRegistrationCacheDomainService`** — Scoped
- **`IRuntimeLeaseService`** — Singleton where HA docs say so
Avoid **Scoped** inside **Singleton** without `IServiceScopeFactory`; prefer scoped persistence like Vault.
---
## Tests
- **MaksIT.CertsUI.Engine.Tests** — Engine unit tests (no full host).
- **MaksIT.CertsUI.Tests** — Integration tests + PostgreSQL; mirror production DI where possible.
---
## Contributor checklist
1. New **HTTP use case** → controller → app **`Services/`** → **`IDomainService`** (or **`IQueryService`** for thin paging only).
2. New **DB write****`Persistance/Services/I…`** + **`Linq2Db/`** + register; **`DomainServices`** call persistence—not host **`CacheService`** for engine rules.
3. New **read/report****`QueryServices`** + **`Query/`**; either domain service or app service calls query—**match** Identity / API key search.
4. New **table shape****`Dto/`** + **`CertsLinq2DbMapping`**.
5. **JSON column mapping****`Persistance/Mappers`**, not controllers.
6. **API contract****`MaksIT.Models`** + web mappers, not Engine `Dto` unless it is a real table shape.
7. **Invariants****`Domain/`** or **`DomainServices/`**.
8. **OpenAPI / status codes** → host only.
---
## Related docs
- [HA_ARCHITECTURE.md](./HA_ARCHITECTURE.md)
- [LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md](./LOGIN_AND_REFRESH_TOKEN_ARCHITECTURE.md)
- [REVERSE_PROXY_ROUTING.md](./REVERSE_PROXY_ROUTING.md)
- [PATCH_DELTA_REFERENCE.md](./PATCH_DELTA_REFERENCE.md)

View File

@ -0,0 +1,48 @@
using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Domain.Certs;
using Xunit;
namespace MaksIT.CertsUI.Engine.Tests;
/// <summary>
/// Same JSON contract as <c>registration_caches.PayloadJson</c> (load/save in
/// <see cref="MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db.RegistrationCachePersistanceServiceLinq2Db"/>)
/// and as written into zip entries via <c>RegistrationCache.ToJson()</c> (MaksIT.Core STJ helpers).
/// </summary>
public class RegistrationCachePayloadJsonTests {
private static readonly Guid Aggregate = Guid.Parse("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
[Fact]
public void PayloadJson_ToJson_ToObject_roundtrips_like_registration_caches_row() {
const string acmeResource = "https://acme.example/acct/1";
var cache = new RegistrationCache {
AccountId = Aggregate,
AcmeAccountResourceId = acmeResource,
Description = "unit payload",
Contacts = ["mailto:ops@example.com"],
IsStaging = true,
ChallengeType = "http-01",
IsDisabled = false
};
var payloadJson = cache.ToJson();
var loaded = payloadJson.ToObject<RegistrationCache>();
Assert.NotNull(loaded);
Assert.Equal(Aggregate, loaded!.AccountId);
Assert.Equal(acmeResource, loaded.AcmeAccountResourceId);
Assert.Equal(cache.Description, loaded.Description);
Assert.Equal(cache.Contacts, loaded.Contacts);
Assert.Equal(cache.IsStaging, loaded.IsStaging);
Assert.Equal(cache.ChallengeType, loaded.ChallengeType);
Assert.Equal(cache.IsDisabled, loaded.IsDisabled);
var payloadJson2 = loaded.ToJson();
var loaded2 = payloadJson2.ToObject<RegistrationCache>();
Assert.NotNull(loaded2);
Assert.Equal(loaded.AcmeAccountResourceId, loaded2!.AcmeAccountResourceId);
Assert.Equal(loaded.AccountId, loaded2.AccountId);
Assert.Equal(loaded.Description, loaded2.Description);
Assert.Equal(loaded.Contacts, loaded2.Contacts);
}
}

View File

@ -63,7 +63,8 @@ public static class CertsLinq2DbMapping {
// RegistrationCacheDto -> registration_caches
builder.Entity<RegistrationCacheDto>()
.HasTableName(Table.RegistrationCaches.Name)
.Property(x => x.AccountId).HasColumnName("AccountId").IsPrimaryKey()
.Property(x => x.Id).HasColumnName("AccountId").IsPrimaryKey()
.Property(x => x.AccountId).IsNotColumn()
.Property(x => x.Version).HasColumnName("Version")
.Property(x => x.PayloadJson).HasColumnName("PayloadJson");

View File

@ -44,6 +44,11 @@ public interface ICertsFlowDomainService {
#region HTTP-01 challenge
Task<Result<string?>> AcmeChallengeAsync(string fileName, CancellationToken cancellationToken = default);
#endregion
#region Maintenance
/// <summary>Deletes HTTP-01 challenge rows older than <paramref name="maxAge"/> (used by renewal sweep).</summary>
Task<Result<int>> PurgeStaleHttpChallengesAsync(TimeSpan maxAge, CancellationToken cancellationToken = default);
#endregion
}
/// <summary>
@ -56,7 +61,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
private readonly ILogger<CertsFlowDomainService> _logger;
private readonly HttpClient _httpClient;
private readonly ILetsEncryptService _letsEncryptService;
private readonly IRegistrationCachePersistanceService _registrationCache;
private readonly IRegistrationCacheDomainService _registrationCache;
private readonly IAgentDeploymentService _agentDeployment;
private readonly ICertsFlowEngineConfiguration _config;
private readonly ITermsOfServiceCachePersistenceService _termsOfServiceCache;
@ -68,7 +73,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
ILogger<CertsFlowDomainService> logger,
HttpClient httpClient,
ILetsEncryptService letsEncryptService,
IRegistrationCachePersistanceService registrationCache,
IRegistrationCacheDomainService registrationCache,
IAgentDeploymentService agentDeployment,
ICertsFlowEngineConfiguration config,
ITermsOfServiceCachePersistenceService termsOfServiceCache,
@ -190,7 +195,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
accountId = Guid.NewGuid();
}
else {
var cacheResult = await _registrationCache.LoadAsync(accountId.Value);
var cacheResult = await _registrationCache.LoadAsync(accountId.Value, CancellationToken.None).ConfigureAwait(false);
if (!cacheResult.IsSuccess || cacheResult.Value == null) {
accountId = Guid.NewGuid();
}
@ -249,7 +254,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
if (!cacheResult.IsSuccess || cacheResult.Value == null)
return cacheResult;
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value);
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value, CancellationToken.None).ConfigureAwait(false);
if (!saveResult.IsSuccess)
return saveResult;
return Result.Ok();
@ -264,7 +269,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
#region Deploy and revoke
public async Task<Result<Dictionary<string, string>?>> ApplyCertificatesAsync(Guid accountId) {
var cacheResult = await _registrationCache.LoadAsync(accountId);
var cacheResult = await _registrationCache.LoadAsync(accountId, CancellationToken.None).ConfigureAwait(false);
if (!cacheResult.IsSuccess || cacheResult.Value?.CachedCerts == null)
return cacheResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
var cache = cacheResult.Value;
@ -291,7 +296,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
if (!cacheResult.IsSuccess || cacheResult.Value == null)
return cacheResult;
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value);
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value, CancellationToken.None).ConfigureAwait(false);
if (!saveResult.IsSuccess)
return saveResult;
return Result.Ok();
@ -384,12 +389,19 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
#endregion
#region Maintenance
public Task<Result<int>> PurgeStaleHttpChallengesAsync(TimeSpan maxAge, CancellationToken cancellationToken = default) =>
_httpChallenges.DeleteOlderThanAsync(maxAge, cancellationToken);
#endregion
private async Task TryPersistRegistrationCacheFromSessionAsync(Guid sessionId) {
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
if (!cacheResult.IsSuccess || cacheResult.Value == null)
return;
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value);
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value, CancellationToken.None).ConfigureAwait(false);
if (!saveResult.IsSuccess)
_logger.LogWarning("Could not persist registration cache after ACME flow step for account {AccountId}.", cacheResult.Value.AccountId);
}

View File

@ -0,0 +1,40 @@
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.Results;
namespace MaksIT.CertsUI.Engine.DomainServices;
/// <summary>
/// Registration cache use cases (load/save, zip import/export). Orchestrates <see cref="Persistance.Services.IRegistrationCachePersistanceService"/> only from the engine layer.
/// </summary>
public interface IRegistrationCacheDomainService {
#region Read
Task<Result<RegistrationCache[]?>> LoadAllAsync(CancellationToken cancellationToken = default);
Task<Result<RegistrationCache?>> LoadAsync(Guid accountId, CancellationToken cancellationToken = default);
#endregion
#region Write
Task<Result> SaveAsync(Guid accountId, RegistrationCache cache, CancellationToken cancellationToken = default);
Task<Result> DeleteAllAsync(CancellationToken cancellationToken = default);
Task<Result> DeleteAsync(Guid accountId, CancellationToken cancellationToken = default);
#endregion
#region Zip import/export
Task<Result<byte[]>> DownloadCacheZipAsync(CancellationToken cancellationToken = default);
Task<Result<byte[]?>> DownloadAccountCacheZipAsync(Guid accountId, CancellationToken cancellationToken = default);
Task<Result> UploadCacheZipAsync(byte[] zipBytes, CancellationToken cancellationToken = default);
Task<Result> UploadAccountCacheZipAsync(Guid accountId, byte[] zipBytes, CancellationToken cancellationToken = default);
#endregion
}

View File

@ -0,0 +1,181 @@
using System.IO.Compression;
using Microsoft.Extensions.Logging;
using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Dto.Certs;
using MaksIT.CertsUI.Engine.Persistance.Services;
using MaksIT.Results;
namespace MaksIT.CertsUI.Engine.DomainServices;
/// <summary>
/// Domain-level registration cache operations (zip + row persistence). Host <see cref="MaksIT.CertsUI.Services.CacheService"/> delegates here.
/// </summary>
public sealed class RegistrationCacheDomainService(
ILogger<RegistrationCacheDomainService> logger,
IRegistrationCachePersistanceService registrationCachePersistence
) : IRegistrationCacheDomainService {
private readonly ILogger<RegistrationCacheDomainService> _logger = logger;
private readonly IRegistrationCachePersistanceService _persistence = registrationCachePersistence;
public Task<Result<RegistrationCache[]?>> LoadAllAsync(CancellationToken cancellationToken = default) =>
_persistence.LoadAllAsync(cancellationToken);
public Task<Result<RegistrationCache?>> LoadAsync(Guid accountId, CancellationToken cancellationToken = default) =>
_persistence.LoadAsync(accountId, cancellationToken);
public Task<Result> SaveAsync(Guid accountId, RegistrationCache cache, CancellationToken cancellationToken = default) =>
_persistence.SaveAsync(accountId, cache, cancellationToken);
public Task<Result> DeleteAllAsync(CancellationToken cancellationToken = default) =>
_persistence.DeleteAllAsync(cancellationToken);
public Task<Result> DeleteAsync(Guid accountId, CancellationToken cancellationToken = default) =>
_persistence.DeleteAsync(accountId, cancellationToken);
public async Task<Result<byte[]>> DownloadCacheZipAsync(CancellationToken cancellationToken = default) {
try {
var allResult = await _persistence.LoadAllAsync(cancellationToken).ConfigureAwait(false);
if (!allResult.IsSuccess || allResult.Value == null)
return Result<byte[]>.InternalServerError(null, allResult.Messages?.ToArray() ?? ["Could not load registration caches."]);
var rows = allResult.Value;
using var ms = new MemoryStream();
if (rows.Length == 0) {
using (new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { }
return Result<byte[]>.Ok(ms.ToArray());
}
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) {
foreach (var row in rows) {
var entry = zip.CreateEntry($"{row.AccountId}.json");
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream);
writer.Write(row.ToJson());
}
}
var zipBytes = ms.ToArray();
_logger.LogInformation("Exported {Count} registration caches to zip.", rows.Length);
return Result<byte[]>.Ok(zipBytes);
}
catch (Exception ex) {
var message = "Error creating registration cache zip.";
_logger.LogError(ex, message);
return Result<byte[]>.InternalServerError(null, [message, .. ex.ExtractMessages()]);
}
}
public async Task<Result<byte[]?>> DownloadAccountCacheZipAsync(Guid accountId, CancellationToken cancellationToken = default) {
try {
var readResult = await _persistence.LoadAsync(accountId, cancellationToken).ConfigureAwait(false);
if (!readResult.IsSuccess || readResult.Value == null) {
var message = $"Registration cache not found for account {accountId}.";
_logger.LogWarning(message);
return Result<byte[]?>.NotFound(null, message);
}
var row = readResult.Value;
using var ms = new MemoryStream();
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) {
var entry = zip.CreateEntry($"{accountId}.json");
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream);
writer.Write(row.ToJson());
}
var zipBytes = ms.ToArray();
_logger.LogInformation("Account registration cache zipped for {AccountId}", accountId);
return Result<byte[]?>.Ok(zipBytes);
}
catch (Exception ex) {
var message = "Error creating account registration cache zip.";
_logger.LogError(ex, message);
return Result<byte[]?>.InternalServerError(null, [message, .. ex.ExtractMessages()]);
}
}
public Task<Result> UploadCacheZipAsync(byte[] zipBytes, CancellationToken cancellationToken = default) {
try {
using var ms = new MemoryStream(zipBytes);
using var zip = new ZipArchive(ms, ZipArchiveMode.Read);
return ImportZipEntriesAsync(zip.Entries, null, cancellationToken);
}
catch (Exception ex) {
var message = "Error reading or importing registration cache zip.";
_logger.LogError(ex, message);
return Task.FromResult(Result.InternalServerError([message, .. ex.ExtractMessages()]));
}
}
public async Task<Result> UploadAccountCacheZipAsync(Guid accountId, byte[] zipBytes, CancellationToken cancellationToken = default) {
try {
using var ms = new MemoryStream(zipBytes);
using var zip = new ZipArchive(ms, ZipArchiveMode.Read);
var import = await ImportZipEntriesAsync(zip.Entries, accountId, cancellationToken).ConfigureAwait(false);
if (!import.IsSuccess)
return import;
return Result.Ok(import.Messages?.ToArray() ?? ["Imported account registration cache zip."]);
}
catch (Exception ex) {
var message = "Error reading or importing account registration cache zip.";
_logger.LogError(ex, message);
return Result.InternalServerError([message, .. ex.ExtractMessages()]);
}
}
private async Task<Result> ImportZipEntriesAsync(IReadOnlyList<ZipArchiveEntry> entries, Guid? enforcedAccountId, CancellationToken cancellationToken) {
var processedJsonEntries = 0;
foreach (var entry in entries) {
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrEmpty(entry.Name) || !entry.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
continue;
processedJsonEntries++;
var name = Path.GetFileNameWithoutExtension(entry.Name);
if (!Guid.TryParse(name, out var id))
return Result.BadRequest($"Invalid cache entry name '{entry.Name}'. Expected '<accountId>.json'.");
if (enforcedAccountId != null && id != enforcedAccountId.Value)
return Result.BadRequest($"Account upload accepts only '{enforcedAccountId}'. Found '{id}' in '{entry.Name}'.");
using var stream = entry.Open();
using var reader = new StreamReader(stream);
var json = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(json))
return Result.BadRequest($"Cache entry '{entry.Name}' is empty.");
var payload = json.ToObject<RegistrationCachePayloadDocument>();
if (payload == null) {
_logger.LogWarning("Skipping zip entry {Name}: invalid JSON.", entry.FullName);
return Result.BadRequest($"Cache entry '{entry.Name}' has invalid JSON.");
}
var cache = new RegistrationCache {
Id = payload.Id,
AccountId = payload.Id,
Description = payload.Description ?? "",
Contacts = payload.Contacts ?? [],
IsStaging = payload.IsStaging,
ChallengeType = payload.ChallengeType ?? "",
IsDisabled = payload.IsDisabled,
AccountKey = payload.AccountKey,
Key = payload.Key,
Location = payload.Location,
CachedCerts = payload.CachedCerts,
AcmeRenewalNotBeforeUtcByHostname = payload.AcmeRenewalNotBeforeUtcByHostname
};
var save = await _persistence.SaveAsync(id, cache, cancellationToken).ConfigureAwait(false);
if (!save.IsSuccess)
return save;
}
if (enforcedAccountId != null && processedJsonEntries != 1)
return Result.BadRequest($"Account upload requires exactly one JSON entry named '{enforcedAccountId}.json'. Found {processedJsonEntries}.");
var message = $"Imported registration caches from zip ({processedJsonEntries} entries).";
_logger.LogInformation(message);
return Result.Ok(message);
}
}

View File

@ -1,10 +1,19 @@
using MaksIT.Core.Abstractions.Dto;
namespace MaksIT.CertsUI.Engine.Dto.Certs;
/// <summary>
/// PostgreSQL <c>registration_caches</c> row: ACME registration payload as JSON text.
/// </summary>
public class RegistrationCacheDto {
public Guid AccountId { get; set; }
public class RegistrationCacheDto : DtoDocumentBase<Guid> {
/// <summary>
/// Backward-compatible alias for <see cref="DtoDocumentBase{Guid}.Id"/>.
/// </summary>
public Guid AccountId {
get => Id;
set => Id = value;
}
public long Version { get; set; }
public required string PayloadJson { get; set; }
}

View File

@ -0,0 +1,46 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using MaksIT.Core.Security.JWK;
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
namespace MaksIT.CertsUI.Engine.Dto.Certs;
public sealed class RegistrationCachePayloadDocument {
private Guid? _id;
public Guid Id { get => _id ?? AccountId; set => _id = value; }
public Guid AccountId { get; set; }
/// <summary>Filled from JSON key <c>Id</c> (ACME account URI).</summary>
public string? RootIdCapital { get; set; }
/// <summary>Filled from JSON key <c>id</c>.</summary>
public string? RootIdLowercase { get; set; }
/// <summary>Optional key <c>acmeAccountResourceId</c> when present.</summary>
public string? AcmeAccountResourceId { get; set; }
public string? Description { get; set; }
public string[]? Contacts { get; set; }
public bool IsStaging { get; set; }
public string? ChallengeType { get; set; }
public bool IsDisabled { get; set; }
public byte[]? AccountKey { get; set; }
public Jwk? Key { get; set; }
public Uri? Location { get; set; }
public Dictionary<string, CertificateCache>? CachedCerts { get; set; }
/// <inheritdoc cref="RegistrationCache.AcmeRenewalNotBeforeUtcByHostname" />
public Dictionary<string, DateTimeOffset>? AcmeRenewalNotBeforeUtcByHostname { get; set; }
}

View File

@ -0,0 +1,12 @@
namespace MaksIT.CertsUI.Engine.Dto.Identity;
/// <summary>
/// Placeholder row shape for API key entity scope queries (Vault parity). Not mapped to a table until scope storage exists; used for <see cref="QueryServices.Identity.IApiKeyEntityScopeQueryService"/> predicate typing.
/// </summary>
public sealed class ApiKeyEntityScopeDto {
public Guid Id { get; set; }
public Guid ApiKeyId { get; set; }
public Guid EntityId { get; set; }
public int EntityType { get; set; }
public int Scope { get; set; }
}

View File

@ -52,6 +52,8 @@ public static class ServiceCollectionExtensions {
#region Registration cache
services.AddScoped<IRegistrationCachePersistanceService, RegistrationCachePersistanceServiceLinq2Db>();
services.AddScoped<IRegistrationCacheDomainService, RegistrationCacheDomainService>();
services.AddScoped<IAcmeSessionPersistanceService, AcmeSessionPersistanceServiceLinq2Db>();
services.AddScoped<IAcmeHttpChallengePersistenceService, AcmeHttpChallengePersistenceServiceLinq2Db>();
services.AddScoped<ITermsOfServiceCachePersistenceService, TermsOfServiceCachePersistenceServiceLinq2Db>();
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>();
@ -64,7 +66,6 @@ public static class ServiceCollectionExtensions {
#endregion
#region ACME / Let's Encrypt
services.AddSingleton<IAcmeSessionStore, AcmePostgresSessionStore>();
services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
#endregion
}

View File

@ -22,7 +22,6 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
<PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup>

View File

@ -0,0 +1,70 @@
using System.Security.Cryptography;
using MaksIT.Core.Extensions;
using MaksIT.Core.Security.JWK;
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
namespace MaksIT.CertsUI.Engine.Persistance.Mappers;
/// <summary>
/// Maps ACME browser-session <see cref="State"/> to/from <c>acme_sessions.payload_json</c>.
/// Used by <see cref="MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db.AcmeSessionPersistanceServiceLinq2Db"/>.
/// </summary>
internal static class AcmeSessionPayloadMapper {
public static string ToPayloadJson(State state) {
ArgumentNullException.ThrowIfNull(state);
var snap = new AcmeSessionPayloadSnapshot {
IsStaging = state.IsStaging,
Directory = state.Directory,
CurrentOrder = state.CurrentOrder,
Challenges = [.. state.Challenges],
Cache = state.Cache,
Jwk = state.Jwk,
AccountKeyCspBlob = state.Rsa is RSACryptoServiceProvider csp ? csp.ExportCspBlob(true) : null
};
return snap.ToJson();
}
public static State FromPayloadJson(string json) {
if (string.IsNullOrWhiteSpace(json))
return new State();
var snap = json.ToObject<AcmeSessionPayloadSnapshot>();
if (snap == null)
return new State();
var state = new State {
IsStaging = snap.IsStaging,
Directory = snap.Directory,
CurrentOrder = snap.CurrentOrder,
Cache = snap.Cache,
Jwk = snap.Jwk
};
foreach (var c in snap.Challenges ?? [])
if (c != null)
state.Challenges.Add(c);
if (snap.AccountKeyCspBlob is { Length: > 0 }) {
var rsa = new RSACryptoServiceProvider();
rsa.ImportCspBlob(snap.AccountKeyCspBlob);
state.Rsa = rsa;
}
return state;
}
/// <summary>DTO shape stored in <c>payload_json</c> (not the in-memory <see cref="State"/>).</summary>
private sealed class AcmeSessionPayloadSnapshot {
public bool IsStaging { get; set; }
public AcmeDirectory? Directory { get; set; }
public Order? CurrentOrder { get; set; }
public List<AuthorizationChallengeChallenge> Challenges { get; set; } = [];
public RegistrationCache? Cache { get; set; }
public Jwk? Jwk { get; set; }
/// <summary>RSA account key CSP blob when present (same encoding as <see cref="RegistrationCache.AccountKey"/>).</summary>
public byte[]? AccountKeyCspBlob { get; set; }
}
}

View File

@ -0,0 +1,28 @@
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
using MaksIT.Results;
namespace MaksIT.CertsUI.Engine.Persistance.Services;
/// <summary>
/// PostgreSQL <c>acme_sessions</c>: load/save JSON payload for per-session ACME <see cref="State"/>.
/// </summary>
public interface IAcmeSessionPersistanceService {
#region Read
/// <summary>
/// Loads a non-expired session row. Returns <see cref="Result{T}.IsSuccess"/> with <c>null</c> when none match.
/// </summary>
Task<Result<State?>> LoadAsync(Guid sessionId, CancellationToken cancellationToken = default);
#endregion
#region Write
/// <summary>
/// Upserts session payload and refreshes <c>updated_at_utc</c> / <c>expires_at_utc</c>.
/// </summary>
Task<Result> SaveAsync(Guid sessionId, State state, CancellationToken cancellationToken = default);
#endregion
}

View File

@ -0,0 +1,89 @@
using LinqToDB;
using Microsoft.Extensions.Logging;
using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
using MaksIT.CertsUI.Engine.Dto.Certs;
using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Persistance.Mappers;
using MaksIT.Results;
namespace MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db;
/// <summary>
/// Linq2Db-based implementation of <see cref="IAcmeSessionPersistanceService"/> for PostgreSQL.
/// </summary>
public sealed class AcmeSessionPersistanceServiceLinq2Db(
ILogger<AcmeSessionPersistanceServiceLinq2Db> logger,
ICertsDataConnectionFactory connectionFactory
) : IAcmeSessionPersistanceService {
private static readonly TimeSpan SessionTtl = TimeSpan.FromHours(1);
private readonly ILogger<AcmeSessionPersistanceServiceLinq2Db> _logger = logger;
private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory;
public Task<Result<State?>> LoadAsync(Guid sessionId, CancellationToken cancellationToken = default) {
cancellationToken.ThrowIfCancellationRequested();
try {
using var db = _connectionFactory.Create();
var now = DateTimeOffset.UtcNow;
var row = db.GetTable<AcmeSessionDto>()
.Where(x => x.SessionId == sessionId && x.ExpiresAtUtc > now)
.FirstOrDefault();
if (row == null)
return Task.FromResult(Result<State?>.Ok(null));
try {
var state = AcmeSessionPayloadMapper.FromPayloadJson(row.PayloadJson);
return Task.FromResult(Result<State?>.Ok(state));
}
catch (Exception ex) {
_logger.LogWarning(ex, "Failed to deserialize ACME session {SessionId}; returning empty.", sessionId);
return Task.FromResult(Result<State?>.Ok(null));
}
}
catch (Exception ex) {
if (_logger.IsEnabled(LogLevel.Error))
_logger.LogError(ex, "Error loading ACME session {SessionId}", sessionId);
return Task.FromResult(Result<State?>.InternalServerError(null, ["An error occurred while loading the ACME session.", .. ex.ExtractMessages()]));
}
}
public Task<Result> SaveAsync(Guid sessionId, State state, CancellationToken cancellationToken = default) {
cancellationToken.ThrowIfCancellationRequested();
ArgumentNullException.ThrowIfNull(state);
try {
var json = AcmeSessionPayloadMapper.ToPayloadJson(state);
var now = DateTimeOffset.UtcNow;
var expires = now.Add(SessionTtl);
using var db = _connectionFactory.Create();
var existing = db.GetTable<AcmeSessionDto>()
.Where(x => x.SessionId == sessionId)
.FirstOrDefault();
if (existing == null) {
db.Insert(new AcmeSessionDto {
SessionId = sessionId,
PayloadJson = json,
UpdatedAtUtc = now,
ExpiresAtUtc = expires
});
}
else {
existing.PayloadJson = json;
existing.UpdatedAtUtc = now;
existing.ExpiresAtUtc = expires;
db.Update(existing);
}
return Task.FromResult(Result.Ok());
}
catch (Exception ex) {
if (_logger.IsEnabled(LogLevel.Error))
_logger.LogError(ex, "Error saving ACME session {SessionId}", sessionId);
return Task.FromResult(Result.InternalServerError(["An error occurred while saving the ACME session.", .. ex.ExtractMessages()]));
}
}
}

View File

@ -1,11 +1,9 @@
using LinqToDB;
using LinqToDB.Data;
using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Domain.Identity;
using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Persistance.Mappers;
using MaksIT.CertsUI.Engine.Persistance.Services;
using MaksIT.Results;
using Microsoft.Extensions.Logging;

View File

@ -1,12 +1,12 @@
using Microsoft.Extensions.Logging;
using LinqToDB;
using LinqToDB.Data;
using MaksIT.Results;
using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Dto.Certs;
using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Persistance.Services;
using MaksIT.Results;
using Microsoft.Extensions.Logging;
namespace MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db;
@ -16,49 +16,57 @@ namespace MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db;
public sealed class RegistrationCachePersistanceServiceLinq2Db(
ILogger<RegistrationCachePersistanceServiceLinq2Db> logger,
ICertsDataConnectionFactory connectionFactory
) : IRegistrationCachePersistanceService {
) : IRegistrationCachePersistanceService
{
private readonly ILogger<RegistrationCachePersistanceServiceLinq2Db> _logger = logger;
private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory;
public Task<Result<RegistrationCache[]?>> LoadAllAsync(CancellationToken cancellationToken = default) {
public Task<Result<RegistrationCache[]?>> LoadAllAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try {
try
{
using var db = _connectionFactory.Create();
var rows = db.GetTable<RegistrationCacheDto>().ToList();
var caches = new List<RegistrationCache>();
foreach (var row in rows) {
if (string.IsNullOrWhiteSpace(row.PayloadJson)) {
_logger.LogWarning("Registration cache row is empty for account {AccountId}", row.AccountId);
foreach (var row in rows)
{
if (string.IsNullOrWhiteSpace(row.PayloadJson))
{
_logger.LogWarning("Registration cache row is empty for account {AccountId}", row.Id);
continue;
}
var cache = row.PayloadJson.ToObject<RegistrationCache>();
if (cache == null) {
_logger.LogWarning("Could not deserialize registration cache for account {AccountId}", row.AccountId);
if (cache == null)
{
_logger.LogWarning("Could not deserialize registration cache for account {AccountId}", row.Id);
continue;
}
cache.AccountId = row.AccountId;
cache.ConcurrencyVersion = row.Version;
caches.Add(cache);
}
return Task.FromResult(Result<RegistrationCache[]?>.Ok(caches.ToArray()));
}
catch (Exception ex) {
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error))
_logger.LogError(ex, "Error loading all registration caches.");
return Task.FromResult(Result<RegistrationCache[]?>.InternalServerError(null, ["An error occurred while loading registration caches.", .. ex.ExtractMessages()]));
}
}
public Task<Result<RegistrationCache?>> LoadAsync(Guid accountId, CancellationToken cancellationToken = default) {
public Task<Result<RegistrationCache?>> LoadAsync(Guid accountId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try {
try
{
using var db = _connectionFactory.Create();
var row = db.GetTable<RegistrationCacheDto>().FirstOrDefault(r => r.AccountId == accountId);
var row = db.GetTable<RegistrationCacheDto>().FirstOrDefault(r => r.Id == accountId);
if (row == null)
return Task.FromResult(Result<RegistrationCache?>.NotFound(null, $"Registration cache not found for account {accountId}."));
@ -69,46 +77,52 @@ public sealed class RegistrationCachePersistanceServiceLinq2Db(
if (cache == null)
return Task.FromResult(Result<RegistrationCache?>.InternalServerError(null, $"Registration cache payload is invalid for account {accountId}."));
cache.AccountId = accountId;
cache.ConcurrencyVersion = row.Version;
return Task.FromResult(Result<RegistrationCache?>.Ok(cache));
}
catch (Exception ex) {
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error))
_logger.LogError(ex, "Error loading registration cache for account {AccountId}", accountId);
return Task.FromResult(Result<RegistrationCache?>.InternalServerError(null, ["An error occurred while loading the registration cache.", .. ex.ExtractMessages()]));
}
}
public Task<Result> SaveAsync(Guid accountId, RegistrationCache cache, CancellationToken cancellationToken = default) {
public Task<Result> SaveAsync(Guid accountId, RegistrationCache cache, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
ArgumentNullException.ThrowIfNull(cache);
try {
try
{
using var db = _connectionFactory.Create();
cache.AccountId = accountId;
var json = cache.ToJson();
var row = db.GetTable<RegistrationCacheDto>().FirstOrDefault(r => r.AccountId == accountId);
var row = db.GetTable<RegistrationCacheDto>().FirstOrDefault(r => r.Id == accountId);
if (row == null) {
db.Insert(new RegistrationCacheDto {
AccountId = accountId,
if (row == null)
{
db.Insert(new RegistrationCacheDto
{
Id = accountId,
Version = 1,
PayloadJson = json
});
cache.ConcurrencyVersion = 1;
}
else {
else
{
var expectedVersion = cache.ConcurrencyVersion > 0 ? cache.ConcurrencyVersion : row.Version;
var nextVersion = expectedVersion + 1;
var updated = db.GetTable<RegistrationCacheDto>()
.Where(r => r.AccountId == accountId && r.Version == expectedVersion)
.Where(r => r.Id == accountId && r.Version == expectedVersion)
.Set(r => r.PayloadJson, json)
.Set(r => r.Version, nextVersion)
.Update();
if (updated == 0) {
if (updated == 0)
{
_logger.LogWarning(
"Optimistic concurrency conflict for registration cache {AccountId}. Expected version {ExpectedVersion}.",
accountId, expectedVersion);
@ -120,33 +134,40 @@ public sealed class RegistrationCachePersistanceServiceLinq2Db(
return Task.FromResult(Result.Ok());
}
catch (Exception ex) {
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error))
_logger.LogError(ex, "Error saving registration cache for account {AccountId}", accountId);
return Task.FromResult(Result.InternalServerError(["An error occurred while saving the registration cache.", .. ex.ExtractMessages()]));
}
}
public Task<Result> DeleteAllAsync(CancellationToken cancellationToken = default) {
public Task<Result> DeleteAllAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try {
try
{
using var db = _connectionFactory.Create();
db.Execute("DELETE FROM registration_caches");
return Task.FromResult(Result.Ok());
}
catch (Exception ex) {
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error))
_logger.LogError(ex, "Error deleting all registration caches.");
return Task.FromResult(Result.InternalServerError(["An error occurred while deleting registration caches.", .. ex.ExtractMessages()]));
}
}
public Task<Result> DeleteAsync(Guid accountId, CancellationToken cancellationToken = default) {
public Task<Result> DeleteAsync(Guid accountId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
try {
try
{
using var db = _connectionFactory.Create();
var deleted = db.GetTable<RegistrationCacheDto>().Where(r => r.AccountId == accountId).Delete();
if (deleted == 0) {
var deleted = db.GetTable<RegistrationCacheDto>().Where(r => r.Id == accountId).Delete();
if (deleted == 0)
{
_logger.LogWarning("Registration cache not found for account {AccountId}", accountId);
return Task.FromResult(Result.Ok());
}
@ -154,10 +175,12 @@ public sealed class RegistrationCachePersistanceServiceLinq2Db(
_logger.LogInformation("Registration cache deleted for account {AccountId}", accountId);
return Task.FromResult(Result.Ok());
}
catch (Exception ex) {
catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error))
_logger.LogError(ex, "Error deleting registration cache for account {AccountId}", accountId);
return Task.FromResult(Result.InternalServerError(["An error occurred while deleting the registration cache.", .. ex.ExtractMessages()]));
}
}
}

View File

@ -0,0 +1,28 @@
using System.Linq.Expressions;
namespace MaksIT.CertsUI.Engine.QueryServices;
/// <summary>
/// Composes a predicate on a related entity into a predicate on the outer entity via a navigation expression.
/// Uses parameter replacement so the result is Linq2Db/IQueryable translatable to SQL (same pattern as MaksIT.Vault.Engine.QueryServices.ExpressionCompose).
/// </summary>
public static class ExpressionCompose {
/// <summary>
/// Composes inner predicate with navigation to produce a predicate on the outer type (Linq2Db/IQueryable translatable to SQL).
/// </summary>
public static Expression<Func<TOuter, bool>>? ComposeNavigationPredicate<TOuter, TInner>(
Expression<Func<TInner, bool>>? innerPredicate,
Expression<Func<TOuter, TInner>> navigation) {
if (innerPredicate == null) return null;
ArgumentNullException.ThrowIfNull(navigation);
var visitor = new ReplaceParameterWithExpressionVisitor(innerPredicate.Parameters[0], navigation.Body);
var newBody = visitor.Visit(innerPredicate.Body);
return Expression.Lambda<Func<TOuter, bool>>(newBody, navigation.Parameters[0]);
}
private sealed class ReplaceParameterWithExpressionVisitor(ParameterExpression parameter, Expression replacement) : ExpressionVisitor {
protected override Expression VisitParameter(ParameterExpression node) =>
node == parameter ? replacement : base.VisitParameter(node);
}
}

View File

@ -1,16 +1,18 @@
using MaksIT.CertsUI.Engine.Query;
using System.Linq.Expressions;
using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.Query.Identity;
using MaksIT.Results;
namespace MaksIT.CertsUI.Engine.QueryServices.Identity;
/// <summary>
/// API key ↔ entity scope search. Certs has no persisted scope graph yet; default implementation returns an empty page.
/// API key ↔ entity scope search (Vault <c>IApiKeyEntityScopeQueryService</c> pattern). Default implementation returns empty results until scope rows exist in PostgreSQL.
/// </summary>
public interface IApiKeyEntityScopeQueryService {
Task<Result<PagedQueryResult<ApiKeyEntityScopeQueryResult>>> SearchApiKeyEntityScopesAsync(
Guid? apiKeyId,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default);
Result<List<ApiKeyEntityScopeQueryResult>?> Search(
Expression<Func<ApiKeyEntityScopeDto, bool>>? predicate,
int? skip,
int? limit);
Result<int?> Count(Expression<Func<ApiKeyEntityScopeDto, bool>>? predicate);
}

View File

@ -1,17 +1,19 @@
using MaksIT.CertsUI.Engine.Query;
using System.Linq.Expressions;
using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.Query.Identity;
using MaksIT.Results;
namespace MaksIT.CertsUI.Engine.QueryServices.Identity;
/// <summary>
/// Read-only paged API key search for list views.
/// Read-only API key search (Vault <c>IApiKeyQueryService</c> pattern): optional predicate on <see cref="ApiKeyDto"/>, skip/limit, and count.
/// </summary>
public interface IApiKeyQueryService {
Task<Result<PagedQueryResult<ApiKeyQueryResult>>> SearchApiKeysAsync(
string? descriptionFilter,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default);
Result<List<ApiKeyQueryResult>?> Search(
Expression<Func<ApiKeyDto, bool>>? apiKeysPredicate,
int? skip,
int? limit);
Result<int?> Count(Expression<Func<ApiKeyDto, bool>>? apiKeysPredicate);
}

View File

@ -1,17 +1,20 @@
using MaksIT.CertsUI.Engine.Query;
using System.Linq.Expressions;
using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.Query.Identity;
using MaksIT.Results;
namespace MaksIT.CertsUI.Engine.QueryServices.Identity;
/// <summary>
/// Read-only paged user search for admin/list views.
/// Read-only user search (MaksIT.Vault.Engine.QueryServices.Identity.IIdentityQueryService pattern): optional Linq2Db-translatable predicate on <see cref="UserDto"/>, skip/limit, and a separate count.
/// Host builds optional <see cref="System.Linq.Expressions.Expression"/> predicates on <see cref="UserDto"/> for filters and RBAC; use <see cref="MaksIT.CertsUI.Engine.QueryServices.ExpressionCompose"/> to compose nested predicates (Vault parity).
/// </summary>
public interface IUserQueryService {
Task<Result<PagedQueryResult<UserQueryResult>>> SearchUsersAsync(
string? usernameFilter,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default);
Result<List<UserQueryResult>?> Search(
Expression<Func<UserDto, bool>>? usersPredicate,
int? skip,
int? limit);
Result<int?> Count(Expression<Func<UserDto, bool>>? usersPredicate);
}

View File

@ -1,4 +1,5 @@
using MaksIT.CertsUI.Engine.Query;
using System.Linq.Expressions;
using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.Query.Identity;
using MaksIT.CertsUI.Engine.QueryServices.Identity;
using MaksIT.Results;
@ -7,23 +8,27 @@ using Microsoft.Extensions.Logging;
namespace MaksIT.CertsUI.Engine.QueryServices.Linq2Db.Identity;
/// <summary>
/// Placeholder: Certs UI does not store API key entity scopes in PostgreSQL. Returns an empty page so the API contract matches Vault without failing callers.
/// Placeholder: Certs UI does not store API key entity scopes in PostgreSQL. Returns empty results so the API contract matches Vault without failing callers.
/// Replace with a Linq2Db implementation when scope rows exist.
/// </summary>
public sealed class ApiKeyEntityScopeQueryServiceStub(ILogger<ApiKeyEntityScopeQueryServiceStub> logger) : IApiKeyEntityScopeQueryService {
public Task<Result<PagedQueryResult<ApiKeyEntityScopeQueryResult>>> SearchApiKeyEntityScopesAsync(
Guid? apiKeyId,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default) {
_ = apiKeyId;
cancellationToken.ThrowIfCancellationRequested();
public Result<List<ApiKeyEntityScopeQueryResult>?> Search(
Expression<Func<ApiKeyEntityScopeDto, bool>>? predicate,
int? skip,
int? limit) {
_ = predicate;
_ = skip;
_ = limit;
if (logger.IsEnabled(LogLevel.Debug))
logger.LogDebug("Api key entity scope search is not persisted in Certs; returning empty page.");
var page = Math.Max(1, pageNumber);
var size = Math.Clamp(pageSize, 1, 500);
return Task.FromResult(Result<PagedQueryResult<ApiKeyEntityScopeQueryResult>>.Ok(
new PagedQueryResult<ApiKeyEntityScopeQueryResult>([], 0, page, size)));
logger.LogDebug("Api key entity scope search is not persisted in Certs; returning empty list.");
return Result<List<ApiKeyEntityScopeQueryResult>?>.Ok([]);
}
public Result<int?> Count(Expression<Func<ApiKeyEntityScopeDto, bool>>? predicate) {
_ = predicate;
if (logger.IsEnabled(LogLevel.Debug))
logger.LogDebug("Api key entity scope count is not persisted in Certs; returning 0.");
return Result<int?>.Ok(0);
}
}

View File

@ -1,9 +1,8 @@
using System.Linq.Expressions;
using LinqToDB;
using LinqToDB.Data;
using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Query;
using MaksIT.CertsUI.Engine.Query.Identity;
using MaksIT.CertsUI.Engine.QueryServices.Identity;
using MaksIT.Results;
@ -12,49 +11,52 @@ using Microsoft.Extensions.Logging;
namespace MaksIT.CertsUI.Engine.QueryServices.Linq2Db.Identity;
/// <summary>
/// Linq2Db-based implementation of <see cref="IApiKeyQueryService"/>.
/// Linq2Db-based implementation of <see cref="IApiKeyQueryService"/> (Vault-style predicates on <see cref="ApiKeyDto"/>).
/// </summary>
public class ApiKeyQueryServiceLinq2Db(ILogger<ApiKeyQueryServiceLinq2Db> logger, ICertsDataConnectionFactory connectionFactory) : IApiKeyQueryService {
private readonly ILogger<ApiKeyQueryServiceLinq2Db> _logger = logger;
private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory;
public Task<Result<PagedQueryResult<ApiKeyQueryResult>>> SearchApiKeysAsync(
string? descriptionFilter,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default) {
_ = cancellationToken;
public Result<List<ApiKeyQueryResult>?> Search(
Expression<Func<ApiKeyDto, bool>>? apiKeysPredicate,
int? skip,
int? limit) {
try {
var page = Math.Max(1, pageNumber);
var size = Math.Clamp(pageSize, 1, 500);
var skip = (page - 1) * size;
var filter = descriptionFilter?.Trim();
using var db = _connectionFactory.Create();
var table = db.GetTable<ApiKeyDto>();
var filtered = string.IsNullOrWhiteSpace(filter)
? table
: table.Where(k => (k.Description ?? string.Empty).Contains(filter));
var query = db.GetTable<ApiKeyDto>().AsQueryable();
if (apiKeysPredicate != null)
query = query.Where(apiKeysPredicate);
var total = filtered.Count();
var rows = filtered
.OrderByDescending(k => k.CreatedAtUtc)
.Skip(skip)
.Take(size)
.ToList();
query = query.OrderByDescending(k => k.CreatedAtUtc);
var data = rows.Select(MapToQueryResult).ToList();
if (skip.HasValue)
query = query.Skip(skip.Value);
return Task.FromResult(Result<PagedQueryResult<ApiKeyQueryResult>>.Ok(new PagedQueryResult<ApiKeyQueryResult>(
data,
total,
page,
size
)));
if (limit.HasValue)
query = query.Take(limit.Value);
var rows = query.ToList();
var results = rows.Select(MapToQueryResult).ToList();
return Result<List<ApiKeyQueryResult>?>.Ok(results);
}
catch (Exception ex) {
_logger.LogError(ex, "Error occurred while searching API keys.");
return Task.FromResult(Result<PagedQueryResult<ApiKeyQueryResult>>.InternalServerError(null, [.. ex.ExtractMessages()]));
return Result<List<ApiKeyQueryResult>?>.InternalServerError(null, [.. ex.ExtractMessages()]);
}
}
public Result<int?> Count(Expression<Func<ApiKeyDto, bool>>? apiKeysPredicate) {
try {
using var db = _connectionFactory.Create();
var query = db.GetTable<ApiKeyDto>().AsQueryable();
if (apiKeysPredicate != null)
query = query.Where(apiKeysPredicate);
return Result<int?>.Ok(query.Count());
}
catch (Exception ex) {
_logger.LogError(ex, "Error occurred while counting API keys.");
return Result<int?>.InternalServerError(null, [.. ex.ExtractMessages()]);
}
}

View File

@ -1,9 +1,8 @@
using System.Linq.Expressions;
using LinqToDB;
using LinqToDB.Data;
using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Query;
using MaksIT.CertsUI.Engine.Query.Identity;
using MaksIT.CertsUI.Engine.QueryServices.Identity;
using MaksIT.Results;
@ -12,36 +11,31 @@ using Microsoft.Extensions.Logging;
namespace MaksIT.CertsUI.Engine.QueryServices.Linq2Db.Identity;
/// <summary>
/// Linq2Db-based implementation of <see cref="IUserQueryService"/>.
/// Linq2Db-based implementation of <see cref="IUserQueryService"/> (Vault-style predicates on <see cref="UserDto"/>).
/// </summary>
public class UserQueryServiceLinq2Db(ILogger<UserQueryServiceLinq2Db> logger, ICertsDataConnectionFactory connectionFactory) : IUserQueryService {
private readonly ILogger<UserQueryServiceLinq2Db> _logger = logger;
private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory;
public Task<Result<PagedQueryResult<UserQueryResult>>> SearchUsersAsync(
string? usernameFilter,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default) {
_ = cancellationToken;
public Result<List<UserQueryResult>?> Search(
Expression<Func<UserDto, bool>>? usersPredicate,
int? skip,
int? limit) {
try {
var page = Math.Max(1, pageNumber);
var size = Math.Clamp(pageSize, 1, 500);
var skip = (page - 1) * size;
var filter = usernameFilter?.Trim();
using var db = _connectionFactory.Create();
var table = db.GetTable<UserDto>();
var filtered = string.IsNullOrWhiteSpace(filter)
? table
: table.Where(u => u.Name.Contains(filter!));
var query = db.GetTable<UserDto>().AsQueryable();
if (usersPredicate != null)
query = query.Where(usersPredicate);
var total = filtered.Count();
var rows = filtered
.OrderBy(u => u.Name)
.Skip(skip)
.Take(size)
.ToList();
query = query.OrderBy(u => u.Name);
if (skip.HasValue)
query = query.Skip(skip.Value);
if (limit.HasValue)
query = query.Take(limit.Value);
var rows = query.ToList();
var userIds = rows.Select(r => r.Id).ToList();
var allRc = userIds.Count == 0
@ -51,18 +45,27 @@ public class UserQueryServiceLinq2Db(ILogger<UserQueryServiceLinq2Db> logger, IC
.GroupBy(t => t.UserId)
.ToDictionary(g => g.Key, g => g.Count());
var data = rows.Select(r => MapToQueryResult(r, recoveryCountByUser.GetValueOrDefault(r.Id))).ToList();
return Task.FromResult(Result<PagedQueryResult<UserQueryResult>>.Ok(new PagedQueryResult<UserQueryResult>(
data,
total,
page,
size
)));
var results = rows.Select(r => MapToQueryResult(r, recoveryCountByUser.GetValueOrDefault(r.Id))).ToList();
return Result<List<UserQueryResult>?>.Ok(results);
}
catch (Exception ex) {
_logger.LogError(ex, "Error occurred while searching users.");
return Task.FromResult(Result<PagedQueryResult<UserQueryResult>>.InternalServerError(null, [.. ex.ExtractMessages()]));
return Result<List<UserQueryResult>?>.InternalServerError(null, [.. ex.ExtractMessages()]);
}
}
public Result<int?> Count(Expression<Func<UserDto, bool>>? usersPredicate) {
try {
using var db = _connectionFactory.Create();
var query = db.GetTable<UserDto>().AsQueryable();
if (usersPredicate != null)
query = query.Where(usersPredicate);
return Result<int?>.Ok(query.Count());
}
catch (Exception ex) {
_logger.LogError(ex, "Error occurred while counting users.");
return Result<int?>.InternalServerError(null, [.. ex.ExtractMessages()]);
}
}

View File

@ -1,70 +0,0 @@
using LinqToDB;
using LinqToDB.Data;
using MaksIT.CertsUI.Engine;
using MaksIT.CertsUI.Engine.Data;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
using MaksIT.CertsUI.Engine.Dto.Certs;
using MaksIT.CertsUI.Engine.Infrastructure;
using Microsoft.Extensions.Logging;
namespace MaksIT.CertsUI.Engine.Services;
/// <summary>PostgreSQL-backed ACME session state (replaces in-process <c>IMemoryCache</c>).</summary>
public sealed class AcmePostgresSessionStore(
ICertsEngineConfiguration config,
ILogger<AcmePostgresSessionStore> logger
) : IAcmeSessionStore {
private static readonly TimeSpan SessionTtl = TimeSpan.FromHours(1);
private DataConnection CreateConnection() {
var options = new DataOptions()
.UseConnectionString(ProviderName.PostgreSQL, config.ConnectionString)
.UseMappingSchema(CertsLinq2DbMapping.Schema);
return new DataConnection(options);
}
public Task<State> LoadOrCreateAsync(Guid sessionId, CancellationToken cancellationToken = default) {
cancellationToken.ThrowIfCancellationRequested();
using var db = CreateConnection();
var now = DateTimeOffset.UtcNow;
var row = db.GetTable<AcmeSessionDto>()
.Where(x => x.SessionId == sessionId && x.ExpiresAtUtc > now)
.FirstOrDefault();
if (row == null)
return Task.FromResult(new State());
try {
return Task.FromResult(AcmeSessionJsonSerializer.FromJson(row.PayloadJson));
}
catch (Exception ex) {
logger.LogWarning(ex, "Failed to deserialize ACME session {SessionId}; starting empty state.", sessionId);
return Task.FromResult(new State());
}
}
public Task PersistAsync(Guid sessionId, State state, CancellationToken cancellationToken = default) {
cancellationToken.ThrowIfCancellationRequested();
var json = AcmeSessionJsonSerializer.ToJson(state);
var now = DateTimeOffset.UtcNow;
var expires = now.Add(SessionTtl);
using var db = CreateConnection();
var existing = db.GetTable<AcmeSessionDto>()
.Where(x => x.SessionId == sessionId)
.FirstOrDefault();
if (existing == null) {
db.Insert(new AcmeSessionDto {
SessionId = sessionId,
PayloadJson = json,
UpdatedAtUtc = now,
ExpiresAtUtc = expires
});
}
else {
existing.PayloadJson = json;
existing.UpdatedAtUtc = now;
existing.ExpiresAtUtc = expires;
db.Update(existing);
}
return Task.CompletedTask;
}
}

View File

@ -1,50 +0,0 @@
using System.Security.Cryptography;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
using Newtonsoft.Json;
namespace MaksIT.CertsUI.Engine.Services;
internal static class AcmeSessionJsonSerializer {
private static readonly JsonSerializerSettings Settings = new() {
NullValueHandling = NullValueHandling.Ignore,
Formatting = Formatting.None
};
public static string ToJson(State state) {
var snap = new AcmeSessionSnapshot {
IsStaging = state.IsStaging,
Directory = state.Directory,
CurrentOrder = state.CurrentOrder,
Challenges = [.. state.Challenges],
Cache = state.Cache,
Jwk = state.Jwk,
AccountKeyCspBlob = state.Rsa is RSACryptoServiceProvider csp ? csp.ExportCspBlob(true) : null
};
return JsonConvert.SerializeObject(snap, Settings);
}
public static State FromJson(string json) {
if (string.IsNullOrWhiteSpace(json))
return new State();
var snap = JsonConvert.DeserializeObject<AcmeSessionSnapshot>(json, Settings);
if (snap == null)
return new State();
var state = new State {
IsStaging = snap.IsStaging,
Directory = snap.Directory,
CurrentOrder = snap.CurrentOrder,
Cache = snap.Cache,
Jwk = snap.Jwk
};
foreach (var c in snap.Challenges) {
if (c != null)
state.Challenges.Add(c);
}
if (snap.AccountKeyCspBlob is { Length: > 0 }) {
var rsa = new RSACryptoServiceProvider();
rsa.ImportCspBlob(snap.AccountKeyCspBlob);
state.Rsa = rsa;
}
return state;
}
}

View File

@ -1,18 +0,0 @@
using MaksIT.Core.Security.JWK;
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
using Newtonsoft.Json;
namespace MaksIT.CertsUI.Engine.Services;
/// <summary>JSON-serializable projection of <see cref="MaksIT.CertsUI.Engine.Domain.LetsEncrypt.State"/> for <c>acme_sessions.payload_json</c>.</summary>
internal sealed class AcmeSessionSnapshot {
public bool IsStaging { get; set; }
public AcmeDirectory? Directory { get; set; }
public Order? CurrentOrder { get; set; }
public List<AuthorizationChallengeChallenge> Challenges { get; set; } = [];
public RegistrationCache? Cache { get; set; }
public Jwk? Jwk { get; set; }
/// <summary>RSA account key as CSP blob when present (same encoding as <see cref="RegistrationCache.AccountKey"/>).</summary>
public byte[]? AccountKeyCspBlob { get; set; }
}

View File

@ -1,9 +0,0 @@
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
namespace MaksIT.CertsUI.Engine.Services;
/// <summary>Loads and persists per-browser ACME <see cref="State"/> so any replica can continue the flow.</summary>
public interface IAcmeSessionStore {
Task<State> LoadOrCreateAsync(Guid sessionId, CancellationToken cancellationToken = default);
Task PersistAsync(Guid sessionId, State state, CancellationToken cancellationToken = default);
}

View File

@ -1,13 +1,12 @@
using System.Net.Http.Headers;
using Microsoft.Extensions.Logging;
using MaksIT.Results;
using MaksIT.Core.Extensions;
using MaksIT.Core.Security.JWS;
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt.Jws;
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Interfaces;
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
using MaksIT.Results;
namespace MaksIT.CertsUI.Engine.Services;
@ -16,16 +15,39 @@ public partial class LetsEncryptService {
#region Internal helpers
private async Task<State> LoadAcmeSessionStateAsync(Guid sessionId, CancellationToken cancellationToken) {
var result = await _acmeSessionPersistence.LoadAsync(sessionId, cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess) {
_logger.LogWarning(
"ACME session load failed for {SessionId}: {Messages}",
sessionId,
result.Messages != null ? string.Join("; ", result.Messages) : "(no detail)");
return new State();
}
return result.Value ?? new State();
}
private async Task PersistAcmeSessionStateAsync(Guid sessionId, State state, CancellationToken cancellationToken) {
var result = await _acmeSessionPersistence.SaveAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
if (!result.IsSuccess) {
_logger.LogError(
"ACME session save failed for {SessionId}: {Messages}",
sessionId,
result.Messages != null ? string.Join("; ", result.Messages) : "(no detail)");
}
}
private async Task<Result> WithPersistedSessionAsync(
Guid sessionId,
CancellationToken cancellationToken,
Func<State, Task<Result>> body) {
var state = await _sessionStore.LoadOrCreateAsync(sessionId, cancellationToken).ConfigureAwait(false);
var state = await LoadAcmeSessionStateAsync(sessionId, cancellationToken).ConfigureAwait(false);
try {
return await body(state).ConfigureAwait(false);
}
finally {
await _sessionStore.PersistAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
await PersistAcmeSessionStateAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
}
}
@ -33,12 +55,12 @@ public partial class LetsEncryptService {
Guid sessionId,
CancellationToken cancellationToken,
Func<State, Task<Result<T?>>> body) {
var state = await _sessionStore.LoadOrCreateAsync(sessionId, cancellationToken).ConfigureAwait(false);
var state = await LoadAcmeSessionStateAsync(sessionId, cancellationToken).ConfigureAwait(false);
try {
return await body(state).ConfigureAwait(false);
}
finally {
await _sessionStore.PersistAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
await PersistAcmeSessionStateAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
}
}

View File

@ -3,6 +3,12 @@
* https://datatracker.ietf.org/doc/html/draft-ietf-acme-acme-12
*/
using System.Text;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using Microsoft.Extensions.Logging;
using MaksIT.Results;
using MaksIT.Core.Extensions;
using MaksIT.Core.Security;
using MaksIT.Core.Security.JWK;
@ -10,16 +16,9 @@ using MaksIT.Core.Security.JWS;
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt.Jws;
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Interfaces;
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Requests;
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
using MaksIT.Results;
using Microsoft.Extensions.Logging;
using System.Net.Http.Headers;
using System.Threading;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using MaksIT.CertsUI.Engine.Persistance.Services;
namespace MaksIT.CertsUI.Engine.Services;
@ -45,18 +44,18 @@ public partial class LetsEncryptService : ILetsEncryptService {
private readonly ILogger<LetsEncryptService> _logger;
private readonly ICertsEngineConfiguration _engineConfiguration;
private readonly HttpClient _httpClient;
private readonly IAcmeSessionStore _sessionStore;
private readonly IAcmeSessionPersistanceService _acmeSessionPersistence;
public LetsEncryptService(
ILogger<LetsEncryptService> logger,
ICertsEngineConfiguration engineConfiguration,
HttpClient httpClient,
IAcmeSessionStore sessionStore
IAcmeSessionPersistanceService acmeSessionPersistence
) {
_logger = logger;
_engineConfiguration = engineConfiguration;
_httpClient = httpClient;
_sessionStore = sessionStore;
_acmeSessionPersistence = acmeSessionPersistence;
}
public Task<Result<RegistrationCache?>> GetRegistrationCacheAsync(Guid sessionId, CancellationToken cancellationToken = default) =>

View File

@ -1,7 +1,8 @@
using System.Linq;
using System.Linq.Expressions;
using MaksIT.CertsUI.Engine.Domain.Identity;
using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.Persistance.Services;
using MaksIT.CertsUI.Engine.Query;
using MaksIT.CertsUI.Engine.Query.Identity;
using MaksIT.CertsUI.Engine.QueryServices.Identity;
using MaksIT.Results;
@ -25,36 +26,60 @@ public sealed class InMemoryUserStore : IIdentityPersistanceService, IUserQueryS
return Task.FromResult(Result<List<User>>.Ok([.. _users]));
}
public Task<Result<PagedQueryResult<UserQueryResult>>> SearchUsersAsync(
string? usernameFilter,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default) {
public Result<int?> Count(Expression<Func<UserDto, bool>>? usersPredicate) {
lock (_lock) {
IEnumerable<User> filtered = _users;
if (!string.IsNullOrWhiteSpace(usernameFilter))
filtered = filtered.Where(u => u.Username.Contains(usernameFilter, StringComparison.OrdinalIgnoreCase));
var ordered = filtered.OrderBy(u => u.Username).ToList();
var page = Math.Max(1, pageNumber);
var size = Math.Clamp(pageSize, 1, 500);
var total = ordered.Count;
var slice = ordered.Skip((page - 1) * size).Take(size).ToList();
var data = slice.Select(u => new UserQueryResult {
Id = u.Id,
Username = u.Username,
IsActive = u.IsActive,
TwoFactorEnabled = u.TwoFactorEnabled,
LastLogin = u.LastLogin,
}).ToList();
return Task.FromResult(Result<PagedQueryResult<UserQueryResult>>.Ok(new PagedQueryResult<UserQueryResult>(
data,
total,
page,
size
)));
var q = _users.Select(ToUserDto).AsQueryable();
if (usersPredicate != null)
q = q.Where(usersPredicate);
return Result<int?>.Ok(q.Count());
}
}
public Result<List<UserQueryResult>?> Search(
Expression<Func<UserDto, bool>>? usersPredicate,
int? skip,
int? limit) {
lock (_lock) {
var q = _users.Select(ToUserDto).AsQueryable();
if (usersPredicate != null)
q = q.Where(usersPredicate);
q = q.OrderBy(x => x.Name);
if (skip.HasValue)
q = q.Skip(skip.Value);
if (limit.HasValue)
q = q.Take(limit.Value);
var dtos = q.ToList();
var results = dtos.Select(d => MapToQueryResult(d, d.TwoFactorRecoveryCodes.Count)).ToList();
return Result<List<UserQueryResult>?>.Ok(results);
}
}
private static UserDto ToUserDto(User u) => new() {
Id = u.Id,
Name = u.Username,
Salt = u.PasswordSalt,
Hash = u.PasswordHash,
LastLoginUtc = u.LastLogin ?? default,
IsActive = u.IsActive,
TwoFactorSharedKey = u.TwoFactorSharedKey,
JwtTokens = [],
TwoFactorRecoveryCodes = [.. u.TwoFactorRecoveryCodes.Select(rc => new TwoFactorRecoveryCodeDto {
Id = rc.Id,
UserId = u.Id,
Salt = rc.Salt,
Hash = rc.Hash,
IsUsed = rc.IsUsed
})]
};
private static UserQueryResult MapToQueryResult(UserDto row, int recoveryCount) => new() {
Id = row.Id,
Username = row.Name,
IsActive = row.IsActive,
TwoFactorEnabled = row.TwoFactorSharedKey != null && recoveryCount > 0,
LastLogin = row.LastLoginUtc == default ? null : row.LastLoginUtc,
};
public Task<Result<User?>> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) {
lock (_lock) {
var u = _users.FirstOrDefault(x => x.Id == id);

View File

@ -5,6 +5,7 @@ using MaksIT.Results;
using MaksIT.CertsUI.Mappers;
using MaksIT.CertsUI.Services;
using LinqToDB.Data;
using MaksIT.CertsUI.Engine.DomainServices;
using MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db;
using MaksIT.CertsUI.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
@ -35,7 +36,8 @@ public class AccountServicePatchAccountIntegrationTests(PostgresCacheFixture pg)
IsDisabled = false
};
var cachePersistence = new RegistrationCachePersistanceServiceLinq2Db(NullLogger<RegistrationCachePersistanceServiceLinq2Db>.Instance, pg.ConnectionFactory);
var cacheSvc = new CacheService(NullLogger<CacheService>.Instance, fx.AppOptions, cachePersistence);
var cacheDomain = new RegistrationCacheDomainService(NullLogger<RegistrationCacheDomainService>.Instance, cachePersistence);
var cacheSvc = new CacheService(NullLogger<CacheService>.Instance, fx.AppOptions, cacheDomain);
await cacheSvc.SaveToCacheAsync(accountId, reg);
var cacheMock = new Mock<ICacheService>();

View File

@ -45,10 +45,10 @@ public class ApiKeyQueryServiceIntegrationTests(PostgresCacheFixture pg) {
Assert.False(string.IsNullOrWhiteSpace(created.Value!.ApiKey));
Assert.Contains('|', created.Value.ApiKey);
var search = await queryService.SearchApiKeysAsync(null, 1, 50);
var search = queryService.Search(apiKeysPredicate: null, skip: 0, limit: 50);
Assert.True(search.IsSuccess);
Assert.NotNull(search.Value);
Assert.Contains(search.Value!.Data, x => x.Id == created.Value!.Id);
Assert.Contains(search.Value!, x => x.Id == created.Value!.Id);
}
}

View File

@ -2,6 +2,7 @@ using LinqToDB;
using LinqToDB.Data;
using MaksIT.CertsUI.Engine.Dto.Certs;
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.DomainServices;
using MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db;
using MaksIT.CertsUI.Services;
using MaksIT.CertsUI.Tests.Infrastructure;
@ -13,12 +14,11 @@ namespace MaksIT.CertsUI.Tests.Services;
[Collection("postgres-cache")]
public class CacheServiceTests(PostgresCacheFixture pg) {
private CacheService CreateSut() =>
new(
NullLogger<CacheService>.Instance,
pg.Config.AppOptions,
new RegistrationCachePersistanceServiceLinq2Db(NullLogger<RegistrationCachePersistanceServiceLinq2Db>.Instance, pg.ConnectionFactory)
);
private CacheService CreateSut() {
var persistence = new RegistrationCachePersistanceServiceLinq2Db(NullLogger<RegistrationCachePersistanceServiceLinq2Db>.Instance, pg.ConnectionFactory);
var domain = new RegistrationCacheDomainService(NullLogger<RegistrationCacheDomainService>.Instance, persistence);
return new CacheService(NullLogger<CacheService>.Instance, pg.Config.AppOptions, domain);
}
[Fact]
public async Task LoadAccountsFromCacheAsync_WhenNoRows_ReturnsEmptyArray() {
@ -77,8 +77,8 @@ public class CacheServiceTests(PostgresCacheFixture pg) {
public async Task LoadAccountFromCacheAsync_WhenPayloadEmpty_ReturnsError() {
await ClearCachesAsync();
var id = Guid.NewGuid();
using (var db = (DataConnection)pg.ConnectionFactory.Create()) {
db.Insert(new RegistrationCacheDto { AccountId = id, PayloadJson = "" });
using (var db = pg.ConnectionFactory.Create()) {
db.Insert(new RegistrationCacheDto { Id = id, AccountId = id, PayloadJson = "" });
}
var sut = CreateSut();

View File

@ -1,6 +1,7 @@
using System.Net;
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.DomainServices;
using Microsoft.Extensions.Logging.Abstractions;
using MaksIT.CertsUI.Engine.Dto.Certs;
using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Persistance.Services;
@ -8,7 +9,6 @@ using MaksIT.CertsUI.Engine.RuntimeCoordination;
using MaksIT.CertsUI.Engine.Services;
using MaksIT.Results;
using MaksIT.CertsUI.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
@ -32,6 +32,9 @@ public sealed class CertsFlowServiceTests
HttpMessageHandler? httpHandler = null)
{
registrationCache ??= new Mock<IRegistrationCachePersistanceService>();
var registrationDomain = new RegistrationCacheDomainService(
NullLogger<RegistrationCacheDomainService>.Instance,
registrationCache.Object);
agent ??= new Mock<IAgentDeploymentService>();
var tosCacheProvided = termsOfServiceCache is not null;
termsOfServiceCache ??= new Mock<ITermsOfServiceCachePersistenceService>();
@ -69,7 +72,7 @@ public sealed class CertsFlowServiceTests
NullLogger<CertsFlowDomainService>.Instance,
httpClient,
le.Object,
registrationCache.Object,
registrationDomain,
agent.Object,
new TestCertsFlowEngineConfiguration(fx),
termsOfServiceCache.Object,

View File

@ -1,6 +1,6 @@
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.DomainServices;
using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Persistance.Services;
using MaksIT.CertsUI.Engine.RuntimeCoordination;
using MaksIT.CertsUI.Services;
@ -38,15 +38,15 @@ public sealed class AutoRenewal(
logger.LogInformation("Running certificate renewal sweep (lease holder {Holder}).", holder);
using var scope = scopeFactory.CreateScope();
var cacheService = scope.ServiceProvider.GetRequiredService<ICacheService>();
var registrationCacheDomain = scope.ServiceProvider.GetRequiredService<IRegistrationCacheDomainService>();
var certsFlowDomain = scope.ServiceProvider.GetRequiredService<ICertsFlowDomainService>();
var certsFlowService = scope.ServiceProvider.GetRequiredService<ICertsFlowService>();
var httpChallenges = scope.ServiceProvider.GetRequiredService<IAcmeHttpChallengePersistenceService>();
var purge = await httpChallenges.DeleteOlderThanAsync(TimeSpan.FromDays(10), stoppingToken).ConfigureAwait(false);
var purge = await certsFlowDomain.PurgeStaleHttpChallengesAsync(TimeSpan.FromDays(10), stoppingToken).ConfigureAwait(false);
if (purge.IsSuccess && purge.Value > 0)
logger.LogInformation("Purged {Count} HTTP-01 challenge row(s) older than 10 days.", purge.Value);
var loadAccountsFromCacheResult = await cacheService.LoadAccountsFromCacheAsync().ConfigureAwait(false);
var loadAccountsFromCacheResult = await registrationCacheDomain.LoadAllAsync(stoppingToken).ConfigureAwait(false);
if (!loadAccountsFromCacheResult.IsSuccess || loadAccountsFromCacheResult.Value == null) {
LogErrorMessages(loadAccountsFromCacheResult.Messages);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken).ConfigureAwait(false);

View File

@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<Version>3.4.0</Version>
<Version>3.4.1</Version>
<TargetFramework>net10.0</TargetFramework>
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>

View File

@ -98,7 +98,7 @@ public class AccountService(
if (requestData.Description == null)
return PatchFieldIsNotDefined<GetAccountResponse?>(nameof(requestData.Description));
cache.Description = requestData.Description;
cache.Description = requestData.Description;
break;
default:
return UnsupportedPatchOperationResponse<GetAccountResponse?>();

View File

@ -1,4 +1,6 @@
using System.Linq.Expressions;
using MaksIT.CertsUI.Engine.DomainServices;
using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.QueryServices.Identity;
using MaksIT.Models.LetsEncryptServer.ApiKeys;
using MaksIT.Models.LetsEncryptServer.ApiKeys.Search;
@ -75,16 +77,27 @@ public sealed class ApiKeyService(
_ = _jwtTokenData;
var page = Math.Max(1, requestData.PageNumber);
var size = Math.Clamp(requestData.PageSize, 1, 500);
var query = await apiKeyQueryService.SearchApiKeysAsync(requestData.DescriptionFilter?.Trim(), page, size, cancellationToken);
if (!query.IsSuccess || query.Value == null)
return query.ToResultOfType<PagedResponse<SearchAPIKeyResponse>?>(_ => null);
var filter = requestData.DescriptionFilter?.Trim();
Expression<Func<ApiKeyDto, bool>>? predicate = string.IsNullOrWhiteSpace(filter)
? null
: k => (k.Description ?? string.Empty).Contains(filter!);
var paged = query.Value;
var skip = (page - 1) * size;
var countResult = apiKeyQueryService.Count(predicate);
if (!countResult.IsSuccess)
return countResult.ToResultOfType<PagedResponse<SearchAPIKeyResponse>?>(_ => null);
var searchResult = apiKeyQueryService.Search(predicate, skip, size);
if (!searchResult.IsSuccess || searchResult.Value == null)
return searchResult.ToResultOfType<PagedResponse<SearchAPIKeyResponse>?>(_ => null);
var total = countResult.Value ?? 0;
var list = searchResult.Value;
return Result<PagedResponse<SearchAPIKeyResponse>?>.Ok(new PagedResponse<SearchAPIKeyResponse> {
Data = [.. paged.Data.Select(apiKeyToResponseMapper.MapToSearchResponse)],
TotalRecords = paged.TotalRecords,
PageNumber = paged.PageNumber,
PageSize = paged.PageSize,
Data = [.. list.Select(apiKeyToResponseMapper.MapToSearchResponse)],
TotalRecords = total,
PageNumber = page,
PageSize = size,
});
}
@ -96,16 +109,26 @@ public sealed class ApiKeyService(
_ = _jwtTokenData;
var page = Math.Max(1, requestData.PageNumber);
var size = Math.Clamp(requestData.PageSize, 1, 500);
var query = await apiKeyEntityScopeQueryService.SearchApiKeyEntityScopesAsync(requestData.ApiKeyId, page, size, cancellationToken);
if (!query.IsSuccess || query.Value == null)
return query.ToResultOfType<PagedResponse<SearchApiKeyEntityScopeResponse>?>(_ => null);
Expression<Func<ApiKeyEntityScopeDto, bool>>? predicate = requestData.ApiKeyId.HasValue
? s => s.ApiKeyId == requestData.ApiKeyId!.Value
: null;
var paged = query.Value;
var skip = (page - 1) * size;
var countResult = apiKeyEntityScopeQueryService.Count(predicate);
if (!countResult.IsSuccess)
return countResult.ToResultOfType<PagedResponse<SearchApiKeyEntityScopeResponse>?>(_ => null);
var searchResult = apiKeyEntityScopeQueryService.Search(predicate, skip, size);
if (!searchResult.IsSuccess || searchResult.Value == null)
return searchResult.ToResultOfType<PagedResponse<SearchApiKeyEntityScopeResponse>?>(_ => null);
var total = countResult.Value ?? 0;
var list = searchResult.Value;
return Result<PagedResponse<SearchApiKeyEntityScopeResponse>?>.Ok(new PagedResponse<SearchApiKeyEntityScopeResponse> {
Data = [.. paged.Data.Select(apiKeyToResponseMapper.MapToSearchResponse)],
TotalRecords = paged.TotalRecords,
PageNumber = paged.PageNumber,
PageSize = paged.PageSize,
Data = [.. list.Select(apiKeyToResponseMapper.MapToSearchResponse)],
TotalRecords = total,
PageNumber = page,
PageSize = size,
});
}

View File

@ -1,8 +1,7 @@
using System.IO.Compression;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Persistance.Services;
using MaksIT.CertsUI.Engine.DomainServices;
using MaksIT.Results;
using MaksIT.CertsUI.Abstractions.Services;
@ -20,136 +19,40 @@ public interface ICacheService {
Task<Result> DeleteAccountCacheAsync(Guid accountId);
}
/// <summary>Web API façade for registration cache operations; delegates to <see cref="IRegistrationCacheDomainService"/>.</summary>
public class CacheService(
ILogger<CacheService> logger,
IOptions<Configuration> appSettings,
IRegistrationCachePersistanceService registrationCachePersistence
IRegistrationCacheDomainService registrationCacheDomain
) : ServiceBase(
logger,
appSettings
), ICacheService {
public Task<Result<RegistrationCache[]?>> LoadAccountsFromCacheAsync() {
return registrationCachePersistence.LoadAllAsync();
}
public Task<Result<RegistrationCache[]?>> LoadAccountsFromCacheAsync() =>
registrationCacheDomain.LoadAllAsync();
public Task<Result<RegistrationCache?>> LoadAccountFromCacheAsync(Guid accountId) =>
registrationCachePersistence.LoadAsync(accountId);
registrationCacheDomain.LoadAsync(accountId);
public Task<Result> SaveToCacheAsync(Guid accountId, RegistrationCache cache) =>
registrationCachePersistence.SaveAsync(accountId, cache);
registrationCacheDomain.SaveAsync(accountId, cache);
public async Task<Result<byte[]>> DownloadCacheZipAsync() {
try {
var allResult = await registrationCachePersistence.LoadAllAsync();
if (!allResult.IsSuccess || allResult.Value == null)
return Result<byte[]>.InternalServerError(null, allResult.Messages?.ToArray() ?? ["Could not load registration caches."]);
public Task<Result<byte[]>> DownloadCacheZipAsync() =>
registrationCacheDomain.DownloadCacheZipAsync();
var rows = allResult.Value;
using var ms = new MemoryStream();
if (rows.Length == 0) {
using (new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { }
return Result<byte[]>.Ok(ms.ToArray());
}
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) {
foreach (var row in rows) {
var entry = zip.CreateEntry($"{row.AccountId}.json");
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream);
writer.Write(row.ToJson());
}
}
var zipBytes = ms.ToArray();
logger.LogInformation("Exported {Count} registration caches to zip.", rows.Length);
return Result<byte[]>.Ok(zipBytes);
}
catch (Exception ex) {
var message = "Error creating registration cache zip.";
logger.LogError(ex, message);
return Result<byte[]>.InternalServerError(null, [message, .. ex.ExtractMessages()]);
}
}
public Task<Result<byte[]?>> DownloadAccountCacheZipAsync(Guid accountId) =>
registrationCacheDomain.DownloadAccountCacheZipAsync(accountId);
public async Task<Result<byte[]?>> DownloadAccountCacheZipAsync(Guid accountId) {
try {
var readResult = await registrationCachePersistence.LoadAsync(accountId);
if (!readResult.IsSuccess || readResult.Value == null) {
var message = $"Registration cache not found for account {accountId}.";
logger.LogWarning(message);
return Result<byte[]?>.NotFound(null, message);
}
var row = readResult.Value;
using var ms = new MemoryStream();
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) {
var entry = zip.CreateEntry($"{accountId}.json");
using var entryStream = entry.Open();
using var writer = new StreamWriter(entryStream);
writer.Write(row.ToJson());
}
var zipBytes = ms.ToArray();
logger.LogInformation("Account registration cache zipped for {AccountId}", accountId);
return Result<byte[]?>.Ok(zipBytes);
}
catch (Exception ex) {
var message = "Error creating account registration cache zip.";
logger.LogError(ex, message);
return Result<byte[]?>.InternalServerError(null, [message, .. ex.ExtractMessages()]);
}
}
public Task<Result> UploadCacheZipAsync(byte[] zipBytes) =>
registrationCacheDomain.UploadCacheZipAsync(zipBytes);
public Task<Result> UploadCacheZipAsync(byte[] zipBytes) {
try {
using var ms = new MemoryStream(zipBytes);
using var zip = new ZipArchive(ms, ZipArchiveMode.Read);
return ImportZipEntriesAsync(zip.Entries);
}
catch (Exception ex) {
var message = "Error reading or importing registration cache zip.";
logger.LogError(ex, message);
return Task.FromResult(Result.InternalServerError([message, .. ex.ExtractMessages()]));
}
}
public Task<Result> UploadAccountCacheZipAsync(Guid accountId, byte[] zipBytes) =>
registrationCacheDomain.UploadAccountCacheZipAsync(accountId, zipBytes);
public Task<Result> UploadAccountCacheZipAsync(Guid accountId, byte[] zipBytes) {
try {
using var ms = new MemoryStream(zipBytes);
using var zip = new ZipArchive(ms, ZipArchiveMode.Read);
return ImportZipEntriesAsync(zip.Entries);
}
catch (Exception ex) {
var message = "Error reading or importing account registration cache zip.";
logger.LogError(ex, message);
return Task.FromResult(Result.InternalServerError([message, .. ex.ExtractMessages()]));
}
}
public Task<Result> DeleteCacheAsync() =>
registrationCacheDomain.DeleteAllAsync();
private async Task<Result> ImportZipEntriesAsync(IReadOnlyList<ZipArchiveEntry> entries) {
foreach (var entry in entries) {
if (string.IsNullOrEmpty(entry.Name) || !entry.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
continue;
var name = Path.GetFileNameWithoutExtension(entry.Name);
if (!Guid.TryParse(name, out var id))
continue;
using var stream = entry.Open();
using var reader = new StreamReader(stream);
var json = reader.ReadToEnd();
if (string.IsNullOrWhiteSpace(json))
continue;
var cache = json.ToObject<RegistrationCache>();
if (cache == null) {
logger.LogWarning("Skipping zip entry {Name}: invalid JSON.", entry.FullName);
continue;
}
cache.AccountId = id;
var save = await registrationCachePersistence.SaveAsync(id, cache);
if (!save.IsSuccess)
return save;
}
logger.LogInformation("Imported registration caches from zip ({EntryCount} entries).", entries.Count);
return Result.Ok();
}
public Task<Result> DeleteCacheAsync() => registrationCachePersistence.DeleteAllAsync();
public Task<Result> DeleteAccountCacheAsync(Guid accountId) => registrationCachePersistence.DeleteAsync(accountId);
public Task<Result> DeleteAccountCacheAsync(Guid accountId) =>
registrationCacheDomain.DeleteAsync(accountId);
}

View File

@ -1,7 +1,9 @@
using System.Linq.Expressions;
using Microsoft.Extensions.Options;
using MaksIT.Core.Security;
using MaksIT.Core.Webapi.Models;
using MaksIT.CertsUI.Engine.DomainServices;
using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.QueryServices.Identity;
using MaksIT.Models.LetsEncryptServer.Identity.Login;
using MaksIT.Models.LetsEncryptServer.Identity.Logout;
@ -16,7 +18,7 @@ using DomainUser = MaksIT.CertsUI.Engine.Domain.Identity.User;
namespace MaksIT.CertsUI.Services;
public interface IIdentityService {
Task<Result<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>>> SearchUsersAsync(JwtTokenData jwtTokenData, SearchUserRequest requestData);
Task<Result<Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>?>> SearchUsersAsync(JwtTokenData jwtTokenData, SearchUserRequest requestData);
Task<Result<UserResponse?>> ReadUserAsync(JwtTokenData jwtTokenData, Guid id);
Task<Result<UserResponse?>> PostUserAsync(JwtTokenData jwtTokenData, CreateUserRequest requestData);
Task<Result<UserResponse?>> PatchUserAsync(JwtTokenData jwtTokenData, Guid id, PatchUserRequest requestData);
@ -37,27 +39,42 @@ public sealed class IdentityService(
private readonly ITwoFactorSettingsConfiguration _twoFactorSettings = twoFactorSettings;
public async Task<Result<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>>> SearchUsersAsync(JwtTokenData _jwtTokenData, SearchUserRequest requestData) {
public Task<Result<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>?>> SearchUsersAsync(JwtTokenData _jwtTokenData, SearchUserRequest requestData) {
_ = _jwtTokenData;
var page = Math.Max(1, requestData.PageNumber);
var size = Math.Clamp(requestData.PageSize, 1, 500);
var filter = requestData.UsernameFilter?.Trim();
Expression<Func<UserDto, bool>>? predicate = string.IsNullOrWhiteSpace(filter)
? null
: u => u.Name.Contains(filter!);
var query = await userQueryService.SearchUsersAsync(requestData.UsernameFilter?.Trim(), page, size);
if (!query.IsSuccess || query.Value == null)
return query.ToResultOfType<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>>(_ => new MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse> {
var skip = (page - 1) * size;
var countResult = userQueryService.Count(predicate);
if (!countResult.IsSuccess)
return Task.FromResult(countResult.ToResultOfType<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>?>(_ => new MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse> {
Data = [],
TotalRecords = 0,
PageNumber = page,
PageSize = size,
})!;
})!);
var paged = query.Value;
return Result<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>>.Ok(new MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse> {
Data = [.. paged.Data.Select(userToResponseMapper.MapToSearchResponse)],
TotalRecords = paged.TotalRecords,
PageNumber = paged.PageNumber,
PageSize = paged.PageSize,
});
var searchResult = userQueryService.Search(predicate, skip, size);
if (!searchResult.IsSuccess || searchResult.Value == null)
return Task.FromResult(searchResult.ToResultOfType<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>?>(_ => new MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse> {
Data = [],
TotalRecords = 0,
PageNumber = page,
PageSize = size,
})!);
var total = countResult.Value ?? 0;
var list = searchResult.Value;
return Task.FromResult(Result<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>?>.Ok(new MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse> {
Data = [.. list.Select(userToResponseMapper.MapToSearchResponse)],
TotalRecords = total,
PageNumber = page,
PageSize = size,
}));
}
public async Task<Result<UserResponse?>> ReadUserAsync(JwtTokenData _jwtTokenData, Guid id) {

View File

@ -13,6 +13,12 @@ interface RequestOptions {
skipLoader?: boolean
}
interface ApiResponse<T> {
payload: T | undefined
status: number | undefined
ok: boolean
}
// Create an Axios instance
const axiosInstance = axios.create({
timeout: 10000, // Set a timeout if needed
@ -166,7 +172,7 @@ const getData = async <TResponse>(
url: string,
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
): Promise<ApiResponse<TResponse>> => {
try {
const config: any = {
headers: {
@ -180,13 +186,13 @@ const getData = async <TResponse>(
}
const response = await axiosInstance.get<TResponse>(url, config)
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
return { payload: response.data, status: response.status, ok: true }
} catch (error: any) {
return { payload: undefined, status: error?.response?.status, ok: false }
}
}
/**
* Performs a POST request with the given data and returns the response data.
* @param url The endpoint URL.
@ -199,7 +205,7 @@ const postData = async <TRequest, TResponse>(
data?: TRequest,
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
): Promise<ApiResponse<TResponse>> => {
try {
const config: any = {
headers: {
@ -213,14 +219,13 @@ const postData = async <TRequest, TResponse>(
}
const response = await axiosInstance.post<TResponse>(url, data, config)
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
return { payload: response.data, status: response.status, ok: true }
} catch (error: any) {
return { payload: undefined, status: error?.response?.status, ok: false }
}
}
/**
* Performs a PATCH request with the given data and returns the response data.
* @param url The endpoint URL.
@ -233,7 +238,7 @@ const patchData = async <TRequest, TResponse>(
data: TRequest,
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
): Promise<ApiResponse<TResponse>> => {
try {
const config: any = {
headers: {
@ -247,12 +252,12 @@ const patchData = async <TRequest, TResponse>(
}
const response = await axiosInstance.patch<TResponse>(url, data, config)
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
return { payload: response.data, status: response.status, ok: true }
} catch (error: any) {
return { payload: undefined, status: error?.response?.status, ok: false }
}
}
/**
* Performs a PUT request with the given data and returns the response data.
@ -266,7 +271,7 @@ const putData = async <TRequest, TResponse>(
data: TRequest,
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
): Promise<ApiResponse<TResponse>> => {
try {
const config: any = {
headers: {
@ -280,12 +285,12 @@ const putData = async <TRequest, TResponse>(
}
const response = await axiosInstance.put<TResponse>(url, data, config)
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
return { payload: response.data, status: response.status, ok: true }
} catch (error: any) {
return { payload: undefined, status: error?.response?.status, ok: false }
}
}
/**
* Performs a DELETE request and returns the response data.
@ -297,7 +302,7 @@ const deleteData = async <TResponse>(
url: string,
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
): Promise<ApiResponse<TResponse>> => {
try {
const config: any = {
headers: {
@ -311,13 +316,13 @@ const deleteData = async <TResponse>(
}
const response = await axiosInstance.delete<TResponse>(url, config)
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
return { payload: response.data, status: response.status, ok: true }
} catch (error: any) {
return { payload: undefined, status: error?.response?.status, ok: false }
}
}
/**
* Performs a POST request with binary payload (e.g., file upload) and returns the response data.
* @param url The endpoint URL.
@ -330,7 +335,7 @@ const postBinary = async <TResponse>(
data: Blob | ArrayBuffer | Uint8Array,
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
): Promise<ApiResponse<TResponse>> => {
try {
const config: any = {
headers: {
@ -344,13 +349,13 @@ const postBinary = async <TResponse>(
}
const response = await axiosInstance.post<TResponse>(url, data, config)
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
return { payload: response.data, status: response.status, ok: true }
} catch (error: any) {
return { payload: undefined, status: error?.response?.status, ok: false }
}
}
/**
* Performs a GET request to retrieve binary data (e.g., file download).
* @param url The endpoint URL.
@ -363,7 +368,7 @@ const getBinary = async (
timeout?: number,
as: 'arraybuffer' | 'blob' = 'arraybuffer',
options?: RequestOptions
): Promise<{ data: ArrayBuffer | Blob, headers: Record<string, string> } | undefined> => {
): Promise<ApiResponse<{ data: ArrayBuffer | Blob, headers: Record<string, string> }>> => {
try {
const config: any = {
responseType: as,
@ -377,15 +382,19 @@ const getBinary = async (
const response = await axiosInstance.get(url, config)
return {
data: response.data,
headers: response.headers as Record<string, string>
payload: {
data: response.data,
headers: response.headers as Record<string, string>
},
status: response.status,
ok: true
}
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
} catch (error: any) {
return { payload: undefined, status: error?.response?.status, ok: false }
}
}
/**
* Performs a POST request using multipart/form-data.
* Accepts either a ready FormData or a record of fields to be converted into FormData.
@ -401,7 +410,7 @@ const postFormData = async <TResponse>(
form: FormData | Record<string, string | Blob | File | (string | Blob | File)[]>,
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
): Promise<ApiResponse<TResponse>> => {
try {
const formData =
form instanceof FormData
@ -428,14 +437,13 @@ const postFormData = async <TResponse>(
}
const response = await axiosInstance.post<TResponse>(url, formData, config)
return response.data
} catch {
// Error is already handled by interceptors, so just return undefined
return undefined
return { payload: response.data, status: response.status, ok: true }
} catch (error: any) {
return { payload: undefined, status: error?.response?.status, ok: false }
}
}
/**
* Convenience helper for uploading a single file via multipart/form-data.
* @param url The endpoint URL.
@ -454,7 +462,7 @@ const postFile = async <TResponse>(
extraFields?: Record<string, string>,
timeout?: number,
options?: RequestOptions
): Promise<TResponse | undefined> => {
): Promise<ApiResponse<TResponse>> => {
const fd = new FormData()
const inferredName = filename ?? (file instanceof File ? file.name : 'file')
fd.append(fieldName, file, inferredName)
@ -466,6 +474,7 @@ const postFile = async <TResponse>(
return postFormData<TResponse>(url, fd, timeout, options)
}
/** Options that disable the global loader for a request (for background/UI-only fetches). */
const noLoaderOptions: RequestOptions = { skipLoader: true }
@ -475,7 +484,7 @@ const noLoaderOptions: RequestOptions = { skipLoader: true }
const getDataWithoutLoader = async <TResponse>(
url: string,
timeout?: number
): Promise<TResponse | undefined> =>
): Promise<ApiResponse<TResponse>> =>
getData<TResponse>(url, timeout, noLoaderOptions)
/**
@ -485,10 +494,11 @@ const postDataWithoutLoader = async <TRequest, TResponse>(
url: string,
data?: TRequest,
timeout?: number
): Promise<TResponse | undefined> =>
): Promise<ApiResponse<TResponse>> =>
postData<TRequest, TResponse>(url, data, timeout, noLoaderOptions)
export {
type ApiResponse,
axiosInstance,
getData,
postData,

View File

@ -58,9 +58,9 @@ const DataTableFilter = <T extends { [key: string]: string }>(props: FilterProps
pageSize: 100,
filters
}).then((response) => {
if (!response) return
if (!response.ok || !response.payload) return
const rows = response.data ?? []
const rows = response.payload.data ?? []
const linqQuery = rows.map(item => `${columnId} == "${item['id']}"`).join(' || ')
onFilterChange?.(filterId, columnId, linqQuery)

View File

@ -45,9 +45,9 @@ const DataTableLabel = <T extends { [key: string]: never }>(props: LabelProps) =
getDataWithoutLoader<T>(route)
.then(response => {
if (!response) return
if (!response.ok || !response.payload) return
setRemoteLabel(response[accessorKey])
setRemoteLabel(response.payload[accessorKey])
}).finally(() => {})
}, [props])

View File

@ -6,6 +6,7 @@ interface FileUploadComponentProps {
label?: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
multiple?: boolean
files?: File[]
onChange?: (files: File[]) => void
disabled?: boolean
}
@ -14,6 +15,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
label = 'Select files',
colspan = 6,
multiple = true,
files,
onChange,
disabled = false,
}) => {
@ -30,16 +32,47 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
}
}, [showPopup])
const areFilesEqual = (left: File[], right: File[]) =>
left.length === right.length &&
left.every((file, index) => {
const other = right[index]
return other &&
file.name === other.name &&
file.size === other.size &&
file.lastModified === other.lastModified &&
file.type === other.type
})
const displayFiles = files ?? selectedFiles
// Keep native input in sync for controlled resets.
React.useEffect(() => {
if (files !== undefined && files.length === 0 && inputRef.current)
inputRef.current.value = ''
}, [files])
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? Array.from(e.target.files) : []
setSelectedFiles(files)
onChange?.(files)
const nextFiles = e.target.files ? Array.from(e.target.files) : []
if (files === undefined) {
setSelectedFiles(nextFiles)
}
if (!areFilesEqual(nextFiles, displayFiles)) {
onChange?.(nextFiles)
}
}
const handleClear = () => {
setSelectedFiles([])
if (files === undefined) {
setSelectedFiles([])
}
if (inputRef.current) inputRef.current.value = ''
onChange?.([])
if (displayFiles.length > 0) {
onChange?.([])
}
}
const handleSelectFiles = () => {
@ -78,9 +111,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
className={'bg-gray-200 px-4 py-2 rounded w-full text-center select-none block'}
style={{ minHeight: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
{selectedFiles.length} file{selectedFiles.length !== 1 ? 's' : ''}
{displayFiles.length} file{displayFiles.length !== 1 ? 's' : ''}
</span>
{showPopup && selectedFiles.length > 0 && (
{showPopup && displayFiles.length > 0 && (
<div
ref={popupRef}
className={'fixed z-50 bg-white border border-gray-300 rounded shadow-lg p-2 text-sm'}
@ -111,7 +144,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
onFocus={() => {}}
>
<ul className={'max-h-40 overflow-auto'} tabIndex={0} style={{outline: 'none'}}>
{selectedFiles.map((file, idx) => (
{displayFiles.map((file, idx) => (
<li key={file.name + idx} className={'truncate'} title={file.name}>
{file.name}
</li>
@ -127,7 +160,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
<ButtonComponent
buttonHierarchy={'secondary'}
onClick={handleClear}
disabled={disabled || selectedFiles.length === 0}
disabled={disabled || displayFiles.length === 0}
colspan={1}
>
<TrashIcon />

View File

@ -70,8 +70,8 @@ const RemoteSelectBoxComponent = <TRequest extends PagedRequest>(props: RemoteSe
postData<TRequest, PagedResponse<SearchResponseBase>>(GetApiRoute(apiRoute).route, pagedRequest)
.then((response) => {
if (!response) return
setOptions(response.items)
if (!response.ok || !response.payload) return
setOptions(response.payload.items)
})
.catch((error) => {
console.error('RemoteSelectBox fetch error:', error)

View File

@ -50,10 +50,10 @@ const SecretComponent: FC<PasswordGeneratorProps> = (props) => {
const handleGenerateSecret = () => {
getData<TrngResponse>(`${GetApiRoute(ApiRoutes.generateSecret).route}`)
.then(response => {
if (!response) return
if (!response.ok || !response.payload) return
const fakeEvent = {
target: { value: response.secret }
target: { value: response.payload.secret }
} as ChangeEvent<HTMLInputElement>
handleOnChange(fakeEvent)

View File

@ -77,9 +77,9 @@ const CreateApiKey: FC<CreateApiKeyProps> = (props) => {
postData<CreateApiKeyRequest, ApiKeyResponse>(GetApiRoute(ApiRoutes.apikeyPost).route, request.data)
.then(response => {
if (!response) return
if (!response.ok || !response.payload) return
onSubmitted?.(response)
onSubmitted?.(response.payload)
})
}

View File

@ -125,7 +125,7 @@ const EditApiKey: FC<EditApiKeyProps> = (props) => {
getData<ApiKeyResponse>(GetApiRoute(ApiRoutes.apikeyGet).route
.replace('{apiKeyId}', apiKeyId))
.then(response => {
if (!response) {
if (!response.ok || !response.payload) {
// Leave form state as initial defaults; id will remain empty and
// the "not found" UI will be shown below.
setInitialState(deepCopy(initialFormState))
@ -133,7 +133,7 @@ const EditApiKey: FC<EditApiKeyProps> = (props) => {
return
}
handleInitialization(response)
handleInitialization(response.payload)
})
.finally(() => {
setHasLoaded(true)
@ -169,10 +169,10 @@ const EditApiKey: FC<EditApiKeyProps> = (props) => {
patchData<PatchApiKeyRequest, ApiKeyResponse>(GetApiRoute(ApiRoutes.apikeyPatch).route
.replace('{apiKeyId}', apiKeyId), request.data)
.then(response => {
if (!response) return
if (!response.ok || !response.payload) return
handleInitialization(response)
onSubmitted?.(response)
handleInitialization(response.payload)
onSubmitted?.(response.payload)
})
}

View File

@ -95,7 +95,7 @@ const SearchApiKey: FC = () => {
const loadData = useCallback(() => {
postData<SearchAPIKeyRequest, PagedResponse<SearchAPIKeyResponse>>(GetApiRoute(ApiRoutes.apikeySearch).route, pagedRequest).then((response) => {
setRawd(response ?? undefined)
setRawd(response.payload ?? undefined)
}).finally(() => {})
}, [pagedRequest])
@ -113,7 +113,10 @@ const SearchApiKey: FC = () => {
const handleDeleteRow = (ids: {[key: string]: string}) => {
deleteData(GetApiRoute(ApiRoutes.apikeyDelete).route
.replace('{apiKeyId}', ids.id)
).then(() => loadData())
).then((response) => {
if (!response.ok) return
loadData()
})
}
const handleEditCancel = () => {

View File

@ -111,9 +111,9 @@ const EditAccount: FC<EditAccountProps> = (props) => {
getData<GetAccountResponse>(GetApiRoute(ApiRoutes.ACCOUNT_GET).route
.replace('{accountId}', accountId)
).then((response) => {
if (!response) return
if (!response.ok || !response.payload) return
handleInitialization(response)
handleInitialization(response.payload)
})
}, [accountId, handleInitialization])
@ -168,10 +168,10 @@ const EditAccount: FC<EditAccountProps> = (props) => {
.replace('{accountId}', accountId), delta, 120000
)
if (!response) return
if (!response.ok || !response.payload) return
handleInitialization(response)
onSubmitted?.(response)
handleInitialization(response.payload)
onSubmitted?.(response.payload)
}

View File

@ -17,8 +17,8 @@ const Home: FC = () => {
const loadData = useCallback(() => {
getData<GetAccountResponse[]>(GetApiRoute(ApiRoutes.ACCOUNTS_GET).route).then((response) => {
if (!response) return
setRawd(response)
if (!response.ok || !response.payload) return
setRawd(response.payload)
})
}, [])
@ -30,7 +30,8 @@ const Home: FC = () => {
deleteData<void>(
GetApiRoute(ApiRoutes.ACCOUNT_DELETE)
.route.replace('{accountId}', accountId)
).then(_ => {
).then(response => {
if (!response.ok) return
setRawd(rawd.filter((account) => account.accountId !== accountId))
})
}
@ -43,9 +44,9 @@ const Home: FC = () => {
postData<void, { [key: string]: string }>(GetApiRoute(ApiRoutes.CERTS_FLOW_CERTIFICATES_APPLY).route
.replace('{accountId}', accountId)
).then(response => {
if (!response?.message) return
if (!response.ok || !response.payload?.message) return
addToast(response?.message, 'info')
addToast(response.payload.message, 'info')
})
}

View File

@ -20,15 +20,15 @@ const LetsEncryptTermsOfService: FC = () => {
isStaging: true
})
.then(response => {
if (!response) return
if (!response.ok || !response.payload) return
return getData<string>(
GetApiRoute(ApiRoutes.CERTS_FLOW_TERMS_OF_SERVICE).route.replace('{sessionId}', response),
GetApiRoute(ApiRoutes.CERTS_FLOW_TERMS_OF_SERVICE).route.replace('{sessionId}', response.payload),
120_000
)
})
.then(base64Pdf => {
if (typeof base64Pdf === 'string' && base64Pdf.length > 0) {
setPdfUrl(base64Pdf)
.then(response => {
if (response?.ok && typeof response.payload === 'string' && response.payload.length > 0) {
setPdfUrl(response.payload)
} else {
setError('Failed to retrieve PDF.')
}

View File

@ -137,7 +137,7 @@ const Register: FC<RegisterProps> = () => {
120000
)
if (!response) return
if (!response.ok) return
navigate('/')
}

View File

@ -76,9 +76,9 @@ const CreateUser: FC<CreateUserProps> = (props) => {
postData<CreateUserRequest, UserResponse>(GetApiRoute(ApiRoutes.identityPost).route, request.data)
.then((response) => {
if (!response) return
if (!response.ok || !response.payload) return
setInitialState(createUserFormPropsProto())
onSubmitted?.(response)
onSubmitted?.(response.payload)
})
}

View File

@ -61,10 +61,10 @@ const ChangePassword: FC<ChangePasswordProps> = (props) => {
data
)
if (!response) return
if (!response.ok || !response.payload) return
addToast('Password updated.', 'success')
onSubmitted?.(response)
onSubmitted?.(response.payload)
handleOnClose()
}

View File

@ -48,11 +48,11 @@ const EditUser: FC<EditUserProps> = (props) => {
const handleLoad = useCallback(() => {
getData<UserResponse>(GetApiRoute(ApiRoutes.identityGet).route.replace('{userId}', userId))
.then((response) => {
setUser(response ?? null)
if (response) {
setTwoFactorEnabled(!!response.twoFactorEnabled)
setRecoveryCodesLeft(response.recoveryCodesLeft)
setIsActive(response.isActive !== false)
setUser(response.payload ?? null)
if (response.ok && response.payload) {
setTwoFactorEnabled(!!response.payload.twoFactorEnabled)
setRecoveryCodesLeft(response.payload.recoveryCodesLeft)
setIsActive(response.payload.isActive !== false)
setDirtyIsActive(false)
}
})
@ -88,11 +88,11 @@ const EditUser: FC<EditUserProps> = (props) => {
GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId),
body
).then((response) => {
if (!response) return
setUser(response)
setIsActive(response.isActive !== false)
if (!response.ok || !response.payload) return
setUser(response.payload)
setIsActive(response.payload.isActive !== false)
setDirtyIsActive(false)
onSubmitted?.(response)
onSubmitted?.(response.payload)
})
}
@ -162,27 +162,27 @@ const EditUser: FC<EditUserProps> = (props) => {
GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId),
{ twoFactorEnabled: true }
).then((response) => {
if (!response) return
if (!response.ok || !response.payload) return
setShowEnableTwoFactor(true)
setTwoFactorEnabled(!!response.twoFactorEnabled)
setQrCodeUrl(response.qrCodeUrl)
setRecoveryCodes(response.twoFactorRecoveryCodes)
setRecoveryCodesLeft(response.recoveryCodesLeft)
setUser(response)
onSubmitted?.(response)
setTwoFactorEnabled(!!response.payload.twoFactorEnabled)
setQrCodeUrl(response.payload.qrCodeUrl)
setRecoveryCodes(response.payload.twoFactorRecoveryCodes)
setRecoveryCodesLeft(response.payload.recoveryCodesLeft)
setUser(response.payload)
onSubmitted?.(response.payload)
})
} else {
patchData<PatchUserEnabeleTwoFactorRequest, UserResponse>(
GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId),
{ twoFactorEnabled: false }
).then((response) => {
if (!response) return
setTwoFactorEnabled(!!response.twoFactorEnabled)
if (!response.ok || !response.payload) return
setTwoFactorEnabled(!!response.payload.twoFactorEnabled)
setQrCodeUrl(undefined)
setRecoveryCodes(undefined)
setRecoveryCodesLeft(response.recoveryCodesLeft)
setUser(response)
onSubmitted?.(response)
setRecoveryCodesLeft(response.payload.recoveryCodesLeft)
setUser(response.payload)
onSubmitted?.(response.payload)
})
}
}}

View File

@ -119,7 +119,7 @@ const SearchUser: FC = () => {
GetApiRoute(ApiRoutes.identitySearch).route,
pagedRequest
).then((response) => {
setRawd(response ?? undefined)
setRawd(response.payload ?? undefined)
}).finally(() => {})
}, [pagedRequest])
@ -135,7 +135,10 @@ const SearchUser: FC = () => {
const handleDeleteRow = (ids: Record<string, string>) => {
deleteData(GetApiRoute(ApiRoutes.identityDelete).route.replace('{userId}', ids.id))
.then(() => loadData())
.then((response) => {
if (!response.ok) return
loadData()
})
}
const handleEditCancel = () => {

View File

@ -16,9 +16,9 @@ const Utilities: FC = () => {
const hadnleTestAgent = () => {
getData<HelloWorldResponse>(GetApiRoute(ApiRoutes.AGENT_TEST).route)
.then((response) => {
if (!response) return
if (!response.ok || !response.payload) return
addToast(response.message, 'info')
addToast(response.payload.message, 'info')
})
}
@ -29,20 +29,25 @@ const Utilities: FC = () => {
}
const zipBlob = await downloadZip(files).blob()
// Option A: direct file helper
postFile(GetApiRoute(ApiRoutes.FULL_CACHE_UPLOAD_POST).route, zipBlob, 'file', 'cache.zip')
.then((_) => {
setFiles([])
addToast('Files uploaded successfully', 'success')
})
const response = await postFile(
GetApiRoute(ApiRoutes.FULL_CACHE_UPLOAD_POST).route,
zipBlob,
'file',
'cache.zip'
)
if (!response.ok)
return
setFiles([])
addToast('Files uploaded successfully', 'success')
}
const handleDownloadFiles = () => {
getBinary(GetApiRoute(ApiRoutes.FULL_CACHE_DOWNLOAD_GET).route
).then((response) => {
if (!response) return
if (!response.ok || !response.payload) return
const { data, headers } = response
const { data, headers } = response.payload
const filename = extractFilenameFromHeaders(headers, 'cache.zip')
saveBinaryToDisk(data, filename)
})
@ -50,7 +55,8 @@ const Utilities: FC = () => {
const handleDestroyFiles = () => {
deleteData(GetApiRoute(ApiRoutes.FULL_CACHE_DELETE).route)
.then((_) => {
.then((response) => {
if (!response.ok) return
addToast('Cache files destroyed successfully', 'success')
})
}
@ -72,6 +78,7 @@ const Utilities: FC = () => {
colspan={6}
label={'Select cache files'}
multiple={true}
files={files}
onChange={setFiles}
/>
@ -82,7 +89,7 @@ const Utilities: FC = () => {
onClick={handleUploadFiles}
/>
<span className={'col-span-3'}></span>
<span className={'col-span-12'}></span>
<ButtonComponent
colspan={3}

View File

@ -37,7 +37,7 @@ const login = createAsyncThunk(
async (requestData: LoginRequest) => {
const apiRoute = GetApiRoute(ApiRoutes.identityLogin)
const response = await postData<LoginRequest, LoginResponse>(apiRoute.route, requestData)
return response
return response.payload
}
)
@ -53,7 +53,7 @@ const logout = createAsyncThunk(
logoutFromAllDevices,
token: identity.token,
})
return response
return response.payload
}
)
@ -70,7 +70,7 @@ const refreshJwt = createAsyncThunk(
force,
})
return response
return response.payload
}
)