mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2026-06-10 00:28:11 +02:00
(feature): architecture improvements and session persistance
This commit is contained in:
parent
098fa91515
commit
4c92f6c25b
26
CHANGELOG.md
26
CHANGELOG.md
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
344
assets/docs/ARCHITECTURE_LAYERING.md
Normal file
344
assets/docs/ARCHITECTURE_LAYERING.md
Normal 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 6–7 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 **8–5** 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)
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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");
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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; }
|
||||||
|
}
|
||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
}
|
||||||
@ -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()]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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()]));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/MaksIT.CertsUI.Engine/QueryServices/ExpressionCompose.cs
Normal file
28
src/MaksIT.CertsUI.Engine/QueryServices/ExpressionCompose.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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; }
|
|
||||||
}
|
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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) =>
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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>();
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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,14 +219,13 @@ 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 }
|
||||||
return response.data
|
} catch (error: any) {
|
||||||
} catch {
|
return { payload: undefined, status: error?.response?.status, ok: false }
|
||||||
// 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.
|
||||||
* @param url The endpoint URL.
|
* @param url The endpoint URL.
|
||||||
@ -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,14 +437,13 @@ 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 }
|
||||||
return response.data
|
} catch (error: any) {
|
||||||
} catch {
|
return { payload: undefined, status: error?.response?.status, ok: false }
|
||||||
// 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.
|
||||||
* @param url The endpoint URL.
|
* @param url The endpoint URL.
|
||||||
@ -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,
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
|
|||||||
@ -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])
|
||||||
|
|
||||||
|
|||||||
@ -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 />
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -137,7 +137,7 @@ const Register: FC<RegisterProps> = () => {
|
|||||||
120000
|
120000
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!response) return
|
if (!response.ok) return
|
||||||
|
|
||||||
navigate('/')
|
navigate('/')
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -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 = () => {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user