(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). 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 ## [3.4.0] - 2026-04-27
### Breaking ### 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. 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 ## Development setup
### Prerequisites ### 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. 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 ## 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 // RegistrationCacheDto -> registration_caches
builder.Entity<RegistrationCacheDto>() builder.Entity<RegistrationCacheDto>()
.HasTableName(Table.RegistrationCaches.Name) .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.Version).HasColumnName("Version")
.Property(x => x.PayloadJson).HasColumnName("PayloadJson"); .Property(x => x.PayloadJson).HasColumnName("PayloadJson");

View File

@ -44,6 +44,11 @@ public interface ICertsFlowDomainService {
#region HTTP-01 challenge #region HTTP-01 challenge
Task<Result<string?>> AcmeChallengeAsync(string fileName, CancellationToken cancellationToken = default); Task<Result<string?>> AcmeChallengeAsync(string fileName, CancellationToken cancellationToken = default);
#endregion #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> /// <summary>
@ -56,7 +61,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
private readonly ILogger<CertsFlowDomainService> _logger; private readonly ILogger<CertsFlowDomainService> _logger;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly ILetsEncryptService _letsEncryptService; private readonly ILetsEncryptService _letsEncryptService;
private readonly IRegistrationCachePersistanceService _registrationCache; private readonly IRegistrationCacheDomainService _registrationCache;
private readonly IAgentDeploymentService _agentDeployment; private readonly IAgentDeploymentService _agentDeployment;
private readonly ICertsFlowEngineConfiguration _config; private readonly ICertsFlowEngineConfiguration _config;
private readonly ITermsOfServiceCachePersistenceService _termsOfServiceCache; private readonly ITermsOfServiceCachePersistenceService _termsOfServiceCache;
@ -68,7 +73,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
ILogger<CertsFlowDomainService> logger, ILogger<CertsFlowDomainService> logger,
HttpClient httpClient, HttpClient httpClient,
ILetsEncryptService letsEncryptService, ILetsEncryptService letsEncryptService,
IRegistrationCachePersistanceService registrationCache, IRegistrationCacheDomainService registrationCache,
IAgentDeploymentService agentDeployment, IAgentDeploymentService agentDeployment,
ICertsFlowEngineConfiguration config, ICertsFlowEngineConfiguration config,
ITermsOfServiceCachePersistenceService termsOfServiceCache, ITermsOfServiceCachePersistenceService termsOfServiceCache,
@ -190,7 +195,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
accountId = Guid.NewGuid(); accountId = Guid.NewGuid();
} }
else { 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) { if (!cacheResult.IsSuccess || cacheResult.Value == null) {
accountId = Guid.NewGuid(); accountId = Guid.NewGuid();
} }
@ -249,7 +254,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false); var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
if (!cacheResult.IsSuccess || cacheResult.Value == null) if (!cacheResult.IsSuccess || cacheResult.Value == null)
return cacheResult; 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) if (!saveResult.IsSuccess)
return saveResult; return saveResult;
return Result.Ok(); return Result.Ok();
@ -264,7 +269,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
#region Deploy and revoke #region Deploy and revoke
public async Task<Result<Dictionary<string, string>?>> ApplyCertificatesAsync(Guid accountId) { 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) if (!cacheResult.IsSuccess || cacheResult.Value?.CachedCerts == null)
return cacheResult.ToResultOfType<Dictionary<string, string>?>(_ => null); return cacheResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
var cache = cacheResult.Value; var cache = cacheResult.Value;
@ -291,7 +296,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false); var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
if (!cacheResult.IsSuccess || cacheResult.Value == null) if (!cacheResult.IsSuccess || cacheResult.Value == null)
return cacheResult; 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) if (!saveResult.IsSuccess)
return saveResult; return saveResult;
return Result.Ok(); return Result.Ok();
@ -384,12 +389,19 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
#endregion #endregion
#region Maintenance
public Task<Result<int>> PurgeStaleHttpChallengesAsync(TimeSpan maxAge, CancellationToken cancellationToken = default) =>
_httpChallenges.DeleteOlderThanAsync(maxAge, cancellationToken);
#endregion
private async Task TryPersistRegistrationCacheFromSessionAsync(Guid sessionId) { private async Task TryPersistRegistrationCacheFromSessionAsync(Guid sessionId) {
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false); var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
if (!cacheResult.IsSuccess || cacheResult.Value == null) if (!cacheResult.IsSuccess || cacheResult.Value == null)
return; 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) if (!saveResult.IsSuccess)
_logger.LogWarning("Could not persist registration cache after ACME flow step for account {AccountId}.", cacheResult.Value.AccountId); _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; namespace MaksIT.CertsUI.Engine.Dto.Certs;
/// <summary> /// <summary>
/// PostgreSQL <c>registration_caches</c> row: ACME registration payload as JSON text. /// PostgreSQL <c>registration_caches</c> row: ACME registration payload as JSON text.
/// </summary> /// </summary>
public class RegistrationCacheDto { public class RegistrationCacheDto : DtoDocumentBase<Guid> {
public Guid AccountId { get; set; } /// <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 long Version { get; set; }
public required string PayloadJson { 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 #region Registration cache
services.AddScoped<IRegistrationCachePersistanceService, RegistrationCachePersistanceServiceLinq2Db>(); services.AddScoped<IRegistrationCachePersistanceService, RegistrationCachePersistanceServiceLinq2Db>();
services.AddScoped<IRegistrationCacheDomainService, RegistrationCacheDomainService>();
services.AddScoped<IAcmeSessionPersistanceService, AcmeSessionPersistanceServiceLinq2Db>();
services.AddScoped<IAcmeHttpChallengePersistenceService, AcmeHttpChallengePersistenceServiceLinq2Db>(); services.AddScoped<IAcmeHttpChallengePersistenceService, AcmeHttpChallengePersistenceServiceLinq2Db>();
services.AddScoped<ITermsOfServiceCachePersistenceService, TermsOfServiceCachePersistenceServiceLinq2Db>(); services.AddScoped<ITermsOfServiceCachePersistenceService, TermsOfServiceCachePersistenceServiceLinq2Db>();
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>(); services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>();
@ -64,7 +66,6 @@ public static class ServiceCollectionExtensions {
#endregion #endregion
#region ACME / Let's Encrypt #region ACME / Let's Encrypt
services.AddSingleton<IAcmeSessionStore, AcmePostgresSessionStore>();
services.AddHttpClient<ILetsEncryptService, LetsEncryptService>(); services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
#endregion #endregion
} }

View File

@ -22,7 +22,6 @@
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" /> <PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
<PackageReference Include="Microsoft.Extensions.Http" 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" /> <PackageReference Include="Npgsql" Version="10.0.2" />
</ItemGroup> </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;
using LinqToDB.Data;
using MaksIT.Core.Extensions; using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Domain.Identity; using MaksIT.CertsUI.Engine.Domain.Identity;
using MaksIT.CertsUI.Engine.Dto.Identity; using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.Infrastructure; using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Persistance.Mappers; using MaksIT.CertsUI.Engine.Persistance.Mappers;
using MaksIT.CertsUI.Engine.Persistance.Services;
using MaksIT.Results; using MaksIT.Results;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;

View File

@ -1,12 +1,12 @@
using Microsoft.Extensions.Logging;
using LinqToDB; using LinqToDB;
using LinqToDB.Data; using LinqToDB.Data;
using MaksIT.Results;
using MaksIT.Core.Extensions; using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Domain.Certs; using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Dto.Certs; using MaksIT.CertsUI.Engine.Dto.Certs;
using MaksIT.CertsUI.Engine.Infrastructure; 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; namespace MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db;
@ -16,49 +16,57 @@ namespace MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db;
public sealed class RegistrationCachePersistanceServiceLinq2Db( public sealed class RegistrationCachePersistanceServiceLinq2Db(
ILogger<RegistrationCachePersistanceServiceLinq2Db> logger, ILogger<RegistrationCachePersistanceServiceLinq2Db> logger,
ICertsDataConnectionFactory connectionFactory ICertsDataConnectionFactory connectionFactory
) : IRegistrationCachePersistanceService { ) : IRegistrationCachePersistanceService
{
private readonly ILogger<RegistrationCachePersistanceServiceLinq2Db> _logger = logger; private readonly ILogger<RegistrationCachePersistanceServiceLinq2Db> _logger = logger;
private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory; private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory;
public Task<Result<RegistrationCache[]?>> LoadAllAsync(CancellationToken cancellationToken = default) { public Task<Result<RegistrationCache[]?>> LoadAllAsync(CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested(); cancellationToken.ThrowIfCancellationRequested();
try { try
{
using var db = _connectionFactory.Create(); using var db = _connectionFactory.Create();
var rows = db.GetTable<RegistrationCacheDto>().ToList(); var rows = db.GetTable<RegistrationCacheDto>().ToList();
var caches = new List<RegistrationCache>(); var caches = new List<RegistrationCache>();
foreach (var row in rows) { foreach (var row in rows)
if (string.IsNullOrWhiteSpace(row.PayloadJson)) { {
_logger.LogWarning("Registration cache row is empty for account {AccountId}", row.AccountId); if (string.IsNullOrWhiteSpace(row.PayloadJson))
{
_logger.LogWarning("Registration cache row is empty for account {AccountId}", row.Id);
continue; continue;
} }
var cache = row.PayloadJson.ToObject<RegistrationCache>(); var cache = row.PayloadJson.ToObject<RegistrationCache>();
if (cache == null) { if (cache == null)
_logger.LogWarning("Could not deserialize registration cache for account {AccountId}", row.AccountId); {
_logger.LogWarning("Could not deserialize registration cache for account {AccountId}", row.Id);
continue; continue;
} }
cache.AccountId = row.AccountId;
cache.ConcurrencyVersion = row.Version; cache.ConcurrencyVersion = row.Version;
caches.Add(cache); caches.Add(cache);
} }
return Task.FromResult(Result<RegistrationCache[]?>.Ok(caches.ToArray())); return Task.FromResult(Result<RegistrationCache[]?>.Ok(caches.ToArray()));
} }
catch (Exception ex) { catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) if (_logger.IsEnabled(LogLevel.Error))
_logger.LogError(ex, "Error loading all registration caches."); _logger.LogError(ex, "Error loading all registration caches.");
return Task.FromResult(Result<RegistrationCache[]?>.InternalServerError(null, ["An error occurred while loading registration caches.", .. ex.ExtractMessages()])); 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(); cancellationToken.ThrowIfCancellationRequested();
try { try
{
using var db = _connectionFactory.Create(); 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) if (row == null)
return Task.FromResult(Result<RegistrationCache?>.NotFound(null, $"Registration cache not found for account {accountId}.")); 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) if (cache == null)
return Task.FromResult(Result<RegistrationCache?>.InternalServerError(null, $"Registration cache payload is invalid for account {accountId}.")); return Task.FromResult(Result<RegistrationCache?>.InternalServerError(null, $"Registration cache payload is invalid for account {accountId}."));
cache.AccountId = accountId;
cache.ConcurrencyVersion = row.Version; cache.ConcurrencyVersion = row.Version;
return Task.FromResult(Result<RegistrationCache?>.Ok(cache)); return Task.FromResult(Result<RegistrationCache?>.Ok(cache));
} }
catch (Exception ex) { catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) if (_logger.IsEnabled(LogLevel.Error))
_logger.LogError(ex, "Error loading registration cache for account {AccountId}", accountId); _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()])); 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(); cancellationToken.ThrowIfCancellationRequested();
ArgumentNullException.ThrowIfNull(cache); ArgumentNullException.ThrowIfNull(cache);
try { try
{
using var db = _connectionFactory.Create(); using var db = _connectionFactory.Create();
cache.AccountId = accountId; cache.AccountId = accountId;
var json = cache.ToJson(); 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) { if (row == null)
db.Insert(new RegistrationCacheDto { {
AccountId = accountId, db.Insert(new RegistrationCacheDto
{
Id = accountId,
Version = 1, Version = 1,
PayloadJson = json PayloadJson = json
}); });
cache.ConcurrencyVersion = 1; cache.ConcurrencyVersion = 1;
} }
else { else
{
var expectedVersion = cache.ConcurrencyVersion > 0 ? cache.ConcurrencyVersion : row.Version; var expectedVersion = cache.ConcurrencyVersion > 0 ? cache.ConcurrencyVersion : row.Version;
var nextVersion = expectedVersion + 1; var nextVersion = expectedVersion + 1;
var updated = db.GetTable<RegistrationCacheDto>() 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.PayloadJson, json)
.Set(r => r.Version, nextVersion) .Set(r => r.Version, nextVersion)
.Update(); .Update();
if (updated == 0) { if (updated == 0)
{
_logger.LogWarning( _logger.LogWarning(
"Optimistic concurrency conflict for registration cache {AccountId}. Expected version {ExpectedVersion}.", "Optimistic concurrency conflict for registration cache {AccountId}. Expected version {ExpectedVersion}.",
accountId, expectedVersion); accountId, expectedVersion);
@ -120,33 +134,40 @@ public sealed class RegistrationCachePersistanceServiceLinq2Db(
return Task.FromResult(Result.Ok()); return Task.FromResult(Result.Ok());
} }
catch (Exception ex) { catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) if (_logger.IsEnabled(LogLevel.Error))
_logger.LogError(ex, "Error saving registration cache for account {AccountId}", accountId); _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()])); 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(); cancellationToken.ThrowIfCancellationRequested();
try { try
{
using var db = _connectionFactory.Create(); using var db = _connectionFactory.Create();
db.Execute("DELETE FROM registration_caches"); db.Execute("DELETE FROM registration_caches");
return Task.FromResult(Result.Ok()); return Task.FromResult(Result.Ok());
} }
catch (Exception ex) { catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) if (_logger.IsEnabled(LogLevel.Error))
_logger.LogError(ex, "Error deleting all registration caches."); _logger.LogError(ex, "Error deleting all registration caches.");
return Task.FromResult(Result.InternalServerError(["An error occurred while deleting registration caches.", .. ex.ExtractMessages()])); 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(); cancellationToken.ThrowIfCancellationRequested();
try { try
{
using var db = _connectionFactory.Create(); using var db = _connectionFactory.Create();
var deleted = db.GetTable<RegistrationCacheDto>().Where(r => r.AccountId == accountId).Delete(); var deleted = db.GetTable<RegistrationCacheDto>().Where(r => r.Id == accountId).Delete();
if (deleted == 0) { if (deleted == 0)
{
_logger.LogWarning("Registration cache not found for account {AccountId}", accountId); _logger.LogWarning("Registration cache not found for account {AccountId}", accountId);
return Task.FromResult(Result.Ok()); return Task.FromResult(Result.Ok());
} }
@ -154,10 +175,12 @@ public sealed class RegistrationCachePersistanceServiceLinq2Db(
_logger.LogInformation("Registration cache deleted for account {AccountId}", accountId); _logger.LogInformation("Registration cache deleted for account {AccountId}", accountId);
return Task.FromResult(Result.Ok()); return Task.FromResult(Result.Ok());
} }
catch (Exception ex) { catch (Exception ex)
{
if (_logger.IsEnabled(LogLevel.Error)) if (_logger.IsEnabled(LogLevel.Error))
_logger.LogError(ex, "Error deleting registration cache for account {AccountId}", accountId); _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()])); 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.CertsUI.Engine.Query.Identity;
using MaksIT.Results; using MaksIT.Results;
namespace MaksIT.CertsUI.Engine.QueryServices.Identity; namespace MaksIT.CertsUI.Engine.QueryServices.Identity;
/// <summary> /// <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> /// </summary>
public interface IApiKeyEntityScopeQueryService { public interface IApiKeyEntityScopeQueryService {
Task<Result<PagedQueryResult<ApiKeyEntityScopeQueryResult>>> SearchApiKeyEntityScopesAsync( Result<List<ApiKeyEntityScopeQueryResult>?> Search(
Guid? apiKeyId, Expression<Func<ApiKeyEntityScopeDto, bool>>? predicate,
int pageNumber, int? skip,
int pageSize, int? limit);
CancellationToken cancellationToken = default);
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.CertsUI.Engine.Query.Identity;
using MaksIT.Results; using MaksIT.Results;
namespace MaksIT.CertsUI.Engine.QueryServices.Identity; namespace MaksIT.CertsUI.Engine.QueryServices.Identity;
/// <summary> /// <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> /// </summary>
public interface IApiKeyQueryService { public interface IApiKeyQueryService {
Task<Result<PagedQueryResult<ApiKeyQueryResult>>> SearchApiKeysAsync( Result<List<ApiKeyQueryResult>?> Search(
string? descriptionFilter, Expression<Func<ApiKeyDto, bool>>? apiKeysPredicate,
int pageNumber, int? skip,
int pageSize, int? limit);
CancellationToken cancellationToken = default);
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.CertsUI.Engine.Query.Identity;
using MaksIT.Results; using MaksIT.Results;
namespace MaksIT.CertsUI.Engine.QueryServices.Identity; namespace MaksIT.CertsUI.Engine.QueryServices.Identity;
/// <summary> /// <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> /// </summary>
public interface IUserQueryService { public interface IUserQueryService {
Task<Result<PagedQueryResult<UserQueryResult>>> SearchUsersAsync( Result<List<UserQueryResult>?> Search(
string? usernameFilter, Expression<Func<UserDto, bool>>? usersPredicate,
int pageNumber, int? skip,
int pageSize, int? limit);
CancellationToken cancellationToken = default);
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.Query.Identity;
using MaksIT.CertsUI.Engine.QueryServices.Identity; using MaksIT.CertsUI.Engine.QueryServices.Identity;
using MaksIT.Results; using MaksIT.Results;
@ -7,23 +8,27 @@ using Microsoft.Extensions.Logging;
namespace MaksIT.CertsUI.Engine.QueryServices.Linq2Db.Identity; namespace MaksIT.CertsUI.Engine.QueryServices.Linq2Db.Identity;
/// <summary> /// <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. /// Replace with a Linq2Db implementation when scope rows exist.
/// </summary> /// </summary>
public sealed class ApiKeyEntityScopeQueryServiceStub(ILogger<ApiKeyEntityScopeQueryServiceStub> logger) : IApiKeyEntityScopeQueryService { public sealed class ApiKeyEntityScopeQueryServiceStub(ILogger<ApiKeyEntityScopeQueryServiceStub> logger) : IApiKeyEntityScopeQueryService {
public Task<Result<PagedQueryResult<ApiKeyEntityScopeQueryResult>>> SearchApiKeyEntityScopesAsync( public Result<List<ApiKeyEntityScopeQueryResult>?> Search(
Guid? apiKeyId, Expression<Func<ApiKeyEntityScopeDto, bool>>? predicate,
int pageNumber, int? skip,
int pageSize, int? limit) {
CancellationToken cancellationToken = default) { _ = predicate;
_ = apiKeyId; _ = skip;
cancellationToken.ThrowIfCancellationRequested(); _ = limit;
if (logger.IsEnabled(LogLevel.Debug)) if (logger.IsEnabled(LogLevel.Debug))
logger.LogDebug("Api key entity scope search is not persisted in Certs; returning empty page."); logger.LogDebug("Api key entity scope search is not persisted in Certs; returning empty list.");
var page = Math.Max(1, pageNumber); return Result<List<ApiKeyEntityScopeQueryResult>?>.Ok([]);
var size = Math.Clamp(pageSize, 1, 500); }
return Task.FromResult(Result<PagedQueryResult<ApiKeyEntityScopeQueryResult>>.Ok(
new PagedQueryResult<ApiKeyEntityScopeQueryResult>([], 0, page, size))); 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;
using LinqToDB.Data;
using MaksIT.Core.Extensions; using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Dto.Identity; using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.Infrastructure; using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Query;
using MaksIT.CertsUI.Engine.Query.Identity; using MaksIT.CertsUI.Engine.Query.Identity;
using MaksIT.CertsUI.Engine.QueryServices.Identity; using MaksIT.CertsUI.Engine.QueryServices.Identity;
using MaksIT.Results; using MaksIT.Results;
@ -12,49 +11,52 @@ using Microsoft.Extensions.Logging;
namespace MaksIT.CertsUI.Engine.QueryServices.Linq2Db.Identity; namespace MaksIT.CertsUI.Engine.QueryServices.Linq2Db.Identity;
/// <summary> /// <summary>
/// Linq2Db-based implementation of <see cref="IApiKeyQueryService"/>. /// Linq2Db-based implementation of <see cref="IApiKeyQueryService"/> (Vault-style predicates on <see cref="ApiKeyDto"/>).
/// </summary> /// </summary>
public class ApiKeyQueryServiceLinq2Db(ILogger<ApiKeyQueryServiceLinq2Db> logger, ICertsDataConnectionFactory connectionFactory) : IApiKeyQueryService { public class ApiKeyQueryServiceLinq2Db(ILogger<ApiKeyQueryServiceLinq2Db> logger, ICertsDataConnectionFactory connectionFactory) : IApiKeyQueryService {
private readonly ILogger<ApiKeyQueryServiceLinq2Db> _logger = logger; private readonly ILogger<ApiKeyQueryServiceLinq2Db> _logger = logger;
private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory; private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory;
public Task<Result<PagedQueryResult<ApiKeyQueryResult>>> SearchApiKeysAsync( public Result<List<ApiKeyQueryResult>?> Search(
string? descriptionFilter, Expression<Func<ApiKeyDto, bool>>? apiKeysPredicate,
int pageNumber, int? skip,
int pageSize, int? limit) {
CancellationToken cancellationToken = default) {
_ = cancellationToken;
try { 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(); using var db = _connectionFactory.Create();
var table = db.GetTable<ApiKeyDto>(); var query = db.GetTable<ApiKeyDto>().AsQueryable();
var filtered = string.IsNullOrWhiteSpace(filter) if (apiKeysPredicate != null)
? table query = query.Where(apiKeysPredicate);
: table.Where(k => (k.Description ?? string.Empty).Contains(filter));
var total = filtered.Count(); query = query.OrderByDescending(k => k.CreatedAtUtc);
var rows = filtered
.OrderByDescending(k => k.CreatedAtUtc)
.Skip(skip)
.Take(size)
.ToList();
var data = rows.Select(MapToQueryResult).ToList(); if (skip.HasValue)
query = query.Skip(skip.Value);
return Task.FromResult(Result<PagedQueryResult<ApiKeyQueryResult>>.Ok(new PagedQueryResult<ApiKeyQueryResult>( if (limit.HasValue)
data, query = query.Take(limit.Value);
total,
page, var rows = query.ToList();
size var results = rows.Select(MapToQueryResult).ToList();
))); return Result<List<ApiKeyQueryResult>?>.Ok(results);
} }
catch (Exception ex) { catch (Exception ex) {
_logger.LogError(ex, "Error occurred while searching API keys."); _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;
using LinqToDB.Data;
using MaksIT.Core.Extensions; using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Dto.Identity; using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.Infrastructure; using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Query;
using MaksIT.CertsUI.Engine.Query.Identity; using MaksIT.CertsUI.Engine.Query.Identity;
using MaksIT.CertsUI.Engine.QueryServices.Identity; using MaksIT.CertsUI.Engine.QueryServices.Identity;
using MaksIT.Results; using MaksIT.Results;
@ -12,36 +11,31 @@ using Microsoft.Extensions.Logging;
namespace MaksIT.CertsUI.Engine.QueryServices.Linq2Db.Identity; namespace MaksIT.CertsUI.Engine.QueryServices.Linq2Db.Identity;
/// <summary> /// <summary>
/// Linq2Db-based implementation of <see cref="IUserQueryService"/>. /// Linq2Db-based implementation of <see cref="IUserQueryService"/> (Vault-style predicates on <see cref="UserDto"/>).
/// </summary> /// </summary>
public class UserQueryServiceLinq2Db(ILogger<UserQueryServiceLinq2Db> logger, ICertsDataConnectionFactory connectionFactory) : IUserQueryService { public class UserQueryServiceLinq2Db(ILogger<UserQueryServiceLinq2Db> logger, ICertsDataConnectionFactory connectionFactory) : IUserQueryService {
private readonly ILogger<UserQueryServiceLinq2Db> _logger = logger; private readonly ILogger<UserQueryServiceLinq2Db> _logger = logger;
private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory; private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory;
public Task<Result<PagedQueryResult<UserQueryResult>>> SearchUsersAsync( public Result<List<UserQueryResult>?> Search(
string? usernameFilter, Expression<Func<UserDto, bool>>? usersPredicate,
int pageNumber, int? skip,
int pageSize, int? limit) {
CancellationToken cancellationToken = default) {
_ = cancellationToken;
try { 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(); using var db = _connectionFactory.Create();
var table = db.GetTable<UserDto>(); var query = db.GetTable<UserDto>().AsQueryable();
var filtered = string.IsNullOrWhiteSpace(filter) if (usersPredicate != null)
? table query = query.Where(usersPredicate);
: table.Where(u => u.Name.Contains(filter!));
var total = filtered.Count(); query = query.OrderBy(u => u.Name);
var rows = filtered
.OrderBy(u => u.Name) if (skip.HasValue)
.Skip(skip) query = query.Skip(skip.Value);
.Take(size)
.ToList(); if (limit.HasValue)
query = query.Take(limit.Value);
var rows = query.ToList();
var userIds = rows.Select(r => r.Id).ToList(); var userIds = rows.Select(r => r.Id).ToList();
var allRc = userIds.Count == 0 var allRc = userIds.Count == 0
@ -51,18 +45,27 @@ public class UserQueryServiceLinq2Db(ILogger<UserQueryServiceLinq2Db> logger, IC
.GroupBy(t => t.UserId) .GroupBy(t => t.UserId)
.ToDictionary(g => g.Key, g => g.Count()); .ToDictionary(g => g.Key, g => g.Count());
var data = rows.Select(r => MapToQueryResult(r, recoveryCountByUser.GetValueOrDefault(r.Id))).ToList(); var results = rows.Select(r => MapToQueryResult(r, recoveryCountByUser.GetValueOrDefault(r.Id))).ToList();
return Result<List<UserQueryResult>?>.Ok(results);
return Task.FromResult(Result<PagedQueryResult<UserQueryResult>>.Ok(new PagedQueryResult<UserQueryResult>(
data,
total,
page,
size
)));
} }
catch (Exception ex) { catch (Exception ex) {
_logger.LogError(ex, "Error occurred while searching users."); _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 System.Net.Http.Headers;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using MaksIT.Results;
using MaksIT.Core.Extensions; using MaksIT.Core.Extensions;
using MaksIT.Core.Security.JWS; using MaksIT.Core.Security.JWS;
using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt; using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt.Jws; using MaksIT.CertsUI.Engine.Domain.LetsEncrypt.Jws;
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Interfaces; using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Interfaces;
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses; using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
using MaksIT.Results;
namespace MaksIT.CertsUI.Engine.Services; namespace MaksIT.CertsUI.Engine.Services;
@ -16,16 +15,39 @@ public partial class LetsEncryptService {
#region Internal helpers #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( private async Task<Result> WithPersistedSessionAsync(
Guid sessionId, Guid sessionId,
CancellationToken cancellationToken, CancellationToken cancellationToken,
Func<State, Task<Result>> body) { Func<State, Task<Result>> body) {
var state = await _sessionStore.LoadOrCreateAsync(sessionId, cancellationToken).ConfigureAwait(false); var state = await LoadAcmeSessionStateAsync(sessionId, cancellationToken).ConfigureAwait(false);
try { try {
return await body(state).ConfigureAwait(false); return await body(state).ConfigureAwait(false);
} }
finally { 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, Guid sessionId,
CancellationToken cancellationToken, CancellationToken cancellationToken,
Func<State, Task<Result<T?>>> body) { Func<State, Task<Result<T?>>> body) {
var state = await _sessionStore.LoadOrCreateAsync(sessionId, cancellationToken).ConfigureAwait(false); var state = await LoadAcmeSessionStateAsync(sessionId, cancellationToken).ConfigureAwait(false);
try { try {
return await body(state).ConfigureAwait(false); return await body(state).ConfigureAwait(false);
} }
finally { 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 * 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.Extensions;
using MaksIT.Core.Security; using MaksIT.Core.Security;
using MaksIT.Core.Security.JWK; 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.Certs;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt; using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt.Jws; 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.Requests;
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses; using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
using MaksIT.Results; using MaksIT.CertsUI.Engine.Persistance.Services;
using Microsoft.Extensions.Logging;
using System.Net.Http.Headers;
using System.Threading;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
namespace MaksIT.CertsUI.Engine.Services; namespace MaksIT.CertsUI.Engine.Services;
@ -45,18 +44,18 @@ public partial class LetsEncryptService : ILetsEncryptService {
private readonly ILogger<LetsEncryptService> _logger; private readonly ILogger<LetsEncryptService> _logger;
private readonly ICertsEngineConfiguration _engineConfiguration; private readonly ICertsEngineConfiguration _engineConfiguration;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly IAcmeSessionStore _sessionStore; private readonly IAcmeSessionPersistanceService _acmeSessionPersistence;
public LetsEncryptService( public LetsEncryptService(
ILogger<LetsEncryptService> logger, ILogger<LetsEncryptService> logger,
ICertsEngineConfiguration engineConfiguration, ICertsEngineConfiguration engineConfiguration,
HttpClient httpClient, HttpClient httpClient,
IAcmeSessionStore sessionStore IAcmeSessionPersistanceService acmeSessionPersistence
) { ) {
_logger = logger; _logger = logger;
_engineConfiguration = engineConfiguration; _engineConfiguration = engineConfiguration;
_httpClient = httpClient; _httpClient = httpClient;
_sessionStore = sessionStore; _acmeSessionPersistence = acmeSessionPersistence;
} }
public Task<Result<RegistrationCache?>> GetRegistrationCacheAsync(Guid sessionId, CancellationToken cancellationToken = default) => public Task<Result<RegistrationCache?>> GetRegistrationCacheAsync(Guid sessionId, CancellationToken cancellationToken = default) =>

View File

@ -1,7 +1,8 @@
using System.Linq; using System.Linq;
using System.Linq.Expressions;
using MaksIT.CertsUI.Engine.Domain.Identity; using MaksIT.CertsUI.Engine.Domain.Identity;
using MaksIT.CertsUI.Engine.Dto.Identity;
using MaksIT.CertsUI.Engine.Persistance.Services; using MaksIT.CertsUI.Engine.Persistance.Services;
using MaksIT.CertsUI.Engine.Query;
using MaksIT.CertsUI.Engine.Query.Identity; using MaksIT.CertsUI.Engine.Query.Identity;
using MaksIT.CertsUI.Engine.QueryServices.Identity; using MaksIT.CertsUI.Engine.QueryServices.Identity;
using MaksIT.Results; using MaksIT.Results;
@ -25,35 +26,59 @@ public sealed class InMemoryUserStore : IIdentityPersistanceService, IUserQueryS
return Task.FromResult(Result<List<User>>.Ok([.. _users])); return Task.FromResult(Result<List<User>>.Ok([.. _users]));
} }
public Task<Result<PagedQueryResult<UserQueryResult>>> SearchUsersAsync( public Result<int?> Count(Expression<Func<UserDto, bool>>? usersPredicate) {
string? usernameFilter,
int pageNumber,
int pageSize,
CancellationToken cancellationToken = default) {
lock (_lock) { lock (_lock) {
IEnumerable<User> filtered = _users; var q = _users.Select(ToUserDto).AsQueryable();
if (!string.IsNullOrWhiteSpace(usernameFilter)) if (usersPredicate != null)
filtered = filtered.Where(u => u.Username.Contains(usernameFilter, StringComparison.OrdinalIgnoreCase)); q = q.Where(usersPredicate);
var ordered = filtered.OrderBy(u => u.Username).ToList(); return Result<int?>.Ok(q.Count());
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(); public Result<List<UserQueryResult>?> Search(
var data = slice.Select(u => new UserQueryResult { 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, Id = u.Id,
Username = u.Username, Name = u.Username,
Salt = u.PasswordSalt,
Hash = u.PasswordHash,
LastLoginUtc = u.LastLogin ?? default,
IsActive = u.IsActive, IsActive = u.IsActive,
TwoFactorEnabled = u.TwoFactorEnabled, TwoFactorSharedKey = u.TwoFactorSharedKey,
LastLogin = u.LastLogin, JwtTokens = [],
}).ToList(); TwoFactorRecoveryCodes = [.. u.TwoFactorRecoveryCodes.Select(rc => new TwoFactorRecoveryCodeDto {
return Task.FromResult(Result<PagedQueryResult<UserQueryResult>>.Ok(new PagedQueryResult<UserQueryResult>( Id = rc.Id,
data, UserId = u.Id,
total, Salt = rc.Salt,
page, Hash = rc.Hash,
size 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) { public Task<Result<User?>> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) {
lock (_lock) { lock (_lock) {

View File

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

View File

@ -45,10 +45,10 @@ public class ApiKeyQueryServiceIntegrationTests(PostgresCacheFixture pg) {
Assert.False(string.IsNullOrWhiteSpace(created.Value!.ApiKey)); Assert.False(string.IsNullOrWhiteSpace(created.Value!.ApiKey));
Assert.Contains('|', 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.True(search.IsSuccess);
Assert.NotNull(search.Value); 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 LinqToDB.Data;
using MaksIT.CertsUI.Engine.Dto.Certs; using MaksIT.CertsUI.Engine.Dto.Certs;
using MaksIT.CertsUI.Engine.Domain.Certs; using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.DomainServices;
using MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db; using MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db;
using MaksIT.CertsUI.Services; using MaksIT.CertsUI.Services;
using MaksIT.CertsUI.Tests.Infrastructure; using MaksIT.CertsUI.Tests.Infrastructure;
@ -13,12 +14,11 @@ namespace MaksIT.CertsUI.Tests.Services;
[Collection("postgres-cache")] [Collection("postgres-cache")]
public class CacheServiceTests(PostgresCacheFixture pg) { public class CacheServiceTests(PostgresCacheFixture pg) {
private CacheService CreateSut() => private CacheService CreateSut() {
new( var persistence = new RegistrationCachePersistanceServiceLinq2Db(NullLogger<RegistrationCachePersistanceServiceLinq2Db>.Instance, pg.ConnectionFactory);
NullLogger<CacheService>.Instance, var domain = new RegistrationCacheDomainService(NullLogger<RegistrationCacheDomainService>.Instance, persistence);
pg.Config.AppOptions, return new CacheService(NullLogger<CacheService>.Instance, pg.Config.AppOptions, domain);
new RegistrationCachePersistanceServiceLinq2Db(NullLogger<RegistrationCachePersistanceServiceLinq2Db>.Instance, pg.ConnectionFactory) }
);
[Fact] [Fact]
public async Task LoadAccountsFromCacheAsync_WhenNoRows_ReturnsEmptyArray() { public async Task LoadAccountsFromCacheAsync_WhenNoRows_ReturnsEmptyArray() {
@ -77,8 +77,8 @@ public class CacheServiceTests(PostgresCacheFixture pg) {
public async Task LoadAccountFromCacheAsync_WhenPayloadEmpty_ReturnsError() { public async Task LoadAccountFromCacheAsync_WhenPayloadEmpty_ReturnsError() {
await ClearCachesAsync(); await ClearCachesAsync();
var id = Guid.NewGuid(); var id = Guid.NewGuid();
using (var db = (DataConnection)pg.ConnectionFactory.Create()) { using (var db = pg.ConnectionFactory.Create()) {
db.Insert(new RegistrationCacheDto { AccountId = id, PayloadJson = "" }); db.Insert(new RegistrationCacheDto { Id = id, AccountId = id, PayloadJson = "" });
} }
var sut = CreateSut(); var sut = CreateSut();

View File

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

View File

@ -1,6 +1,6 @@
using MaksIT.CertsUI.Engine.Domain.Certs; using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.DomainServices;
using MaksIT.CertsUI.Engine.Infrastructure; using MaksIT.CertsUI.Engine.Infrastructure;
using MaksIT.CertsUI.Engine.Persistance.Services;
using MaksIT.CertsUI.Engine.RuntimeCoordination; using MaksIT.CertsUI.Engine.RuntimeCoordination;
using MaksIT.CertsUI.Services; using MaksIT.CertsUI.Services;
@ -38,15 +38,15 @@ public sealed class AutoRenewal(
logger.LogInformation("Running certificate renewal sweep (lease holder {Holder}).", holder); logger.LogInformation("Running certificate renewal sweep (lease holder {Holder}).", holder);
using var scope = scopeFactory.CreateScope(); 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 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) if (purge.IsSuccess && purge.Value > 0)
logger.LogInformation("Purged {Count} HTTP-01 challenge row(s) older than 10 days.", purge.Value); 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) { if (!loadAccountsFromCacheResult.IsSuccess || loadAccountsFromCacheResult.Value == null) {
LogErrorMessages(loadAccountsFromCacheResult.Messages); LogErrorMessages(loadAccountsFromCacheResult.Messages);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken).ConfigureAwait(false); await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken).ConfigureAwait(false);

View File

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

View File

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

View File

@ -1,8 +1,7 @@
using System.IO.Compression; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using MaksIT.Core.Extensions;
using MaksIT.CertsUI.Engine.Domain.Certs; using MaksIT.CertsUI.Engine.Domain.Certs;
using MaksIT.CertsUI.Engine.Persistance.Services; using MaksIT.CertsUI.Engine.DomainServices;
using MaksIT.Results; using MaksIT.Results;
using MaksIT.CertsUI.Abstractions.Services; using MaksIT.CertsUI.Abstractions.Services;
@ -20,136 +19,40 @@ public interface ICacheService {
Task<Result> DeleteAccountCacheAsync(Guid accountId); Task<Result> DeleteAccountCacheAsync(Guid accountId);
} }
/// <summary>Web API façade for registration cache operations; delegates to <see cref="IRegistrationCacheDomainService"/>.</summary>
public class CacheService( public class CacheService(
ILogger<CacheService> logger, ILogger<CacheService> logger,
IOptions<Configuration> appSettings, IOptions<Configuration> appSettings,
IRegistrationCachePersistanceService registrationCachePersistence IRegistrationCacheDomainService registrationCacheDomain
) : ServiceBase( ) : ServiceBase(
logger, logger,
appSettings appSettings
), ICacheService { ), ICacheService {
public Task<Result<RegistrationCache[]?>> LoadAccountsFromCacheAsync() { public Task<Result<RegistrationCache[]?>> LoadAccountsFromCacheAsync() =>
return registrationCachePersistence.LoadAllAsync(); registrationCacheDomain.LoadAllAsync();
}
public Task<Result<RegistrationCache?>> LoadAccountFromCacheAsync(Guid accountId) => public Task<Result<RegistrationCache?>> LoadAccountFromCacheAsync(Guid accountId) =>
registrationCachePersistence.LoadAsync(accountId); registrationCacheDomain.LoadAsync(accountId);
public Task<Result> SaveToCacheAsync(Guid accountId, RegistrationCache cache) => public Task<Result> SaveToCacheAsync(Guid accountId, RegistrationCache cache) =>
registrationCachePersistence.SaveAsync(accountId, cache); registrationCacheDomain.SaveAsync(accountId, cache);
public async Task<Result<byte[]>> DownloadCacheZipAsync() { public Task<Result<byte[]>> DownloadCacheZipAsync() =>
try { registrationCacheDomain.DownloadCacheZipAsync();
var allResult = await registrationCachePersistence.LoadAllAsync();
if (!allResult.IsSuccess || allResult.Value == null)
return Result<byte[]>.InternalServerError(null, allResult.Messages?.ToArray() ?? ["Could not load registration caches."]);
var rows = allResult.Value; public Task<Result<byte[]?>> DownloadAccountCacheZipAsync(Guid accountId) =>
using var ms = new MemoryStream(); registrationCacheDomain.DownloadAccountCacheZipAsync(accountId);
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) { public Task<Result> UploadCacheZipAsync(byte[] zipBytes) =>
try { registrationCacheDomain.UploadCacheZipAsync(zipBytes);
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) { public Task<Result> UploadAccountCacheZipAsync(Guid accountId, byte[] zipBytes) =>
try { registrationCacheDomain.UploadAccountCacheZipAsync(accountId, zipBytes);
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) { public Task<Result> DeleteCacheAsync() =>
try { registrationCacheDomain.DeleteAllAsync();
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()]));
}
}
private async Task<Result> ImportZipEntriesAsync(IReadOnlyList<ZipArchiveEntry> entries) { public Task<Result> DeleteAccountCacheAsync(Guid accountId) =>
foreach (var entry in entries) { registrationCacheDomain.DeleteAsync(accountId);
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);
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ interface FileUploadComponentProps {
label?: string label?: string
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
multiple?: boolean multiple?: boolean
files?: File[]
onChange?: (files: File[]) => void onChange?: (files: File[]) => void
disabled?: boolean disabled?: boolean
} }
@ -14,6 +15,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
label = 'Select files', label = 'Select files',
colspan = 6, colspan = 6,
multiple = true, multiple = true,
files,
onChange, onChange,
disabled = false, disabled = false,
}) => { }) => {
@ -30,17 +32,48 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
} }
}, [showPopup]) }, [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 handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? Array.from(e.target.files) : [] const nextFiles = e.target.files ? Array.from(e.target.files) : []
setSelectedFiles(files)
onChange?.(files) if (files === undefined) {
setSelectedFiles(nextFiles)
}
if (!areFilesEqual(nextFiles, displayFiles)) {
onChange?.(nextFiles)
}
} }
const handleClear = () => { const handleClear = () => {
if (files === undefined) {
setSelectedFiles([]) setSelectedFiles([])
}
if (inputRef.current) inputRef.current.value = '' if (inputRef.current) inputRef.current.value = ''
if (displayFiles.length > 0) {
onChange?.([]) onChange?.([])
} }
}
const handleSelectFiles = () => { const handleSelectFiles = () => {
if (!disabled) inputRef.current?.click() if (!disabled) inputRef.current?.click()
@ -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'} 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' }} style={{ minHeight: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
> >
{selectedFiles.length} file{selectedFiles.length !== 1 ? 's' : ''} {displayFiles.length} file{displayFiles.length !== 1 ? 's' : ''}
</span> </span>
{showPopup && selectedFiles.length > 0 && ( {showPopup && displayFiles.length > 0 && (
<div <div
ref={popupRef} ref={popupRef}
className={'fixed z-50 bg-white border border-gray-300 rounded shadow-lg p-2 text-sm'} 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={() => {}} onFocus={() => {}}
> >
<ul className={'max-h-40 overflow-auto'} tabIndex={0} style={{outline: 'none'}}> <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}> <li key={file.name + idx} className={'truncate'} title={file.name}>
{file.name} {file.name}
</li> </li>
@ -127,7 +160,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
<ButtonComponent <ButtonComponent
buttonHierarchy={'secondary'} buttonHierarchy={'secondary'}
onClick={handleClear} onClick={handleClear}
disabled={disabled || selectedFiles.length === 0} disabled={disabled || displayFiles.length === 0}
colspan={1} colspan={1}
> >
<TrashIcon /> <TrashIcon />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -17,8 +17,8 @@ const Home: FC = () => {
const loadData = useCallback(() => { const loadData = useCallback(() => {
getData<GetAccountResponse[]>(GetApiRoute(ApiRoutes.ACCOUNTS_GET).route).then((response) => { getData<GetAccountResponse[]>(GetApiRoute(ApiRoutes.ACCOUNTS_GET).route).then((response) => {
if (!response) return if (!response.ok || !response.payload) return
setRawd(response) setRawd(response.payload)
}) })
}, []) }, [])
@ -30,7 +30,8 @@ const Home: FC = () => {
deleteData<void>( deleteData<void>(
GetApiRoute(ApiRoutes.ACCOUNT_DELETE) GetApiRoute(ApiRoutes.ACCOUNT_DELETE)
.route.replace('{accountId}', accountId) .route.replace('{accountId}', accountId)
).then(_ => { ).then(response => {
if (!response.ok) return
setRawd(rawd.filter((account) => account.accountId !== accountId)) 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 postData<void, { [key: string]: string }>(GetApiRoute(ApiRoutes.CERTS_FLOW_CERTIFICATES_APPLY).route
.replace('{accountId}', accountId) .replace('{accountId}', accountId)
).then(response => { ).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 isStaging: true
}) })
.then(response => { .then(response => {
if (!response) return if (!response.ok || !response.payload) return
return getData<string>( 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 120_000
) )
}) })
.then(base64Pdf => { .then(response => {
if (typeof base64Pdf === 'string' && base64Pdf.length > 0) { if (response?.ok && typeof response.payload === 'string' && response.payload.length > 0) {
setPdfUrl(base64Pdf) setPdfUrl(response.payload)
} else { } else {
setError('Failed to retrieve PDF.') setError('Failed to retrieve PDF.')
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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