mirror of
https://github.com/MAKS-IT-COM/maksit-certs-ui.git
synced 2026-05-16 04:48:12 +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).
|
||||
|
||||
## [3.4.1] - 2026-04-30
|
||||
|
||||
### Breaking
|
||||
|
||||
- **Engine query ports (Vault-style):** **`IUserQueryService`**, **`IApiKeyQueryService`**, and **`IApiKeyEntityScopeQueryService`** no longer expose async paged **`Search…Async`** with string filters. They now use synchronous **`Search`** / **`Count`** with optional **`Expression<Func<TDto, bool>>?`** predicates (Linq2Db-translatable), **`skip` / `limit`**, and **`Result`** types—matching the thin-search wiring in **`IdentityService`** / **`ApiKeyService`**. Custom Engine hosts must update call sites and registrations.
|
||||
- **ACME session persistence:** **`IAcmeSessionStore`**, **`AcmePostgresSessionStore`**, **`AcmeSessionSnapshot`**, and **`AcmeSessionJsonSerializer`** are removed. **`ILetsEncryptService`** now depends on **`IAcmeSessionPersistanceService`** (**`AcmeSessionPersistanceServiceLinq2Db`**) for **`acme_sessions`** JSON load/save.
|
||||
- **`ICertsFlowDomainService`:** Constructor takes **`IRegistrationCacheDomainService`** instead of **`IRegistrationCachePersistanceService`** (registration cache orchestration moved behind **`RegistrationCacheDomainService`**).
|
||||
|
||||
### Added
|
||||
|
||||
- **`ExpressionCompose`** (`QueryServices/ExpressionCompose.cs`) for composing nested Linq2Db predicates (Vault parity).
|
||||
- **`IRegistrationCacheDomainService`** / **`RegistrationCacheDomainService`**, **`RegistrationCachePayloadDocument`**, and **`RegistrationCachePayloadJsonTests`** (Engine unit tests) for registration-cache JSON handling; **`RegistrationCacheDto`** now extends **`DtoDocumentBase<Guid>`** with **`AccountId`** as an alias of **`Id`**; persistence and mapping updates in **`RegistrationCachePersistanceServiceLinq2Db`** / **`CertsLinq2DbMapping`**.
|
||||
- **`IAcmeSessionPersistanceService`**, **`AcmeSessionPersistanceServiceLinq2Db`**, and **`AcmeSessionPayloadMapper`** for PostgreSQL-backed ACME **`State`** persistence.
|
||||
- **`ApiKeyEntityScopeDto`** and **`ApiKeyEntityScopeQueryServiceStub`** adjustments for entity-scope search parity.
|
||||
- **Docs:** **`assets/docs/ARCHITECTURE_LAYERING.md`** (layering, spine flows, Pattern A/B); **[CONTRIBUTING.md](CONTRIBUTING.md)** links to it and documents **`dotnet test`** for **`MaksIT.CertsUI.Engine.Tests`** / **`MaksIT.CertsUI.Tests`**.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`LetsEncryptService`:** Uses **`IAcmeSessionPersistanceService`**; helper updates in **`LetsEncryptService.Helpers.cs`**.
|
||||
- **`CertsFlowDomainService`:** **`PurgeStaleHttpChallengesAsync`** (HTTP-01 cleanup); **`AutoRenewal`** calls it before renewal work.
|
||||
- **`CacheService`:** Thin façade over **`IRegistrationCacheDomainService`** (host API unchanged for callers).
|
||||
- **`IdentityService`** / **`ApiKeyService`:** Build predicates and call **`Count`** + **`Search`** on **`IUserQueryService`** / **`IApiKeyQueryService`** / **`IApiKeyEntityScopeQueryService`**.
|
||||
- **Engine:** Dropped **`Newtonsoft.Json`** package reference from **`MaksIT.CertsUI.Engine`** (STJ-only JSON paths).
|
||||
- **Web UI:** **`axiosConfig`** **`getData`** / **`postData`** (and related helpers) return **`{ payload, status, ok }`** so callers can distinguish HTTP status; forms and slices updated (**`SearchUser`**, **`SearchApiKey`**, **`Utilities`**, **`EditUser`**, **`Home`**, **`FileUploadComponent`**, **`identitySlice`**, etc.).
|
||||
- **Integration tests:** **`InMemoryUserStore`**, **`CacheServiceTests`**, **`CertsFlowServiceTests`**, **`ApiKeyQueryServiceIntegrationTests`**, **`AccountServicePatchAccountIntegrationTests`** aligned with the new ports.
|
||||
|
||||
## [3.4.0] - 2026-04-27
|
||||
|
||||
### Breaking
|
||||
|
||||
@ -12,6 +12,12 @@ Useful contributions include bug fixes, documentation improvements, Helm chart u
|
||||
|
||||
Large or architectural changes are best discussed first (see [Contact](#contact)) so effort aligns with project goals.
|
||||
|
||||
## Architecture and code layout
|
||||
|
||||
**Where the rules live:** layering, folder responsibilities, persistence vs host boundaries, DI lifetimes, and an AI/contributor checklist are documented in **[assets/docs/ARCHITECTURE_LAYERING.md](assets/docs/ARCHITECTURE_LAYERING.md)**. Read that before adding new Engine persistence, services, or cross-project dependencies.
|
||||
|
||||
**Summary:** `MaksIT.CertsUI.Engine` holds domain, PostgreSQL persistence (Linq2Db), migrations, and ACME engine code; it returns `MaksIT.Results` types, not HTTP responses. `MaksIT.CertsUI` is the web host (controllers, app services, ProblemDetails). Topic-specific design notes also live under [assets/docs/](assets/docs/) (HA, auth, proxy, etc.).
|
||||
|
||||
## Development setup
|
||||
|
||||
### Prerequisites
|
||||
@ -34,7 +40,7 @@ Use `Debug` while iterating locally if you prefer.
|
||||
|
||||
Follow [README.md](README.md) for Podman Compose, Docker Compose, or Kubernetes (Helm). That is the supported way to exercise the WebAPI, WebUI, and reverse proxy together.
|
||||
|
||||
There is no separate automated test project in this repository today; manual verification through the WebUI and your compose or cluster setup is the practical check for most changes.
|
||||
**Automated tests:** from the repo root, `dotnet test src/MaksIT.CertsUI.Engine.Tests` and `dotnet test src/MaksIT.CertsUI.Tests` (the latter may require a reachable PostgreSQL when integration tests run). For UI-only or deployment changes, manual verification through the WebUI and compose or cluster setup still applies.
|
||||
|
||||
## Pull requests
|
||||
|
||||
|
||||
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
|
||||
builder.Entity<RegistrationCacheDto>()
|
||||
.HasTableName(Table.RegistrationCaches.Name)
|
||||
.Property(x => x.AccountId).HasColumnName("AccountId").IsPrimaryKey()
|
||||
.Property(x => x.Id).HasColumnName("AccountId").IsPrimaryKey()
|
||||
.Property(x => x.AccountId).IsNotColumn()
|
||||
.Property(x => x.Version).HasColumnName("Version")
|
||||
.Property(x => x.PayloadJson).HasColumnName("PayloadJson");
|
||||
|
||||
|
||||
@ -44,6 +44,11 @@ public interface ICertsFlowDomainService {
|
||||
#region HTTP-01 challenge
|
||||
Task<Result<string?>> AcmeChallengeAsync(string fileName, CancellationToken cancellationToken = default);
|
||||
#endregion
|
||||
|
||||
#region Maintenance
|
||||
/// <summary>Deletes HTTP-01 challenge rows older than <paramref name="maxAge"/> (used by renewal sweep).</summary>
|
||||
Task<Result<int>> PurgeStaleHttpChallengesAsync(TimeSpan maxAge, CancellationToken cancellationToken = default);
|
||||
#endregion
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -56,7 +61,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
private readonly ILogger<CertsFlowDomainService> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly ILetsEncryptService _letsEncryptService;
|
||||
private readonly IRegistrationCachePersistanceService _registrationCache;
|
||||
private readonly IRegistrationCacheDomainService _registrationCache;
|
||||
private readonly IAgentDeploymentService _agentDeployment;
|
||||
private readonly ICertsFlowEngineConfiguration _config;
|
||||
private readonly ITermsOfServiceCachePersistenceService _termsOfServiceCache;
|
||||
@ -68,7 +73,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
ILogger<CertsFlowDomainService> logger,
|
||||
HttpClient httpClient,
|
||||
ILetsEncryptService letsEncryptService,
|
||||
IRegistrationCachePersistanceService registrationCache,
|
||||
IRegistrationCacheDomainService registrationCache,
|
||||
IAgentDeploymentService agentDeployment,
|
||||
ICertsFlowEngineConfiguration config,
|
||||
ITermsOfServiceCachePersistenceService termsOfServiceCache,
|
||||
@ -190,7 +195,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
accountId = Guid.NewGuid();
|
||||
}
|
||||
else {
|
||||
var cacheResult = await _registrationCache.LoadAsync(accountId.Value);
|
||||
var cacheResult = await _registrationCache.LoadAsync(accountId.Value, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!cacheResult.IsSuccess || cacheResult.Value == null) {
|
||||
accountId = Guid.NewGuid();
|
||||
}
|
||||
@ -249,7 +254,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!cacheResult.IsSuccess || cacheResult.Value == null)
|
||||
return cacheResult;
|
||||
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value);
|
||||
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!saveResult.IsSuccess)
|
||||
return saveResult;
|
||||
return Result.Ok();
|
||||
@ -264,7 +269,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
#region Deploy and revoke
|
||||
|
||||
public async Task<Result<Dictionary<string, string>?>> ApplyCertificatesAsync(Guid accountId) {
|
||||
var cacheResult = await _registrationCache.LoadAsync(accountId);
|
||||
var cacheResult = await _registrationCache.LoadAsync(accountId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!cacheResult.IsSuccess || cacheResult.Value?.CachedCerts == null)
|
||||
return cacheResult.ToResultOfType<Dictionary<string, string>?>(_ => null);
|
||||
var cache = cacheResult.Value;
|
||||
@ -291,7 +296,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!cacheResult.IsSuccess || cacheResult.Value == null)
|
||||
return cacheResult;
|
||||
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value);
|
||||
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!saveResult.IsSuccess)
|
||||
return saveResult;
|
||||
return Result.Ok();
|
||||
@ -384,12 +389,19 @@ public class CertsFlowDomainService : ICertsFlowDomainService {
|
||||
|
||||
#endregion
|
||||
|
||||
#region Maintenance
|
||||
|
||||
public Task<Result<int>> PurgeStaleHttpChallengesAsync(TimeSpan maxAge, CancellationToken cancellationToken = default) =>
|
||||
_httpChallenges.DeleteOlderThanAsync(maxAge, cancellationToken);
|
||||
|
||||
#endregion
|
||||
|
||||
private async Task TryPersistRegistrationCacheFromSessionAsync(Guid sessionId) {
|
||||
var cacheResult = await _letsEncryptService.GetRegistrationCacheAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!cacheResult.IsSuccess || cacheResult.Value == null)
|
||||
return;
|
||||
|
||||
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value);
|
||||
var saveResult = await _registrationCache.SaveAsync(cacheResult.Value.AccountId, cacheResult.Value, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!saveResult.IsSuccess)
|
||||
_logger.LogWarning("Could not persist registration cache after ACME flow step for account {AccountId}.", cacheResult.Value.AccountId);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// PostgreSQL <c>registration_caches</c> row: ACME registration payload as JSON text.
|
||||
/// </summary>
|
||||
public class RegistrationCacheDto {
|
||||
public Guid AccountId { get; set; }
|
||||
public class RegistrationCacheDto : DtoDocumentBase<Guid> {
|
||||
/// <summary>
|
||||
/// Backward-compatible alias for <see cref="DtoDocumentBase{Guid}.Id"/>.
|
||||
/// </summary>
|
||||
public Guid AccountId {
|
||||
get => Id;
|
||||
set => Id = value;
|
||||
}
|
||||
|
||||
public long Version { get; set; }
|
||||
public required string PayloadJson { get; set; }
|
||||
}
|
||||
|
||||
@ -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
|
||||
services.AddScoped<IRegistrationCachePersistanceService, RegistrationCachePersistanceServiceLinq2Db>();
|
||||
services.AddScoped<IRegistrationCacheDomainService, RegistrationCacheDomainService>();
|
||||
services.AddScoped<IAcmeSessionPersistanceService, AcmeSessionPersistanceServiceLinq2Db>();
|
||||
services.AddScoped<IAcmeHttpChallengePersistenceService, AcmeHttpChallengePersistenceServiceLinq2Db>();
|
||||
services.AddScoped<ITermsOfServiceCachePersistenceService, TermsOfServiceCachePersistenceServiceLinq2Db>();
|
||||
services.AddSingleton<IRuntimeLeaseService, RuntimeLeaseServiceNpgsql>();
|
||||
@ -64,7 +66,6 @@ public static class ServiceCollectionExtensions {
|
||||
#endregion
|
||||
|
||||
#region ACME / Let's Encrypt
|
||||
services.AddSingleton<IAcmeSessionStore, AcmePostgresSessionStore>();
|
||||
services.AddHttpClient<ILetsEncryptService, LetsEncryptService>();
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -22,7 +22,6 @@
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="10.0.7" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.4" />
|
||||
<PackageReference Include="Npgsql" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@ -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.Data;
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.CertsUI.Engine.Domain.Identity;
|
||||
using MaksIT.CertsUI.Engine.Dto.Identity;
|
||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||
using MaksIT.CertsUI.Engine.Persistance.Mappers;
|
||||
using MaksIT.CertsUI.Engine.Persistance.Services;
|
||||
using MaksIT.Results;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using LinqToDB;
|
||||
using LinqToDB.Data;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.CertsUI.Engine.Domain.Certs;
|
||||
using MaksIT.CertsUI.Engine.Dto.Certs;
|
||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||
using MaksIT.CertsUI.Engine.Persistance.Services;
|
||||
using MaksIT.Results;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
|
||||
namespace MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db;
|
||||
|
||||
@ -16,49 +16,57 @@ namespace MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db;
|
||||
public sealed class RegistrationCachePersistanceServiceLinq2Db(
|
||||
ILogger<RegistrationCachePersistanceServiceLinq2Db> logger,
|
||||
ICertsDataConnectionFactory connectionFactory
|
||||
) : IRegistrationCachePersistanceService {
|
||||
) : IRegistrationCachePersistanceService
|
||||
{
|
||||
|
||||
private readonly ILogger<RegistrationCachePersistanceServiceLinq2Db> _logger = logger;
|
||||
private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory;
|
||||
|
||||
public Task<Result<RegistrationCache[]?>> LoadAllAsync(CancellationToken cancellationToken = default) {
|
||||
public Task<Result<RegistrationCache[]?>> LoadAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try {
|
||||
try
|
||||
{
|
||||
using var db = _connectionFactory.Create();
|
||||
var rows = db.GetTable<RegistrationCacheDto>().ToList();
|
||||
|
||||
var caches = new List<RegistrationCache>();
|
||||
foreach (var row in rows) {
|
||||
if (string.IsNullOrWhiteSpace(row.PayloadJson)) {
|
||||
_logger.LogWarning("Registration cache row is empty for account {AccountId}", row.AccountId);
|
||||
foreach (var row in rows)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(row.PayloadJson))
|
||||
{
|
||||
_logger.LogWarning("Registration cache row is empty for account {AccountId}", row.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
var cache = row.PayloadJson.ToObject<RegistrationCache>();
|
||||
if (cache == null) {
|
||||
_logger.LogWarning("Could not deserialize registration cache for account {AccountId}", row.AccountId);
|
||||
if (cache == null)
|
||||
{
|
||||
_logger.LogWarning("Could not deserialize registration cache for account {AccountId}", row.Id);
|
||||
continue;
|
||||
}
|
||||
|
||||
cache.AccountId = row.AccountId;
|
||||
cache.ConcurrencyVersion = row.Version;
|
||||
caches.Add(cache);
|
||||
}
|
||||
|
||||
return Task.FromResult(Result<RegistrationCache[]?>.Ok(caches.ToArray()));
|
||||
}
|
||||
catch (Exception ex) {
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error))
|
||||
_logger.LogError(ex, "Error loading all registration caches.");
|
||||
return Task.FromResult(Result<RegistrationCache[]?>.InternalServerError(null, ["An error occurred while loading registration caches.", .. ex.ExtractMessages()]));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Result<RegistrationCache?>> LoadAsync(Guid accountId, CancellationToken cancellationToken = default) {
|
||||
public Task<Result<RegistrationCache?>> LoadAsync(Guid accountId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try {
|
||||
try
|
||||
{
|
||||
using var db = _connectionFactory.Create();
|
||||
var row = db.GetTable<RegistrationCacheDto>().FirstOrDefault(r => r.AccountId == accountId);
|
||||
var row = db.GetTable<RegistrationCacheDto>().FirstOrDefault(r => r.Id == accountId);
|
||||
if (row == null)
|
||||
return Task.FromResult(Result<RegistrationCache?>.NotFound(null, $"Registration cache not found for account {accountId}."));
|
||||
|
||||
@ -69,46 +77,52 @@ public sealed class RegistrationCachePersistanceServiceLinq2Db(
|
||||
if (cache == null)
|
||||
return Task.FromResult(Result<RegistrationCache?>.InternalServerError(null, $"Registration cache payload is invalid for account {accountId}."));
|
||||
|
||||
cache.AccountId = accountId;
|
||||
cache.ConcurrencyVersion = row.Version;
|
||||
return Task.FromResult(Result<RegistrationCache?>.Ok(cache));
|
||||
}
|
||||
catch (Exception ex) {
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error))
|
||||
_logger.LogError(ex, "Error loading registration cache for account {AccountId}", accountId);
|
||||
return Task.FromResult(Result<RegistrationCache?>.InternalServerError(null, ["An error occurred while loading the registration cache.", .. ex.ExtractMessages()]));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Result> SaveAsync(Guid accountId, RegistrationCache cache, CancellationToken cancellationToken = default) {
|
||||
public Task<Result> SaveAsync(Guid accountId, RegistrationCache cache, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
ArgumentNullException.ThrowIfNull(cache);
|
||||
|
||||
try {
|
||||
try
|
||||
{
|
||||
using var db = _connectionFactory.Create();
|
||||
cache.AccountId = accountId;
|
||||
var json = cache.ToJson();
|
||||
var row = db.GetTable<RegistrationCacheDto>().FirstOrDefault(r => r.AccountId == accountId);
|
||||
var row = db.GetTable<RegistrationCacheDto>().FirstOrDefault(r => r.Id == accountId);
|
||||
|
||||
if (row == null) {
|
||||
db.Insert(new RegistrationCacheDto {
|
||||
AccountId = accountId,
|
||||
if (row == null)
|
||||
{
|
||||
db.Insert(new RegistrationCacheDto
|
||||
{
|
||||
Id = accountId,
|
||||
Version = 1,
|
||||
PayloadJson = json
|
||||
});
|
||||
cache.ConcurrencyVersion = 1;
|
||||
}
|
||||
else {
|
||||
else
|
||||
{
|
||||
var expectedVersion = cache.ConcurrencyVersion > 0 ? cache.ConcurrencyVersion : row.Version;
|
||||
var nextVersion = expectedVersion + 1;
|
||||
|
||||
var updated = db.GetTable<RegistrationCacheDto>()
|
||||
.Where(r => r.AccountId == accountId && r.Version == expectedVersion)
|
||||
.Where(r => r.Id == accountId && r.Version == expectedVersion)
|
||||
.Set(r => r.PayloadJson, json)
|
||||
.Set(r => r.Version, nextVersion)
|
||||
.Update();
|
||||
|
||||
if (updated == 0) {
|
||||
if (updated == 0)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Optimistic concurrency conflict for registration cache {AccountId}. Expected version {ExpectedVersion}.",
|
||||
accountId, expectedVersion);
|
||||
@ -120,33 +134,40 @@ public sealed class RegistrationCachePersistanceServiceLinq2Db(
|
||||
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error))
|
||||
_logger.LogError(ex, "Error saving registration cache for account {AccountId}", accountId);
|
||||
return Task.FromResult(Result.InternalServerError(["An error occurred while saving the registration cache.", .. ex.ExtractMessages()]));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Result> DeleteAllAsync(CancellationToken cancellationToken = default) {
|
||||
public Task<Result> DeleteAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try {
|
||||
try
|
||||
{
|
||||
using var db = _connectionFactory.Create();
|
||||
db.Execute("DELETE FROM registration_caches");
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error))
|
||||
_logger.LogError(ex, "Error deleting all registration caches.");
|
||||
return Task.FromResult(Result.InternalServerError(["An error occurred while deleting registration caches.", .. ex.ExtractMessages()]));
|
||||
}
|
||||
}
|
||||
|
||||
public Task<Result> DeleteAsync(Guid accountId, CancellationToken cancellationToken = default) {
|
||||
public Task<Result> DeleteAsync(Guid accountId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
try {
|
||||
try
|
||||
{
|
||||
using var db = _connectionFactory.Create();
|
||||
var deleted = db.GetTable<RegistrationCacheDto>().Where(r => r.AccountId == accountId).Delete();
|
||||
if (deleted == 0) {
|
||||
var deleted = db.GetTable<RegistrationCacheDto>().Where(r => r.Id == accountId).Delete();
|
||||
if (deleted == 0)
|
||||
{
|
||||
_logger.LogWarning("Registration cache not found for account {AccountId}", accountId);
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
@ -154,10 +175,12 @@ public sealed class RegistrationCachePersistanceServiceLinq2Db(
|
||||
_logger.LogInformation("Registration cache deleted for account {AccountId}", accountId);
|
||||
return Task.FromResult(Result.Ok());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (_logger.IsEnabled(LogLevel.Error))
|
||||
_logger.LogError(ex, "Error deleting registration cache for account {AccountId}", accountId);
|
||||
return Task.FromResult(Result.InternalServerError(["An error occurred while deleting the registration cache.", .. ex.ExtractMessages()]));
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
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.Results;
|
||||
|
||||
namespace MaksIT.CertsUI.Engine.QueryServices.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// API key ↔ entity scope search. Certs has no persisted scope graph yet; default implementation returns an empty page.
|
||||
/// API key ↔ entity scope search (Vault <c>IApiKeyEntityScopeQueryService</c> pattern). Default implementation returns empty results until scope rows exist in PostgreSQL.
|
||||
/// </summary>
|
||||
public interface IApiKeyEntityScopeQueryService {
|
||||
Task<Result<PagedQueryResult<ApiKeyEntityScopeQueryResult>>> SearchApiKeyEntityScopesAsync(
|
||||
Guid? apiKeyId,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Result<List<ApiKeyEntityScopeQueryResult>?> Search(
|
||||
Expression<Func<ApiKeyEntityScopeDto, bool>>? predicate,
|
||||
int? skip,
|
||||
int? limit);
|
||||
|
||||
Result<int?> Count(Expression<Func<ApiKeyEntityScopeDto, bool>>? predicate);
|
||||
}
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
using MaksIT.CertsUI.Engine.Query;
|
||||
using System.Linq.Expressions;
|
||||
using MaksIT.CertsUI.Engine.Dto.Identity;
|
||||
using MaksIT.CertsUI.Engine.Query.Identity;
|
||||
using MaksIT.Results;
|
||||
|
||||
namespace MaksIT.CertsUI.Engine.QueryServices.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only paged API key search for list views.
|
||||
/// Read-only API key search (Vault <c>IApiKeyQueryService</c> pattern): optional predicate on <see cref="ApiKeyDto"/>, skip/limit, and count.
|
||||
/// </summary>
|
||||
public interface IApiKeyQueryService {
|
||||
|
||||
Task<Result<PagedQueryResult<ApiKeyQueryResult>>> SearchApiKeysAsync(
|
||||
string? descriptionFilter,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Result<List<ApiKeyQueryResult>?> Search(
|
||||
Expression<Func<ApiKeyDto, bool>>? apiKeysPredicate,
|
||||
int? skip,
|
||||
int? limit);
|
||||
|
||||
Result<int?> Count(Expression<Func<ApiKeyDto, bool>>? apiKeysPredicate);
|
||||
}
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
using MaksIT.CertsUI.Engine.Query;
|
||||
using System.Linq.Expressions;
|
||||
using MaksIT.CertsUI.Engine.Dto.Identity;
|
||||
using MaksIT.CertsUI.Engine.Query.Identity;
|
||||
using MaksIT.Results;
|
||||
|
||||
namespace MaksIT.CertsUI.Engine.QueryServices.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Read-only paged user search for admin/list views.
|
||||
/// Read-only user search (MaksIT.Vault.Engine.QueryServices.Identity.IIdentityQueryService pattern): optional Linq2Db-translatable predicate on <see cref="UserDto"/>, skip/limit, and a separate count.
|
||||
/// Host builds optional <see cref="System.Linq.Expressions.Expression"/> predicates on <see cref="UserDto"/> for filters and RBAC; use <see cref="MaksIT.CertsUI.Engine.QueryServices.ExpressionCompose"/> to compose nested predicates (Vault parity).
|
||||
/// </summary>
|
||||
public interface IUserQueryService {
|
||||
|
||||
Task<Result<PagedQueryResult<UserQueryResult>>> SearchUsersAsync(
|
||||
string? usernameFilter,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default);
|
||||
Result<List<UserQueryResult>?> Search(
|
||||
Expression<Func<UserDto, bool>>? usersPredicate,
|
||||
int? skip,
|
||||
int? limit);
|
||||
|
||||
Result<int?> Count(Expression<Func<UserDto, bool>>? usersPredicate);
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using MaksIT.CertsUI.Engine.Query;
|
||||
using System.Linq.Expressions;
|
||||
using MaksIT.CertsUI.Engine.Dto.Identity;
|
||||
using MaksIT.CertsUI.Engine.Query.Identity;
|
||||
using MaksIT.CertsUI.Engine.QueryServices.Identity;
|
||||
using MaksIT.Results;
|
||||
@ -7,23 +8,27 @@ using Microsoft.Extensions.Logging;
|
||||
namespace MaksIT.CertsUI.Engine.QueryServices.Linq2Db.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Placeholder: Certs UI does not store API key entity scopes in PostgreSQL. Returns an empty page so the API contract matches Vault without failing callers.
|
||||
/// Placeholder: Certs UI does not store API key entity scopes in PostgreSQL. Returns empty results so the API contract matches Vault without failing callers.
|
||||
/// Replace with a Linq2Db implementation when scope rows exist.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyEntityScopeQueryServiceStub(ILogger<ApiKeyEntityScopeQueryServiceStub> logger) : IApiKeyEntityScopeQueryService {
|
||||
|
||||
public Task<Result<PagedQueryResult<ApiKeyEntityScopeQueryResult>>> SearchApiKeyEntityScopesAsync(
|
||||
Guid? apiKeyId,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default) {
|
||||
_ = apiKeyId;
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
public Result<List<ApiKeyEntityScopeQueryResult>?> Search(
|
||||
Expression<Func<ApiKeyEntityScopeDto, bool>>? predicate,
|
||||
int? skip,
|
||||
int? limit) {
|
||||
_ = predicate;
|
||||
_ = skip;
|
||||
_ = limit;
|
||||
if (logger.IsEnabled(LogLevel.Debug))
|
||||
logger.LogDebug("Api key entity scope search is not persisted in Certs; returning empty page.");
|
||||
var page = Math.Max(1, pageNumber);
|
||||
var size = Math.Clamp(pageSize, 1, 500);
|
||||
return Task.FromResult(Result<PagedQueryResult<ApiKeyEntityScopeQueryResult>>.Ok(
|
||||
new PagedQueryResult<ApiKeyEntityScopeQueryResult>([], 0, page, size)));
|
||||
logger.LogDebug("Api key entity scope search is not persisted in Certs; returning empty list.");
|
||||
return Result<List<ApiKeyEntityScopeQueryResult>?>.Ok([]);
|
||||
}
|
||||
|
||||
public Result<int?> Count(Expression<Func<ApiKeyEntityScopeDto, bool>>? predicate) {
|
||||
_ = predicate;
|
||||
if (logger.IsEnabled(LogLevel.Debug))
|
||||
logger.LogDebug("Api key entity scope count is not persisted in Certs; returning 0.");
|
||||
return Result<int?>.Ok(0);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
using System.Linq.Expressions;
|
||||
using LinqToDB;
|
||||
using LinqToDB.Data;
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.CertsUI.Engine.Dto.Identity;
|
||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||
using MaksIT.CertsUI.Engine.Query;
|
||||
using MaksIT.CertsUI.Engine.Query.Identity;
|
||||
using MaksIT.CertsUI.Engine.QueryServices.Identity;
|
||||
using MaksIT.Results;
|
||||
@ -12,49 +11,52 @@ using Microsoft.Extensions.Logging;
|
||||
namespace MaksIT.CertsUI.Engine.QueryServices.Linq2Db.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Linq2Db-based implementation of <see cref="IApiKeyQueryService"/>.
|
||||
/// Linq2Db-based implementation of <see cref="IApiKeyQueryService"/> (Vault-style predicates on <see cref="ApiKeyDto"/>).
|
||||
/// </summary>
|
||||
public class ApiKeyQueryServiceLinq2Db(ILogger<ApiKeyQueryServiceLinq2Db> logger, ICertsDataConnectionFactory connectionFactory) : IApiKeyQueryService {
|
||||
private readonly ILogger<ApiKeyQueryServiceLinq2Db> _logger = logger;
|
||||
private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory;
|
||||
|
||||
public Task<Result<PagedQueryResult<ApiKeyQueryResult>>> SearchApiKeysAsync(
|
||||
string? descriptionFilter,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default) {
|
||||
_ = cancellationToken;
|
||||
public Result<List<ApiKeyQueryResult>?> Search(
|
||||
Expression<Func<ApiKeyDto, bool>>? apiKeysPredicate,
|
||||
int? skip,
|
||||
int? limit) {
|
||||
try {
|
||||
var page = Math.Max(1, pageNumber);
|
||||
var size = Math.Clamp(pageSize, 1, 500);
|
||||
var skip = (page - 1) * size;
|
||||
var filter = descriptionFilter?.Trim();
|
||||
|
||||
using var db = _connectionFactory.Create();
|
||||
var table = db.GetTable<ApiKeyDto>();
|
||||
var filtered = string.IsNullOrWhiteSpace(filter)
|
||||
? table
|
||||
: table.Where(k => (k.Description ?? string.Empty).Contains(filter));
|
||||
var query = db.GetTable<ApiKeyDto>().AsQueryable();
|
||||
if (apiKeysPredicate != null)
|
||||
query = query.Where(apiKeysPredicate);
|
||||
|
||||
var total = filtered.Count();
|
||||
var rows = filtered
|
||||
.OrderByDescending(k => k.CreatedAtUtc)
|
||||
.Skip(skip)
|
||||
.Take(size)
|
||||
.ToList();
|
||||
query = query.OrderByDescending(k => k.CreatedAtUtc);
|
||||
|
||||
var data = rows.Select(MapToQueryResult).ToList();
|
||||
if (skip.HasValue)
|
||||
query = query.Skip(skip.Value);
|
||||
|
||||
return Task.FromResult(Result<PagedQueryResult<ApiKeyQueryResult>>.Ok(new PagedQueryResult<ApiKeyQueryResult>(
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
size
|
||||
)));
|
||||
if (limit.HasValue)
|
||||
query = query.Take(limit.Value);
|
||||
|
||||
var rows = query.ToList();
|
||||
var results = rows.Select(MapToQueryResult).ToList();
|
||||
return Result<List<ApiKeyQueryResult>?>.Ok(results);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, "Error occurred while searching API keys.");
|
||||
return Task.FromResult(Result<PagedQueryResult<ApiKeyQueryResult>>.InternalServerError(null, [.. ex.ExtractMessages()]));
|
||||
return Result<List<ApiKeyQueryResult>?>.InternalServerError(null, [.. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
public Result<int?> Count(Expression<Func<ApiKeyDto, bool>>? apiKeysPredicate) {
|
||||
try {
|
||||
using var db = _connectionFactory.Create();
|
||||
var query = db.GetTable<ApiKeyDto>().AsQueryable();
|
||||
if (apiKeysPredicate != null)
|
||||
query = query.Where(apiKeysPredicate);
|
||||
|
||||
return Result<int?>.Ok(query.Count());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, "Error occurred while counting API keys.");
|
||||
return Result<int?>.InternalServerError(null, [.. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,9 +1,8 @@
|
||||
using System.Linq.Expressions;
|
||||
using LinqToDB;
|
||||
using LinqToDB.Data;
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.CertsUI.Engine.Dto.Identity;
|
||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||
using MaksIT.CertsUI.Engine.Query;
|
||||
using MaksIT.CertsUI.Engine.Query.Identity;
|
||||
using MaksIT.CertsUI.Engine.QueryServices.Identity;
|
||||
using MaksIT.Results;
|
||||
@ -12,36 +11,31 @@ using Microsoft.Extensions.Logging;
|
||||
namespace MaksIT.CertsUI.Engine.QueryServices.Linq2Db.Identity;
|
||||
|
||||
/// <summary>
|
||||
/// Linq2Db-based implementation of <see cref="IUserQueryService"/>.
|
||||
/// Linq2Db-based implementation of <see cref="IUserQueryService"/> (Vault-style predicates on <see cref="UserDto"/>).
|
||||
/// </summary>
|
||||
public class UserQueryServiceLinq2Db(ILogger<UserQueryServiceLinq2Db> logger, ICertsDataConnectionFactory connectionFactory) : IUserQueryService {
|
||||
private readonly ILogger<UserQueryServiceLinq2Db> _logger = logger;
|
||||
private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory;
|
||||
|
||||
public Task<Result<PagedQueryResult<UserQueryResult>>> SearchUsersAsync(
|
||||
string? usernameFilter,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default) {
|
||||
_ = cancellationToken;
|
||||
public Result<List<UserQueryResult>?> Search(
|
||||
Expression<Func<UserDto, bool>>? usersPredicate,
|
||||
int? skip,
|
||||
int? limit) {
|
||||
try {
|
||||
var page = Math.Max(1, pageNumber);
|
||||
var size = Math.Clamp(pageSize, 1, 500);
|
||||
var skip = (page - 1) * size;
|
||||
var filter = usernameFilter?.Trim();
|
||||
|
||||
using var db = _connectionFactory.Create();
|
||||
var table = db.GetTable<UserDto>();
|
||||
var filtered = string.IsNullOrWhiteSpace(filter)
|
||||
? table
|
||||
: table.Where(u => u.Name.Contains(filter!));
|
||||
var query = db.GetTable<UserDto>().AsQueryable();
|
||||
if (usersPredicate != null)
|
||||
query = query.Where(usersPredicate);
|
||||
|
||||
var total = filtered.Count();
|
||||
var rows = filtered
|
||||
.OrderBy(u => u.Name)
|
||||
.Skip(skip)
|
||||
.Take(size)
|
||||
.ToList();
|
||||
query = query.OrderBy(u => u.Name);
|
||||
|
||||
if (skip.HasValue)
|
||||
query = query.Skip(skip.Value);
|
||||
|
||||
if (limit.HasValue)
|
||||
query = query.Take(limit.Value);
|
||||
|
||||
var rows = query.ToList();
|
||||
|
||||
var userIds = rows.Select(r => r.Id).ToList();
|
||||
var allRc = userIds.Count == 0
|
||||
@ -51,18 +45,27 @@ public class UserQueryServiceLinq2Db(ILogger<UserQueryServiceLinq2Db> logger, IC
|
||||
.GroupBy(t => t.UserId)
|
||||
.ToDictionary(g => g.Key, g => g.Count());
|
||||
|
||||
var data = rows.Select(r => MapToQueryResult(r, recoveryCountByUser.GetValueOrDefault(r.Id))).ToList();
|
||||
|
||||
return Task.FromResult(Result<PagedQueryResult<UserQueryResult>>.Ok(new PagedQueryResult<UserQueryResult>(
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
size
|
||||
)));
|
||||
var results = rows.Select(r => MapToQueryResult(r, recoveryCountByUser.GetValueOrDefault(r.Id))).ToList();
|
||||
return Result<List<UserQueryResult>?>.Ok(results);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, "Error occurred while searching users.");
|
||||
return Task.FromResult(Result<PagedQueryResult<UserQueryResult>>.InternalServerError(null, [.. ex.ExtractMessages()]));
|
||||
return Result<List<UserQueryResult>?>.InternalServerError(null, [.. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
public Result<int?> Count(Expression<Func<UserDto, bool>>? usersPredicate) {
|
||||
try {
|
||||
using var db = _connectionFactory.Create();
|
||||
var query = db.GetTable<UserDto>().AsQueryable();
|
||||
if (usersPredicate != null)
|
||||
query = query.Where(usersPredicate);
|
||||
|
||||
return Result<int?>.Ok(query.Count());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
_logger.LogError(ex, "Error occurred while counting users.");
|
||||
return Result<int?>.InternalServerError(null, [.. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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 Microsoft.Extensions.Logging;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.Core.Security.JWS;
|
||||
using MaksIT.CertsUI.Engine.Domain.Certs;
|
||||
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
|
||||
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt.Jws;
|
||||
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Interfaces;
|
||||
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
|
||||
using MaksIT.Results;
|
||||
|
||||
|
||||
namespace MaksIT.CertsUI.Engine.Services;
|
||||
@ -16,16 +15,39 @@ public partial class LetsEncryptService {
|
||||
|
||||
#region Internal helpers
|
||||
|
||||
private async Task<State> LoadAcmeSessionStateAsync(Guid sessionId, CancellationToken cancellationToken) {
|
||||
var result = await _acmeSessionPersistence.LoadAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsSuccess) {
|
||||
_logger.LogWarning(
|
||||
"ACME session load failed for {SessionId}: {Messages}",
|
||||
sessionId,
|
||||
result.Messages != null ? string.Join("; ", result.Messages) : "(no detail)");
|
||||
return new State();
|
||||
}
|
||||
|
||||
return result.Value ?? new State();
|
||||
}
|
||||
|
||||
private async Task PersistAcmeSessionStateAsync(Guid sessionId, State state, CancellationToken cancellationToken) {
|
||||
var result = await _acmeSessionPersistence.SaveAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
|
||||
if (!result.IsSuccess) {
|
||||
_logger.LogError(
|
||||
"ACME session save failed for {SessionId}: {Messages}",
|
||||
sessionId,
|
||||
result.Messages != null ? string.Join("; ", result.Messages) : "(no detail)");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Result> WithPersistedSessionAsync(
|
||||
Guid sessionId,
|
||||
CancellationToken cancellationToken,
|
||||
Func<State, Task<Result>> body) {
|
||||
var state = await _sessionStore.LoadOrCreateAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
var state = await LoadAcmeSessionStateAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
try {
|
||||
return await body(state).ConfigureAwait(false);
|
||||
}
|
||||
finally {
|
||||
await _sessionStore.PersistAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
|
||||
await PersistAcmeSessionStateAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -33,12 +55,12 @@ public partial class LetsEncryptService {
|
||||
Guid sessionId,
|
||||
CancellationToken cancellationToken,
|
||||
Func<State, Task<Result<T?>>> body) {
|
||||
var state = await _sessionStore.LoadOrCreateAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
var state = await LoadAcmeSessionStateAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
try {
|
||||
return await body(state).ConfigureAwait(false);
|
||||
}
|
||||
finally {
|
||||
await _sessionStore.PersistAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
|
||||
await PersistAcmeSessionStateAsync(sessionId, state, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3,6 +3,12 @@
|
||||
* https://datatracker.ietf.org/doc/html/draft-ietf-acme-acme-12
|
||||
*/
|
||||
|
||||
using System.Text;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.Core.Security;
|
||||
using MaksIT.Core.Security.JWK;
|
||||
@ -10,16 +16,9 @@ using MaksIT.Core.Security.JWS;
|
||||
using MaksIT.CertsUI.Engine.Domain.Certs;
|
||||
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt;
|
||||
using MaksIT.CertsUI.Engine.Domain.LetsEncrypt.Jws;
|
||||
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Interfaces;
|
||||
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Requests;
|
||||
using MaksIT.CertsUI.Engine.Dto.LetsEncrypt.Responses;
|
||||
using MaksIT.Results;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading;
|
||||
using System.Security.Cryptography;
|
||||
using System.Security.Cryptography.X509Certificates;
|
||||
using System.Text;
|
||||
using MaksIT.CertsUI.Engine.Persistance.Services;
|
||||
|
||||
|
||||
namespace MaksIT.CertsUI.Engine.Services;
|
||||
@ -45,18 +44,18 @@ public partial class LetsEncryptService : ILetsEncryptService {
|
||||
private readonly ILogger<LetsEncryptService> _logger;
|
||||
private readonly ICertsEngineConfiguration _engineConfiguration;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly IAcmeSessionStore _sessionStore;
|
||||
private readonly IAcmeSessionPersistanceService _acmeSessionPersistence;
|
||||
|
||||
public LetsEncryptService(
|
||||
ILogger<LetsEncryptService> logger,
|
||||
ICertsEngineConfiguration engineConfiguration,
|
||||
HttpClient httpClient,
|
||||
IAcmeSessionStore sessionStore
|
||||
IAcmeSessionPersistanceService acmeSessionPersistence
|
||||
) {
|
||||
_logger = logger;
|
||||
_engineConfiguration = engineConfiguration;
|
||||
_httpClient = httpClient;
|
||||
_sessionStore = sessionStore;
|
||||
_acmeSessionPersistence = acmeSessionPersistence;
|
||||
}
|
||||
|
||||
public Task<Result<RegistrationCache?>> GetRegistrationCacheAsync(Guid sessionId, CancellationToken cancellationToken = default) =>
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using MaksIT.CertsUI.Engine.Domain.Identity;
|
||||
using MaksIT.CertsUI.Engine.Dto.Identity;
|
||||
using MaksIT.CertsUI.Engine.Persistance.Services;
|
||||
using MaksIT.CertsUI.Engine.Query;
|
||||
using MaksIT.CertsUI.Engine.Query.Identity;
|
||||
using MaksIT.CertsUI.Engine.QueryServices.Identity;
|
||||
using MaksIT.Results;
|
||||
@ -25,36 +26,60 @@ public sealed class InMemoryUserStore : IIdentityPersistanceService, IUserQueryS
|
||||
return Task.FromResult(Result<List<User>>.Ok([.. _users]));
|
||||
}
|
||||
|
||||
public Task<Result<PagedQueryResult<UserQueryResult>>> SearchUsersAsync(
|
||||
string? usernameFilter,
|
||||
int pageNumber,
|
||||
int pageSize,
|
||||
CancellationToken cancellationToken = default) {
|
||||
public Result<int?> Count(Expression<Func<UserDto, bool>>? usersPredicate) {
|
||||
lock (_lock) {
|
||||
IEnumerable<User> filtered = _users;
|
||||
if (!string.IsNullOrWhiteSpace(usernameFilter))
|
||||
filtered = filtered.Where(u => u.Username.Contains(usernameFilter, StringComparison.OrdinalIgnoreCase));
|
||||
var ordered = filtered.OrderBy(u => u.Username).ToList();
|
||||
var page = Math.Max(1, pageNumber);
|
||||
var size = Math.Clamp(pageSize, 1, 500);
|
||||
var total = ordered.Count;
|
||||
var slice = ordered.Skip((page - 1) * size).Take(size).ToList();
|
||||
var data = slice.Select(u => new UserQueryResult {
|
||||
Id = u.Id,
|
||||
Username = u.Username,
|
||||
IsActive = u.IsActive,
|
||||
TwoFactorEnabled = u.TwoFactorEnabled,
|
||||
LastLogin = u.LastLogin,
|
||||
}).ToList();
|
||||
return Task.FromResult(Result<PagedQueryResult<UserQueryResult>>.Ok(new PagedQueryResult<UserQueryResult>(
|
||||
data,
|
||||
total,
|
||||
page,
|
||||
size
|
||||
)));
|
||||
var q = _users.Select(ToUserDto).AsQueryable();
|
||||
if (usersPredicate != null)
|
||||
q = q.Where(usersPredicate);
|
||||
return Result<int?>.Ok(q.Count());
|
||||
}
|
||||
}
|
||||
|
||||
public Result<List<UserQueryResult>?> Search(
|
||||
Expression<Func<UserDto, bool>>? usersPredicate,
|
||||
int? skip,
|
||||
int? limit) {
|
||||
lock (_lock) {
|
||||
var q = _users.Select(ToUserDto).AsQueryable();
|
||||
if (usersPredicate != null)
|
||||
q = q.Where(usersPredicate);
|
||||
q = q.OrderBy(x => x.Name);
|
||||
if (skip.HasValue)
|
||||
q = q.Skip(skip.Value);
|
||||
if (limit.HasValue)
|
||||
q = q.Take(limit.Value);
|
||||
var dtos = q.ToList();
|
||||
var results = dtos.Select(d => MapToQueryResult(d, d.TwoFactorRecoveryCodes.Count)).ToList();
|
||||
return Result<List<UserQueryResult>?>.Ok(results);
|
||||
}
|
||||
}
|
||||
|
||||
private static UserDto ToUserDto(User u) => new() {
|
||||
Id = u.Id,
|
||||
Name = u.Username,
|
||||
Salt = u.PasswordSalt,
|
||||
Hash = u.PasswordHash,
|
||||
LastLoginUtc = u.LastLogin ?? default,
|
||||
IsActive = u.IsActive,
|
||||
TwoFactorSharedKey = u.TwoFactorSharedKey,
|
||||
JwtTokens = [],
|
||||
TwoFactorRecoveryCodes = [.. u.TwoFactorRecoveryCodes.Select(rc => new TwoFactorRecoveryCodeDto {
|
||||
Id = rc.Id,
|
||||
UserId = u.Id,
|
||||
Salt = rc.Salt,
|
||||
Hash = rc.Hash,
|
||||
IsUsed = rc.IsUsed
|
||||
})]
|
||||
};
|
||||
|
||||
private static UserQueryResult MapToQueryResult(UserDto row, int recoveryCount) => new() {
|
||||
Id = row.Id,
|
||||
Username = row.Name,
|
||||
IsActive = row.IsActive,
|
||||
TwoFactorEnabled = row.TwoFactorSharedKey != null && recoveryCount > 0,
|
||||
LastLogin = row.LastLoginUtc == default ? null : row.LastLoginUtc,
|
||||
};
|
||||
|
||||
public Task<Result<User?>> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) {
|
||||
lock (_lock) {
|
||||
var u = _users.FirstOrDefault(x => x.Id == id);
|
||||
|
||||
@ -5,6 +5,7 @@ using MaksIT.Results;
|
||||
using MaksIT.CertsUI.Mappers;
|
||||
using MaksIT.CertsUI.Services;
|
||||
using LinqToDB.Data;
|
||||
using MaksIT.CertsUI.Engine.DomainServices;
|
||||
using MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db;
|
||||
using MaksIT.CertsUI.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@ -35,7 +36,8 @@ public class AccountServicePatchAccountIntegrationTests(PostgresCacheFixture pg)
|
||||
IsDisabled = false
|
||||
};
|
||||
var cachePersistence = new RegistrationCachePersistanceServiceLinq2Db(NullLogger<RegistrationCachePersistanceServiceLinq2Db>.Instance, pg.ConnectionFactory);
|
||||
var cacheSvc = new CacheService(NullLogger<CacheService>.Instance, fx.AppOptions, cachePersistence);
|
||||
var cacheDomain = new RegistrationCacheDomainService(NullLogger<RegistrationCacheDomainService>.Instance, cachePersistence);
|
||||
var cacheSvc = new CacheService(NullLogger<CacheService>.Instance, fx.AppOptions, cacheDomain);
|
||||
await cacheSvc.SaveToCacheAsync(accountId, reg);
|
||||
|
||||
var cacheMock = new Mock<ICacheService>();
|
||||
|
||||
@ -45,10 +45,10 @@ public class ApiKeyQueryServiceIntegrationTests(PostgresCacheFixture pg) {
|
||||
Assert.False(string.IsNullOrWhiteSpace(created.Value!.ApiKey));
|
||||
Assert.Contains('|', created.Value.ApiKey);
|
||||
|
||||
var search = await queryService.SearchApiKeysAsync(null, 1, 50);
|
||||
var search = queryService.Search(apiKeysPredicate: null, skip: 0, limit: 50);
|
||||
|
||||
Assert.True(search.IsSuccess);
|
||||
Assert.NotNull(search.Value);
|
||||
Assert.Contains(search.Value!.Data, x => x.Id == created.Value!.Id);
|
||||
Assert.Contains(search.Value!, x => x.Id == created.Value!.Id);
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ using LinqToDB;
|
||||
using LinqToDB.Data;
|
||||
using MaksIT.CertsUI.Engine.Dto.Certs;
|
||||
using MaksIT.CertsUI.Engine.Domain.Certs;
|
||||
using MaksIT.CertsUI.Engine.DomainServices;
|
||||
using MaksIT.CertsUI.Engine.Persistance.Services.Linq2Db;
|
||||
using MaksIT.CertsUI.Services;
|
||||
using MaksIT.CertsUI.Tests.Infrastructure;
|
||||
@ -13,12 +14,11 @@ namespace MaksIT.CertsUI.Tests.Services;
|
||||
[Collection("postgres-cache")]
|
||||
public class CacheServiceTests(PostgresCacheFixture pg) {
|
||||
|
||||
private CacheService CreateSut() =>
|
||||
new(
|
||||
NullLogger<CacheService>.Instance,
|
||||
pg.Config.AppOptions,
|
||||
new RegistrationCachePersistanceServiceLinq2Db(NullLogger<RegistrationCachePersistanceServiceLinq2Db>.Instance, pg.ConnectionFactory)
|
||||
);
|
||||
private CacheService CreateSut() {
|
||||
var persistence = new RegistrationCachePersistanceServiceLinq2Db(NullLogger<RegistrationCachePersistanceServiceLinq2Db>.Instance, pg.ConnectionFactory);
|
||||
var domain = new RegistrationCacheDomainService(NullLogger<RegistrationCacheDomainService>.Instance, persistence);
|
||||
return new CacheService(NullLogger<CacheService>.Instance, pg.Config.AppOptions, domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAccountsFromCacheAsync_WhenNoRows_ReturnsEmptyArray() {
|
||||
@ -77,8 +77,8 @@ public class CacheServiceTests(PostgresCacheFixture pg) {
|
||||
public async Task LoadAccountFromCacheAsync_WhenPayloadEmpty_ReturnsError() {
|
||||
await ClearCachesAsync();
|
||||
var id = Guid.NewGuid();
|
||||
using (var db = (DataConnection)pg.ConnectionFactory.Create()) {
|
||||
db.Insert(new RegistrationCacheDto { AccountId = id, PayloadJson = "" });
|
||||
using (var db = pg.ConnectionFactory.Create()) {
|
||||
db.Insert(new RegistrationCacheDto { Id = id, AccountId = id, PayloadJson = "" });
|
||||
}
|
||||
|
||||
var sut = CreateSut();
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using MaksIT.CertsUI.Engine.Domain.Certs;
|
||||
using MaksIT.CertsUI.Engine.DomainServices;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using MaksIT.CertsUI.Engine.Dto.Certs;
|
||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||
using MaksIT.CertsUI.Engine.Persistance.Services;
|
||||
@ -8,7 +9,6 @@ using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
||||
using MaksIT.CertsUI.Engine.Services;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.CertsUI.Tests.Infrastructure;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
@ -32,6 +32,9 @@ public sealed class CertsFlowServiceTests
|
||||
HttpMessageHandler? httpHandler = null)
|
||||
{
|
||||
registrationCache ??= new Mock<IRegistrationCachePersistanceService>();
|
||||
var registrationDomain = new RegistrationCacheDomainService(
|
||||
NullLogger<RegistrationCacheDomainService>.Instance,
|
||||
registrationCache.Object);
|
||||
agent ??= new Mock<IAgentDeploymentService>();
|
||||
var tosCacheProvided = termsOfServiceCache is not null;
|
||||
termsOfServiceCache ??= new Mock<ITermsOfServiceCachePersistenceService>();
|
||||
@ -69,7 +72,7 @@ public sealed class CertsFlowServiceTests
|
||||
NullLogger<CertsFlowDomainService>.Instance,
|
||||
httpClient,
|
||||
le.Object,
|
||||
registrationCache.Object,
|
||||
registrationDomain,
|
||||
agent.Object,
|
||||
new TestCertsFlowEngineConfiguration(fx),
|
||||
termsOfServiceCache.Object,
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
using MaksIT.CertsUI.Engine.Domain.Certs;
|
||||
using MaksIT.CertsUI.Engine.DomainServices;
|
||||
using MaksIT.CertsUI.Engine.Infrastructure;
|
||||
using MaksIT.CertsUI.Engine.Persistance.Services;
|
||||
using MaksIT.CertsUI.Engine.RuntimeCoordination;
|
||||
using MaksIT.CertsUI.Services;
|
||||
|
||||
@ -38,15 +38,15 @@ public sealed class AutoRenewal(
|
||||
logger.LogInformation("Running certificate renewal sweep (lease holder {Holder}).", holder);
|
||||
|
||||
using var scope = scopeFactory.CreateScope();
|
||||
var cacheService = scope.ServiceProvider.GetRequiredService<ICacheService>();
|
||||
var registrationCacheDomain = scope.ServiceProvider.GetRequiredService<IRegistrationCacheDomainService>();
|
||||
var certsFlowDomain = scope.ServiceProvider.GetRequiredService<ICertsFlowDomainService>();
|
||||
var certsFlowService = scope.ServiceProvider.GetRequiredService<ICertsFlowService>();
|
||||
var httpChallenges = scope.ServiceProvider.GetRequiredService<IAcmeHttpChallengePersistenceService>();
|
||||
|
||||
var purge = await httpChallenges.DeleteOlderThanAsync(TimeSpan.FromDays(10), stoppingToken).ConfigureAwait(false);
|
||||
var purge = await certsFlowDomain.PurgeStaleHttpChallengesAsync(TimeSpan.FromDays(10), stoppingToken).ConfigureAwait(false);
|
||||
if (purge.IsSuccess && purge.Value > 0)
|
||||
logger.LogInformation("Purged {Count} HTTP-01 challenge row(s) older than 10 days.", purge.Value);
|
||||
|
||||
var loadAccountsFromCacheResult = await cacheService.LoadAccountsFromCacheAsync().ConfigureAwait(false);
|
||||
var loadAccountsFromCacheResult = await registrationCacheDomain.LoadAllAsync(stoppingToken).ConfigureAwait(false);
|
||||
if (!loadAccountsFromCacheResult.IsSuccess || loadAccountsFromCacheResult.Value == null) {
|
||||
LogErrorMessages(loadAccountsFromCacheResult.Messages);
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken).ConfigureAwait(false);
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<Version>3.4.0</Version>
|
||||
<Version>3.4.1</Version>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
<DockerComposeProjectPath>..\docker-compose.dcproj</DockerComposeProjectPath>
|
||||
|
||||
@ -98,7 +98,7 @@ public class AccountService(
|
||||
if (requestData.Description == null)
|
||||
return PatchFieldIsNotDefined<GetAccountResponse?>(nameof(requestData.Description));
|
||||
|
||||
cache.Description = requestData.Description;
|
||||
cache.Description = requestData.Description;
|
||||
break;
|
||||
default:
|
||||
return UnsupportedPatchOperationResponse<GetAccountResponse?>();
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
using System.Linq.Expressions;
|
||||
using MaksIT.CertsUI.Engine.DomainServices;
|
||||
using MaksIT.CertsUI.Engine.Dto.Identity;
|
||||
using MaksIT.CertsUI.Engine.QueryServices.Identity;
|
||||
using MaksIT.Models.LetsEncryptServer.ApiKeys;
|
||||
using MaksIT.Models.LetsEncryptServer.ApiKeys.Search;
|
||||
@ -75,16 +77,27 @@ public sealed class ApiKeyService(
|
||||
_ = _jwtTokenData;
|
||||
var page = Math.Max(1, requestData.PageNumber);
|
||||
var size = Math.Clamp(requestData.PageSize, 1, 500);
|
||||
var query = await apiKeyQueryService.SearchApiKeysAsync(requestData.DescriptionFilter?.Trim(), page, size, cancellationToken);
|
||||
if (!query.IsSuccess || query.Value == null)
|
||||
return query.ToResultOfType<PagedResponse<SearchAPIKeyResponse>?>(_ => null);
|
||||
var filter = requestData.DescriptionFilter?.Trim();
|
||||
Expression<Func<ApiKeyDto, bool>>? predicate = string.IsNullOrWhiteSpace(filter)
|
||||
? null
|
||||
: k => (k.Description ?? string.Empty).Contains(filter!);
|
||||
|
||||
var paged = query.Value;
|
||||
var skip = (page - 1) * size;
|
||||
var countResult = apiKeyQueryService.Count(predicate);
|
||||
if (!countResult.IsSuccess)
|
||||
return countResult.ToResultOfType<PagedResponse<SearchAPIKeyResponse>?>(_ => null);
|
||||
|
||||
var searchResult = apiKeyQueryService.Search(predicate, skip, size);
|
||||
if (!searchResult.IsSuccess || searchResult.Value == null)
|
||||
return searchResult.ToResultOfType<PagedResponse<SearchAPIKeyResponse>?>(_ => null);
|
||||
|
||||
var total = countResult.Value ?? 0;
|
||||
var list = searchResult.Value;
|
||||
return Result<PagedResponse<SearchAPIKeyResponse>?>.Ok(new PagedResponse<SearchAPIKeyResponse> {
|
||||
Data = [.. paged.Data.Select(apiKeyToResponseMapper.MapToSearchResponse)],
|
||||
TotalRecords = paged.TotalRecords,
|
||||
PageNumber = paged.PageNumber,
|
||||
PageSize = paged.PageSize,
|
||||
Data = [.. list.Select(apiKeyToResponseMapper.MapToSearchResponse)],
|
||||
TotalRecords = total,
|
||||
PageNumber = page,
|
||||
PageSize = size,
|
||||
});
|
||||
}
|
||||
|
||||
@ -96,16 +109,26 @@ public sealed class ApiKeyService(
|
||||
_ = _jwtTokenData;
|
||||
var page = Math.Max(1, requestData.PageNumber);
|
||||
var size = Math.Clamp(requestData.PageSize, 1, 500);
|
||||
var query = await apiKeyEntityScopeQueryService.SearchApiKeyEntityScopesAsync(requestData.ApiKeyId, page, size, cancellationToken);
|
||||
if (!query.IsSuccess || query.Value == null)
|
||||
return query.ToResultOfType<PagedResponse<SearchApiKeyEntityScopeResponse>?>(_ => null);
|
||||
Expression<Func<ApiKeyEntityScopeDto, bool>>? predicate = requestData.ApiKeyId.HasValue
|
||||
? s => s.ApiKeyId == requestData.ApiKeyId!.Value
|
||||
: null;
|
||||
|
||||
var paged = query.Value;
|
||||
var skip = (page - 1) * size;
|
||||
var countResult = apiKeyEntityScopeQueryService.Count(predicate);
|
||||
if (!countResult.IsSuccess)
|
||||
return countResult.ToResultOfType<PagedResponse<SearchApiKeyEntityScopeResponse>?>(_ => null);
|
||||
|
||||
var searchResult = apiKeyEntityScopeQueryService.Search(predicate, skip, size);
|
||||
if (!searchResult.IsSuccess || searchResult.Value == null)
|
||||
return searchResult.ToResultOfType<PagedResponse<SearchApiKeyEntityScopeResponse>?>(_ => null);
|
||||
|
||||
var total = countResult.Value ?? 0;
|
||||
var list = searchResult.Value;
|
||||
return Result<PagedResponse<SearchApiKeyEntityScopeResponse>?>.Ok(new PagedResponse<SearchApiKeyEntityScopeResponse> {
|
||||
Data = [.. paged.Data.Select(apiKeyToResponseMapper.MapToSearchResponse)],
|
||||
TotalRecords = paged.TotalRecords,
|
||||
PageNumber = paged.PageNumber,
|
||||
PageSize = paged.PageSize,
|
||||
Data = [.. list.Select(apiKeyToResponseMapper.MapToSearchResponse)],
|
||||
TotalRecords = total,
|
||||
PageNumber = page,
|
||||
PageSize = size,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
using System.IO.Compression;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MaksIT.Core.Extensions;
|
||||
using MaksIT.CertsUI.Engine.Domain.Certs;
|
||||
using MaksIT.CertsUI.Engine.Persistance.Services;
|
||||
using MaksIT.CertsUI.Engine.DomainServices;
|
||||
using MaksIT.Results;
|
||||
using MaksIT.CertsUI.Abstractions.Services;
|
||||
|
||||
@ -20,136 +19,40 @@ public interface ICacheService {
|
||||
Task<Result> DeleteAccountCacheAsync(Guid accountId);
|
||||
}
|
||||
|
||||
/// <summary>Web API façade for registration cache operations; delegates to <see cref="IRegistrationCacheDomainService"/>.</summary>
|
||||
public class CacheService(
|
||||
ILogger<CacheService> logger,
|
||||
IOptions<Configuration> appSettings,
|
||||
IRegistrationCachePersistanceService registrationCachePersistence
|
||||
IRegistrationCacheDomainService registrationCacheDomain
|
||||
) : ServiceBase(
|
||||
logger,
|
||||
appSettings
|
||||
), ICacheService {
|
||||
|
||||
public Task<Result<RegistrationCache[]?>> LoadAccountsFromCacheAsync() {
|
||||
return registrationCachePersistence.LoadAllAsync();
|
||||
}
|
||||
public Task<Result<RegistrationCache[]?>> LoadAccountsFromCacheAsync() =>
|
||||
registrationCacheDomain.LoadAllAsync();
|
||||
|
||||
public Task<Result<RegistrationCache?>> LoadAccountFromCacheAsync(Guid accountId) =>
|
||||
registrationCachePersistence.LoadAsync(accountId);
|
||||
registrationCacheDomain.LoadAsync(accountId);
|
||||
|
||||
public Task<Result> SaveToCacheAsync(Guid accountId, RegistrationCache cache) =>
|
||||
registrationCachePersistence.SaveAsync(accountId, cache);
|
||||
registrationCacheDomain.SaveAsync(accountId, cache);
|
||||
|
||||
public async Task<Result<byte[]>> DownloadCacheZipAsync() {
|
||||
try {
|
||||
var allResult = await registrationCachePersistence.LoadAllAsync();
|
||||
if (!allResult.IsSuccess || allResult.Value == null)
|
||||
return Result<byte[]>.InternalServerError(null, allResult.Messages?.ToArray() ?? ["Could not load registration caches."]);
|
||||
public Task<Result<byte[]>> DownloadCacheZipAsync() =>
|
||||
registrationCacheDomain.DownloadCacheZipAsync();
|
||||
|
||||
var rows = allResult.Value;
|
||||
using var ms = new MemoryStream();
|
||||
if (rows.Length == 0) {
|
||||
using (new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) { }
|
||||
return Result<byte[]>.Ok(ms.ToArray());
|
||||
}
|
||||
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) {
|
||||
foreach (var row in rows) {
|
||||
var entry = zip.CreateEntry($"{row.AccountId}.json");
|
||||
using var entryStream = entry.Open();
|
||||
using var writer = new StreamWriter(entryStream);
|
||||
writer.Write(row.ToJson());
|
||||
}
|
||||
}
|
||||
var zipBytes = ms.ToArray();
|
||||
logger.LogInformation("Exported {Count} registration caches to zip.", rows.Length);
|
||||
return Result<byte[]>.Ok(zipBytes);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
var message = "Error creating registration cache zip.";
|
||||
logger.LogError(ex, message);
|
||||
return Result<byte[]>.InternalServerError(null, [message, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
public Task<Result<byte[]?>> DownloadAccountCacheZipAsync(Guid accountId) =>
|
||||
registrationCacheDomain.DownloadAccountCacheZipAsync(accountId);
|
||||
|
||||
public async Task<Result<byte[]?>> DownloadAccountCacheZipAsync(Guid accountId) {
|
||||
try {
|
||||
var readResult = await registrationCachePersistence.LoadAsync(accountId);
|
||||
if (!readResult.IsSuccess || readResult.Value == null) {
|
||||
var message = $"Registration cache not found for account {accountId}.";
|
||||
logger.LogWarning(message);
|
||||
return Result<byte[]?>.NotFound(null, message);
|
||||
}
|
||||
var row = readResult.Value;
|
||||
using var ms = new MemoryStream();
|
||||
using (var zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true)) {
|
||||
var entry = zip.CreateEntry($"{accountId}.json");
|
||||
using var entryStream = entry.Open();
|
||||
using var writer = new StreamWriter(entryStream);
|
||||
writer.Write(row.ToJson());
|
||||
}
|
||||
var zipBytes = ms.ToArray();
|
||||
logger.LogInformation("Account registration cache zipped for {AccountId}", accountId);
|
||||
return Result<byte[]?>.Ok(zipBytes);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
var message = "Error creating account registration cache zip.";
|
||||
logger.LogError(ex, message);
|
||||
return Result<byte[]?>.InternalServerError(null, [message, .. ex.ExtractMessages()]);
|
||||
}
|
||||
}
|
||||
public Task<Result> UploadCacheZipAsync(byte[] zipBytes) =>
|
||||
registrationCacheDomain.UploadCacheZipAsync(zipBytes);
|
||||
|
||||
public Task<Result> UploadCacheZipAsync(byte[] zipBytes) {
|
||||
try {
|
||||
using var ms = new MemoryStream(zipBytes);
|
||||
using var zip = new ZipArchive(ms, ZipArchiveMode.Read);
|
||||
return ImportZipEntriesAsync(zip.Entries);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
var message = "Error reading or importing registration cache zip.";
|
||||
logger.LogError(ex, message);
|
||||
return Task.FromResult(Result.InternalServerError([message, .. ex.ExtractMessages()]));
|
||||
}
|
||||
}
|
||||
public Task<Result> UploadAccountCacheZipAsync(Guid accountId, byte[] zipBytes) =>
|
||||
registrationCacheDomain.UploadAccountCacheZipAsync(accountId, zipBytes);
|
||||
|
||||
public Task<Result> UploadAccountCacheZipAsync(Guid accountId, byte[] zipBytes) {
|
||||
try {
|
||||
using var ms = new MemoryStream(zipBytes);
|
||||
using var zip = new ZipArchive(ms, ZipArchiveMode.Read);
|
||||
return ImportZipEntriesAsync(zip.Entries);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
var message = "Error reading or importing account registration cache zip.";
|
||||
logger.LogError(ex, message);
|
||||
return Task.FromResult(Result.InternalServerError([message, .. ex.ExtractMessages()]));
|
||||
}
|
||||
}
|
||||
public Task<Result> DeleteCacheAsync() =>
|
||||
registrationCacheDomain.DeleteAllAsync();
|
||||
|
||||
private async Task<Result> ImportZipEntriesAsync(IReadOnlyList<ZipArchiveEntry> entries) {
|
||||
foreach (var entry in entries) {
|
||||
if (string.IsNullOrEmpty(entry.Name) || !entry.Name.EndsWith(".json", StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
var name = Path.GetFileNameWithoutExtension(entry.Name);
|
||||
if (!Guid.TryParse(name, out var id))
|
||||
continue;
|
||||
using var stream = entry.Open();
|
||||
using var reader = new StreamReader(stream);
|
||||
var json = reader.ReadToEnd();
|
||||
if (string.IsNullOrWhiteSpace(json))
|
||||
continue;
|
||||
var cache = json.ToObject<RegistrationCache>();
|
||||
if (cache == null) {
|
||||
logger.LogWarning("Skipping zip entry {Name}: invalid JSON.", entry.FullName);
|
||||
continue;
|
||||
}
|
||||
cache.AccountId = id;
|
||||
var save = await registrationCachePersistence.SaveAsync(id, cache);
|
||||
if (!save.IsSuccess)
|
||||
return save;
|
||||
}
|
||||
logger.LogInformation("Imported registration caches from zip ({EntryCount} entries).", entries.Count);
|
||||
return Result.Ok();
|
||||
}
|
||||
|
||||
public Task<Result> DeleteCacheAsync() => registrationCachePersistence.DeleteAllAsync();
|
||||
|
||||
public Task<Result> DeleteAccountCacheAsync(Guid accountId) => registrationCachePersistence.DeleteAsync(accountId);
|
||||
public Task<Result> DeleteAccountCacheAsync(Guid accountId) =>
|
||||
registrationCacheDomain.DeleteAsync(accountId);
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
using System.Linq.Expressions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MaksIT.Core.Security;
|
||||
using MaksIT.Core.Webapi.Models;
|
||||
using MaksIT.CertsUI.Engine.DomainServices;
|
||||
using MaksIT.CertsUI.Engine.Dto.Identity;
|
||||
using MaksIT.CertsUI.Engine.QueryServices.Identity;
|
||||
using MaksIT.Models.LetsEncryptServer.Identity.Login;
|
||||
using MaksIT.Models.LetsEncryptServer.Identity.Logout;
|
||||
@ -16,7 +18,7 @@ using DomainUser = MaksIT.CertsUI.Engine.Domain.Identity.User;
|
||||
namespace MaksIT.CertsUI.Services;
|
||||
|
||||
public interface IIdentityService {
|
||||
Task<Result<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>>> SearchUsersAsync(JwtTokenData jwtTokenData, SearchUserRequest requestData);
|
||||
Task<Result<Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>?>> SearchUsersAsync(JwtTokenData jwtTokenData, SearchUserRequest requestData);
|
||||
Task<Result<UserResponse?>> ReadUserAsync(JwtTokenData jwtTokenData, Guid id);
|
||||
Task<Result<UserResponse?>> PostUserAsync(JwtTokenData jwtTokenData, CreateUserRequest requestData);
|
||||
Task<Result<UserResponse?>> PatchUserAsync(JwtTokenData jwtTokenData, Guid id, PatchUserRequest requestData);
|
||||
@ -37,27 +39,42 @@ public sealed class IdentityService(
|
||||
|
||||
private readonly ITwoFactorSettingsConfiguration _twoFactorSettings = twoFactorSettings;
|
||||
|
||||
public async Task<Result<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>>> SearchUsersAsync(JwtTokenData _jwtTokenData, SearchUserRequest requestData) {
|
||||
public Task<Result<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>?>> SearchUsersAsync(JwtTokenData _jwtTokenData, SearchUserRequest requestData) {
|
||||
_ = _jwtTokenData;
|
||||
var page = Math.Max(1, requestData.PageNumber);
|
||||
var size = Math.Clamp(requestData.PageSize, 1, 500);
|
||||
var filter = requestData.UsernameFilter?.Trim();
|
||||
Expression<Func<UserDto, bool>>? predicate = string.IsNullOrWhiteSpace(filter)
|
||||
? null
|
||||
: u => u.Name.Contains(filter!);
|
||||
|
||||
var query = await userQueryService.SearchUsersAsync(requestData.UsernameFilter?.Trim(), page, size);
|
||||
if (!query.IsSuccess || query.Value == null)
|
||||
return query.ToResultOfType<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>>(_ => new MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse> {
|
||||
var skip = (page - 1) * size;
|
||||
var countResult = userQueryService.Count(predicate);
|
||||
if (!countResult.IsSuccess)
|
||||
return Task.FromResult(countResult.ToResultOfType<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>?>(_ => new MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse> {
|
||||
Data = [],
|
||||
TotalRecords = 0,
|
||||
PageNumber = page,
|
||||
PageSize = size,
|
||||
})!;
|
||||
})!);
|
||||
|
||||
var paged = query.Value;
|
||||
return Result<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>>.Ok(new MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse> {
|
||||
Data = [.. paged.Data.Select(userToResponseMapper.MapToSearchResponse)],
|
||||
TotalRecords = paged.TotalRecords,
|
||||
PageNumber = paged.PageNumber,
|
||||
PageSize = paged.PageSize,
|
||||
});
|
||||
var searchResult = userQueryService.Search(predicate, skip, size);
|
||||
if (!searchResult.IsSuccess || searchResult.Value == null)
|
||||
return Task.FromResult(searchResult.ToResultOfType<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>?>(_ => new MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse> {
|
||||
Data = [],
|
||||
TotalRecords = 0,
|
||||
PageNumber = page,
|
||||
PageSize = size,
|
||||
})!);
|
||||
|
||||
var total = countResult.Value ?? 0;
|
||||
var list = searchResult.Value;
|
||||
return Task.FromResult(Result<MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse>?>.Ok(new MaksIT.Models.LetsEncryptServer.Common.PagedResponse<SearchUserResponse> {
|
||||
Data = [.. list.Select(userToResponseMapper.MapToSearchResponse)],
|
||||
TotalRecords = total,
|
||||
PageNumber = page,
|
||||
PageSize = size,
|
||||
}));
|
||||
}
|
||||
|
||||
public async Task<Result<UserResponse?>> ReadUserAsync(JwtTokenData _jwtTokenData, Guid id) {
|
||||
|
||||
@ -13,6 +13,12 @@ interface RequestOptions {
|
||||
skipLoader?: boolean
|
||||
}
|
||||
|
||||
interface ApiResponse<T> {
|
||||
payload: T | undefined
|
||||
status: number | undefined
|
||||
ok: boolean
|
||||
}
|
||||
|
||||
// Create an Axios instance
|
||||
const axiosInstance = axios.create({
|
||||
timeout: 10000, // Set a timeout if needed
|
||||
@ -166,7 +172,7 @@ const getData = async <TResponse>(
|
||||
url: string,
|
||||
timeout?: number,
|
||||
options?: RequestOptions
|
||||
): Promise<TResponse | undefined> => {
|
||||
): Promise<ApiResponse<TResponse>> => {
|
||||
try {
|
||||
const config: any = {
|
||||
headers: {
|
||||
@ -180,13 +186,13 @@ const getData = async <TResponse>(
|
||||
}
|
||||
|
||||
const response = await axiosInstance.get<TResponse>(url, config)
|
||||
return response.data
|
||||
} catch {
|
||||
// Error is already handled by interceptors, so just return undefined
|
||||
return undefined
|
||||
return { payload: response.data, status: response.status, ok: true }
|
||||
} catch (error: any) {
|
||||
return { payload: undefined, status: error?.response?.status, ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Performs a POST request with the given data and returns the response data.
|
||||
* @param url The endpoint URL.
|
||||
@ -199,7 +205,7 @@ const postData = async <TRequest, TResponse>(
|
||||
data?: TRequest,
|
||||
timeout?: number,
|
||||
options?: RequestOptions
|
||||
): Promise<TResponse | undefined> => {
|
||||
): Promise<ApiResponse<TResponse>> => {
|
||||
try {
|
||||
const config: any = {
|
||||
headers: {
|
||||
@ -213,14 +219,13 @@ const postData = async <TRequest, TResponse>(
|
||||
}
|
||||
|
||||
const response = await axiosInstance.post<TResponse>(url, data, config)
|
||||
|
||||
return response.data
|
||||
} catch {
|
||||
// Error is already handled by interceptors, so just return undefined
|
||||
return undefined
|
||||
return { payload: response.data, status: response.status, ok: true }
|
||||
} catch (error: any) {
|
||||
return { payload: undefined, status: error?.response?.status, ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Performs a PATCH request with the given data and returns the response data.
|
||||
* @param url The endpoint URL.
|
||||
@ -233,7 +238,7 @@ const patchData = async <TRequest, TResponse>(
|
||||
data: TRequest,
|
||||
timeout?: number,
|
||||
options?: RequestOptions
|
||||
): Promise<TResponse | undefined> => {
|
||||
): Promise<ApiResponse<TResponse>> => {
|
||||
try {
|
||||
const config: any = {
|
||||
headers: {
|
||||
@ -247,12 +252,12 @@ const patchData = async <TRequest, TResponse>(
|
||||
}
|
||||
|
||||
const response = await axiosInstance.patch<TResponse>(url, data, config)
|
||||
return response.data
|
||||
} catch {
|
||||
// Error is already handled by interceptors, so just return undefined
|
||||
return undefined
|
||||
return { payload: response.data, status: response.status, ok: true }
|
||||
} catch (error: any) {
|
||||
return { payload: undefined, status: error?.response?.status, ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Performs a PUT request with the given data and returns the response data.
|
||||
@ -266,7 +271,7 @@ const putData = async <TRequest, TResponse>(
|
||||
data: TRequest,
|
||||
timeout?: number,
|
||||
options?: RequestOptions
|
||||
): Promise<TResponse | undefined> => {
|
||||
): Promise<ApiResponse<TResponse>> => {
|
||||
try {
|
||||
const config: any = {
|
||||
headers: {
|
||||
@ -280,12 +285,12 @@ const putData = async <TRequest, TResponse>(
|
||||
}
|
||||
|
||||
const response = await axiosInstance.put<TResponse>(url, data, config)
|
||||
return response.data
|
||||
} catch {
|
||||
// Error is already handled by interceptors, so just return undefined
|
||||
return undefined
|
||||
return { payload: response.data, status: response.status, ok: true }
|
||||
} catch (error: any) {
|
||||
return { payload: undefined, status: error?.response?.status, ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Performs a DELETE request and returns the response data.
|
||||
@ -297,7 +302,7 @@ const deleteData = async <TResponse>(
|
||||
url: string,
|
||||
timeout?: number,
|
||||
options?: RequestOptions
|
||||
): Promise<TResponse | undefined> => {
|
||||
): Promise<ApiResponse<TResponse>> => {
|
||||
try {
|
||||
const config: any = {
|
||||
headers: {
|
||||
@ -311,13 +316,13 @@ const deleteData = async <TResponse>(
|
||||
}
|
||||
|
||||
const response = await axiosInstance.delete<TResponse>(url, config)
|
||||
return response.data
|
||||
} catch {
|
||||
// Error is already handled by interceptors, so just return undefined
|
||||
return undefined
|
||||
return { payload: response.data, status: response.status, ok: true }
|
||||
} catch (error: any) {
|
||||
return { payload: undefined, status: error?.response?.status, ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Performs a POST request with binary payload (e.g., file upload) and returns the response data.
|
||||
* @param url The endpoint URL.
|
||||
@ -330,7 +335,7 @@ const postBinary = async <TResponse>(
|
||||
data: Blob | ArrayBuffer | Uint8Array,
|
||||
timeout?: number,
|
||||
options?: RequestOptions
|
||||
): Promise<TResponse | undefined> => {
|
||||
): Promise<ApiResponse<TResponse>> => {
|
||||
try {
|
||||
const config: any = {
|
||||
headers: {
|
||||
@ -344,13 +349,13 @@ const postBinary = async <TResponse>(
|
||||
}
|
||||
|
||||
const response = await axiosInstance.post<TResponse>(url, data, config)
|
||||
return response.data
|
||||
} catch {
|
||||
// Error is already handled by interceptors, so just return undefined
|
||||
return undefined
|
||||
return { payload: response.data, status: response.status, ok: true }
|
||||
} catch (error: any) {
|
||||
return { payload: undefined, status: error?.response?.status, ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Performs a GET request to retrieve binary data (e.g., file download).
|
||||
* @param url The endpoint URL.
|
||||
@ -363,7 +368,7 @@ const getBinary = async (
|
||||
timeout?: number,
|
||||
as: 'arraybuffer' | 'blob' = 'arraybuffer',
|
||||
options?: RequestOptions
|
||||
): Promise<{ data: ArrayBuffer | Blob, headers: Record<string, string> } | undefined> => {
|
||||
): Promise<ApiResponse<{ data: ArrayBuffer | Blob, headers: Record<string, string> }>> => {
|
||||
try {
|
||||
const config: any = {
|
||||
responseType: as,
|
||||
@ -377,15 +382,19 @@ const getBinary = async (
|
||||
const response = await axiosInstance.get(url, config)
|
||||
|
||||
return {
|
||||
data: response.data,
|
||||
headers: response.headers as Record<string, string>
|
||||
payload: {
|
||||
data: response.data,
|
||||
headers: response.headers as Record<string, string>
|
||||
},
|
||||
status: response.status,
|
||||
ok: true
|
||||
}
|
||||
} catch {
|
||||
// Error is already handled by interceptors, so just return undefined
|
||||
return undefined
|
||||
} catch (error: any) {
|
||||
return { payload: undefined, status: error?.response?.status, ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Performs a POST request using multipart/form-data.
|
||||
* Accepts either a ready FormData or a record of fields to be converted into FormData.
|
||||
@ -401,7 +410,7 @@ const postFormData = async <TResponse>(
|
||||
form: FormData | Record<string, string | Blob | File | (string | Blob | File)[]>,
|
||||
timeout?: number,
|
||||
options?: RequestOptions
|
||||
): Promise<TResponse | undefined> => {
|
||||
): Promise<ApiResponse<TResponse>> => {
|
||||
try {
|
||||
const formData =
|
||||
form instanceof FormData
|
||||
@ -428,14 +437,13 @@ const postFormData = async <TResponse>(
|
||||
}
|
||||
|
||||
const response = await axiosInstance.post<TResponse>(url, formData, config)
|
||||
|
||||
return response.data
|
||||
} catch {
|
||||
// Error is already handled by interceptors, so just return undefined
|
||||
return undefined
|
||||
return { payload: response.data, status: response.status, ok: true }
|
||||
} catch (error: any) {
|
||||
return { payload: undefined, status: error?.response?.status, ok: false }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Convenience helper for uploading a single file via multipart/form-data.
|
||||
* @param url The endpoint URL.
|
||||
@ -454,7 +462,7 @@ const postFile = async <TResponse>(
|
||||
extraFields?: Record<string, string>,
|
||||
timeout?: number,
|
||||
options?: RequestOptions
|
||||
): Promise<TResponse | undefined> => {
|
||||
): Promise<ApiResponse<TResponse>> => {
|
||||
const fd = new FormData()
|
||||
const inferredName = filename ?? (file instanceof File ? file.name : 'file')
|
||||
fd.append(fieldName, file, inferredName)
|
||||
@ -466,6 +474,7 @@ const postFile = async <TResponse>(
|
||||
return postFormData<TResponse>(url, fd, timeout, options)
|
||||
}
|
||||
|
||||
|
||||
/** Options that disable the global loader for a request (for background/UI-only fetches). */
|
||||
const noLoaderOptions: RequestOptions = { skipLoader: true }
|
||||
|
||||
@ -475,7 +484,7 @@ const noLoaderOptions: RequestOptions = { skipLoader: true }
|
||||
const getDataWithoutLoader = async <TResponse>(
|
||||
url: string,
|
||||
timeout?: number
|
||||
): Promise<TResponse | undefined> =>
|
||||
): Promise<ApiResponse<TResponse>> =>
|
||||
getData<TResponse>(url, timeout, noLoaderOptions)
|
||||
|
||||
/**
|
||||
@ -485,10 +494,11 @@ const postDataWithoutLoader = async <TRequest, TResponse>(
|
||||
url: string,
|
||||
data?: TRequest,
|
||||
timeout?: number
|
||||
): Promise<TResponse | undefined> =>
|
||||
): Promise<ApiResponse<TResponse>> =>
|
||||
postData<TRequest, TResponse>(url, data, timeout, noLoaderOptions)
|
||||
|
||||
export {
|
||||
type ApiResponse,
|
||||
axiosInstance,
|
||||
getData,
|
||||
postData,
|
||||
|
||||
@ -58,9 +58,9 @@ const DataTableFilter = <T extends { [key: string]: string }>(props: FilterProps
|
||||
pageSize: 100,
|
||||
filters
|
||||
}).then((response) => {
|
||||
if (!response) return
|
||||
if (!response.ok || !response.payload) return
|
||||
|
||||
const rows = response.data ?? []
|
||||
const rows = response.payload.data ?? []
|
||||
const linqQuery = rows.map(item => `${columnId} == "${item['id']}"`).join(' || ')
|
||||
onFilterChange?.(filterId, columnId, linqQuery)
|
||||
|
||||
|
||||
@ -45,9 +45,9 @@ const DataTableLabel = <T extends { [key: string]: never }>(props: LabelProps) =
|
||||
|
||||
getDataWithoutLoader<T>(route)
|
||||
.then(response => {
|
||||
if (!response) return
|
||||
if (!response.ok || !response.payload) return
|
||||
|
||||
setRemoteLabel(response[accessorKey])
|
||||
setRemoteLabel(response.payload[accessorKey])
|
||||
}).finally(() => {})
|
||||
}, [props])
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ interface FileUploadComponentProps {
|
||||
label?: string
|
||||
colspan?: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12
|
||||
multiple?: boolean
|
||||
files?: File[]
|
||||
onChange?: (files: File[]) => void
|
||||
disabled?: boolean
|
||||
}
|
||||
@ -14,6 +15,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
label = 'Select files',
|
||||
colspan = 6,
|
||||
multiple = true,
|
||||
files,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}) => {
|
||||
@ -30,16 +32,47 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
}
|
||||
}, [showPopup])
|
||||
|
||||
const areFilesEqual = (left: File[], right: File[]) =>
|
||||
left.length === right.length &&
|
||||
left.every((file, index) => {
|
||||
const other = right[index]
|
||||
return other &&
|
||||
file.name === other.name &&
|
||||
file.size === other.size &&
|
||||
file.lastModified === other.lastModified &&
|
||||
file.type === other.type
|
||||
})
|
||||
|
||||
const displayFiles = files ?? selectedFiles
|
||||
|
||||
// Keep native input in sync for controlled resets.
|
||||
React.useEffect(() => {
|
||||
if (files !== undefined && files.length === 0 && inputRef.current)
|
||||
inputRef.current.value = ''
|
||||
}, [files])
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files ? Array.from(e.target.files) : []
|
||||
setSelectedFiles(files)
|
||||
onChange?.(files)
|
||||
const nextFiles = e.target.files ? Array.from(e.target.files) : []
|
||||
|
||||
if (files === undefined) {
|
||||
setSelectedFiles(nextFiles)
|
||||
}
|
||||
|
||||
if (!areFilesEqual(nextFiles, displayFiles)) {
|
||||
onChange?.(nextFiles)
|
||||
}
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setSelectedFiles([])
|
||||
if (files === undefined) {
|
||||
setSelectedFiles([])
|
||||
}
|
||||
|
||||
if (inputRef.current) inputRef.current.value = ''
|
||||
onChange?.([])
|
||||
|
||||
if (displayFiles.length > 0) {
|
||||
onChange?.([])
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectFiles = () => {
|
||||
@ -78,9 +111,9 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
className={'bg-gray-200 px-4 py-2 rounded w-full text-center select-none block'}
|
||||
style={{ minHeight: '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
{selectedFiles.length} file{selectedFiles.length !== 1 ? 's' : ''}
|
||||
{displayFiles.length} file{displayFiles.length !== 1 ? 's' : ''}
|
||||
</span>
|
||||
{showPopup && selectedFiles.length > 0 && (
|
||||
{showPopup && displayFiles.length > 0 && (
|
||||
<div
|
||||
ref={popupRef}
|
||||
className={'fixed z-50 bg-white border border-gray-300 rounded shadow-lg p-2 text-sm'}
|
||||
@ -111,7 +144,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
onFocus={() => {}}
|
||||
>
|
||||
<ul className={'max-h-40 overflow-auto'} tabIndex={0} style={{outline: 'none'}}>
|
||||
{selectedFiles.map((file, idx) => (
|
||||
{displayFiles.map((file, idx) => (
|
||||
<li key={file.name + idx} className={'truncate'} title={file.name}>
|
||||
{file.name}
|
||||
</li>
|
||||
@ -127,7 +160,7 @@ const FileUploadComponent: React.FC<FileUploadComponentProps> = ({
|
||||
<ButtonComponent
|
||||
buttonHierarchy={'secondary'}
|
||||
onClick={handleClear}
|
||||
disabled={disabled || selectedFiles.length === 0}
|
||||
disabled={disabled || displayFiles.length === 0}
|
||||
colspan={1}
|
||||
>
|
||||
<TrashIcon />
|
||||
|
||||
@ -70,8 +70,8 @@ const RemoteSelectBoxComponent = <TRequest extends PagedRequest>(props: RemoteSe
|
||||
|
||||
postData<TRequest, PagedResponse<SearchResponseBase>>(GetApiRoute(apiRoute).route, pagedRequest)
|
||||
.then((response) => {
|
||||
if (!response) return
|
||||
setOptions(response.items)
|
||||
if (!response.ok || !response.payload) return
|
||||
setOptions(response.payload.items)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('RemoteSelectBox fetch error:', error)
|
||||
|
||||
@ -50,10 +50,10 @@ const SecretComponent: FC<PasswordGeneratorProps> = (props) => {
|
||||
const handleGenerateSecret = () => {
|
||||
getData<TrngResponse>(`${GetApiRoute(ApiRoutes.generateSecret).route}`)
|
||||
.then(response => {
|
||||
if (!response) return
|
||||
if (!response.ok || !response.payload) return
|
||||
|
||||
const fakeEvent = {
|
||||
target: { value: response.secret }
|
||||
target: { value: response.payload.secret }
|
||||
} as ChangeEvent<HTMLInputElement>
|
||||
|
||||
handleOnChange(fakeEvent)
|
||||
|
||||
@ -77,9 +77,9 @@ const CreateApiKey: FC<CreateApiKeyProps> = (props) => {
|
||||
|
||||
postData<CreateApiKeyRequest, ApiKeyResponse>(GetApiRoute(ApiRoutes.apikeyPost).route, request.data)
|
||||
.then(response => {
|
||||
if (!response) return
|
||||
if (!response.ok || !response.payload) return
|
||||
|
||||
onSubmitted?.(response)
|
||||
onSubmitted?.(response.payload)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -125,7 +125,7 @@ const EditApiKey: FC<EditApiKeyProps> = (props) => {
|
||||
getData<ApiKeyResponse>(GetApiRoute(ApiRoutes.apikeyGet).route
|
||||
.replace('{apiKeyId}', apiKeyId))
|
||||
.then(response => {
|
||||
if (!response) {
|
||||
if (!response.ok || !response.payload) {
|
||||
// Leave form state as initial defaults; id will remain empty and
|
||||
// the "not found" UI will be shown below.
|
||||
setInitialState(deepCopy(initialFormState))
|
||||
@ -133,7 +133,7 @@ const EditApiKey: FC<EditApiKeyProps> = (props) => {
|
||||
return
|
||||
}
|
||||
|
||||
handleInitialization(response)
|
||||
handleInitialization(response.payload)
|
||||
})
|
||||
.finally(() => {
|
||||
setHasLoaded(true)
|
||||
@ -169,10 +169,10 @@ const EditApiKey: FC<EditApiKeyProps> = (props) => {
|
||||
patchData<PatchApiKeyRequest, ApiKeyResponse>(GetApiRoute(ApiRoutes.apikeyPatch).route
|
||||
.replace('{apiKeyId}', apiKeyId), request.data)
|
||||
.then(response => {
|
||||
if (!response) return
|
||||
if (!response.ok || !response.payload) return
|
||||
|
||||
handleInitialization(response)
|
||||
onSubmitted?.(response)
|
||||
handleInitialization(response.payload)
|
||||
onSubmitted?.(response.payload)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -95,7 +95,7 @@ const SearchApiKey: FC = () => {
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
postData<SearchAPIKeyRequest, PagedResponse<SearchAPIKeyResponse>>(GetApiRoute(ApiRoutes.apikeySearch).route, pagedRequest).then((response) => {
|
||||
setRawd(response ?? undefined)
|
||||
setRawd(response.payload ?? undefined)
|
||||
}).finally(() => {})
|
||||
}, [pagedRequest])
|
||||
|
||||
@ -113,7 +113,10 @@ const SearchApiKey: FC = () => {
|
||||
const handleDeleteRow = (ids: {[key: string]: string}) => {
|
||||
deleteData(GetApiRoute(ApiRoutes.apikeyDelete).route
|
||||
.replace('{apiKeyId}', ids.id)
|
||||
).then(() => loadData())
|
||||
).then((response) => {
|
||||
if (!response.ok) return
|
||||
loadData()
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditCancel = () => {
|
||||
|
||||
@ -111,9 +111,9 @@ const EditAccount: FC<EditAccountProps> = (props) => {
|
||||
getData<GetAccountResponse>(GetApiRoute(ApiRoutes.ACCOUNT_GET).route
|
||||
.replace('{accountId}', accountId)
|
||||
).then((response) => {
|
||||
if (!response) return
|
||||
if (!response.ok || !response.payload) return
|
||||
|
||||
handleInitialization(response)
|
||||
handleInitialization(response.payload)
|
||||
})
|
||||
}, [accountId, handleInitialization])
|
||||
|
||||
@ -168,10 +168,10 @@ const EditAccount: FC<EditAccountProps> = (props) => {
|
||||
.replace('{accountId}', accountId), delta, 120000
|
||||
)
|
||||
|
||||
if (!response) return
|
||||
if (!response.ok || !response.payload) return
|
||||
|
||||
handleInitialization(response)
|
||||
onSubmitted?.(response)
|
||||
handleInitialization(response.payload)
|
||||
onSubmitted?.(response.payload)
|
||||
|
||||
}
|
||||
|
||||
|
||||
@ -17,8 +17,8 @@ const Home: FC = () => {
|
||||
|
||||
const loadData = useCallback(() => {
|
||||
getData<GetAccountResponse[]>(GetApiRoute(ApiRoutes.ACCOUNTS_GET).route).then((response) => {
|
||||
if (!response) return
|
||||
setRawd(response)
|
||||
if (!response.ok || !response.payload) return
|
||||
setRawd(response.payload)
|
||||
})
|
||||
}, [])
|
||||
|
||||
@ -30,7 +30,8 @@ const Home: FC = () => {
|
||||
deleteData<void>(
|
||||
GetApiRoute(ApiRoutes.ACCOUNT_DELETE)
|
||||
.route.replace('{accountId}', accountId)
|
||||
).then(_ => {
|
||||
).then(response => {
|
||||
if (!response.ok) return
|
||||
setRawd(rawd.filter((account) => account.accountId !== accountId))
|
||||
})
|
||||
}
|
||||
@ -43,9 +44,9 @@ const Home: FC = () => {
|
||||
postData<void, { [key: string]: string }>(GetApiRoute(ApiRoutes.CERTS_FLOW_CERTIFICATES_APPLY).route
|
||||
.replace('{accountId}', accountId)
|
||||
).then(response => {
|
||||
if (!response?.message) return
|
||||
if (!response.ok || !response.payload?.message) return
|
||||
|
||||
addToast(response?.message, 'info')
|
||||
addToast(response.payload.message, 'info')
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -20,15 +20,15 @@ const LetsEncryptTermsOfService: FC = () => {
|
||||
isStaging: true
|
||||
})
|
||||
.then(response => {
|
||||
if (!response) return
|
||||
if (!response.ok || !response.payload) return
|
||||
return getData<string>(
|
||||
GetApiRoute(ApiRoutes.CERTS_FLOW_TERMS_OF_SERVICE).route.replace('{sessionId}', response),
|
||||
GetApiRoute(ApiRoutes.CERTS_FLOW_TERMS_OF_SERVICE).route.replace('{sessionId}', response.payload),
|
||||
120_000
|
||||
)
|
||||
})
|
||||
.then(base64Pdf => {
|
||||
if (typeof base64Pdf === 'string' && base64Pdf.length > 0) {
|
||||
setPdfUrl(base64Pdf)
|
||||
.then(response => {
|
||||
if (response?.ok && typeof response.payload === 'string' && response.payload.length > 0) {
|
||||
setPdfUrl(response.payload)
|
||||
} else {
|
||||
setError('Failed to retrieve PDF.')
|
||||
}
|
||||
|
||||
@ -137,7 +137,7 @@ const Register: FC<RegisterProps> = () => {
|
||||
120000
|
||||
)
|
||||
|
||||
if (!response) return
|
||||
if (!response.ok) return
|
||||
|
||||
navigate('/')
|
||||
}
|
||||
|
||||
@ -76,9 +76,9 @@ const CreateUser: FC<CreateUserProps> = (props) => {
|
||||
|
||||
postData<CreateUserRequest, UserResponse>(GetApiRoute(ApiRoutes.identityPost).route, request.data)
|
||||
.then((response) => {
|
||||
if (!response) return
|
||||
if (!response.ok || !response.payload) return
|
||||
setInitialState(createUserFormPropsProto())
|
||||
onSubmitted?.(response)
|
||||
onSubmitted?.(response.payload)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -61,10 +61,10 @@ const ChangePassword: FC<ChangePasswordProps> = (props) => {
|
||||
data
|
||||
)
|
||||
|
||||
if (!response) return
|
||||
if (!response.ok || !response.payload) return
|
||||
|
||||
addToast('Password updated.', 'success')
|
||||
onSubmitted?.(response)
|
||||
onSubmitted?.(response.payload)
|
||||
handleOnClose()
|
||||
}
|
||||
|
||||
|
||||
@ -48,11 +48,11 @@ const EditUser: FC<EditUserProps> = (props) => {
|
||||
const handleLoad = useCallback(() => {
|
||||
getData<UserResponse>(GetApiRoute(ApiRoutes.identityGet).route.replace('{userId}', userId))
|
||||
.then((response) => {
|
||||
setUser(response ?? null)
|
||||
if (response) {
|
||||
setTwoFactorEnabled(!!response.twoFactorEnabled)
|
||||
setRecoveryCodesLeft(response.recoveryCodesLeft)
|
||||
setIsActive(response.isActive !== false)
|
||||
setUser(response.payload ?? null)
|
||||
if (response.ok && response.payload) {
|
||||
setTwoFactorEnabled(!!response.payload.twoFactorEnabled)
|
||||
setRecoveryCodesLeft(response.payload.recoveryCodesLeft)
|
||||
setIsActive(response.payload.isActive !== false)
|
||||
setDirtyIsActive(false)
|
||||
}
|
||||
})
|
||||
@ -88,11 +88,11 @@ const EditUser: FC<EditUserProps> = (props) => {
|
||||
GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId),
|
||||
body
|
||||
).then((response) => {
|
||||
if (!response) return
|
||||
setUser(response)
|
||||
setIsActive(response.isActive !== false)
|
||||
if (!response.ok || !response.payload) return
|
||||
setUser(response.payload)
|
||||
setIsActive(response.payload.isActive !== false)
|
||||
setDirtyIsActive(false)
|
||||
onSubmitted?.(response)
|
||||
onSubmitted?.(response.payload)
|
||||
})
|
||||
}
|
||||
|
||||
@ -162,27 +162,27 @@ const EditUser: FC<EditUserProps> = (props) => {
|
||||
GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId),
|
||||
{ twoFactorEnabled: true }
|
||||
).then((response) => {
|
||||
if (!response) return
|
||||
if (!response.ok || !response.payload) return
|
||||
setShowEnableTwoFactor(true)
|
||||
setTwoFactorEnabled(!!response.twoFactorEnabled)
|
||||
setQrCodeUrl(response.qrCodeUrl)
|
||||
setRecoveryCodes(response.twoFactorRecoveryCodes)
|
||||
setRecoveryCodesLeft(response.recoveryCodesLeft)
|
||||
setUser(response)
|
||||
onSubmitted?.(response)
|
||||
setTwoFactorEnabled(!!response.payload.twoFactorEnabled)
|
||||
setQrCodeUrl(response.payload.qrCodeUrl)
|
||||
setRecoveryCodes(response.payload.twoFactorRecoveryCodes)
|
||||
setRecoveryCodesLeft(response.payload.recoveryCodesLeft)
|
||||
setUser(response.payload)
|
||||
onSubmitted?.(response.payload)
|
||||
})
|
||||
} else {
|
||||
patchData<PatchUserEnabeleTwoFactorRequest, UserResponse>(
|
||||
GetApiRoute(ApiRoutes.identityPatch).route.replace('{userId}', userId),
|
||||
{ twoFactorEnabled: false }
|
||||
).then((response) => {
|
||||
if (!response) return
|
||||
setTwoFactorEnabled(!!response.twoFactorEnabled)
|
||||
if (!response.ok || !response.payload) return
|
||||
setTwoFactorEnabled(!!response.payload.twoFactorEnabled)
|
||||
setQrCodeUrl(undefined)
|
||||
setRecoveryCodes(undefined)
|
||||
setRecoveryCodesLeft(response.recoveryCodesLeft)
|
||||
setUser(response)
|
||||
onSubmitted?.(response)
|
||||
setRecoveryCodesLeft(response.payload.recoveryCodesLeft)
|
||||
setUser(response.payload)
|
||||
onSubmitted?.(response.payload)
|
||||
})
|
||||
}
|
||||
}}
|
||||
|
||||
@ -119,7 +119,7 @@ const SearchUser: FC = () => {
|
||||
GetApiRoute(ApiRoutes.identitySearch).route,
|
||||
pagedRequest
|
||||
).then((response) => {
|
||||
setRawd(response ?? undefined)
|
||||
setRawd(response.payload ?? undefined)
|
||||
}).finally(() => {})
|
||||
}, [pagedRequest])
|
||||
|
||||
@ -135,7 +135,10 @@ const SearchUser: FC = () => {
|
||||
|
||||
const handleDeleteRow = (ids: Record<string, string>) => {
|
||||
deleteData(GetApiRoute(ApiRoutes.identityDelete).route.replace('{userId}', ids.id))
|
||||
.then(() => loadData())
|
||||
.then((response) => {
|
||||
if (!response.ok) return
|
||||
loadData()
|
||||
})
|
||||
}
|
||||
|
||||
const handleEditCancel = () => {
|
||||
|
||||
@ -16,9 +16,9 @@ const Utilities: FC = () => {
|
||||
const hadnleTestAgent = () => {
|
||||
getData<HelloWorldResponse>(GetApiRoute(ApiRoutes.AGENT_TEST).route)
|
||||
.then((response) => {
|
||||
if (!response) return
|
||||
if (!response.ok || !response.payload) return
|
||||
|
||||
addToast(response.message, 'info')
|
||||
addToast(response.payload.message, 'info')
|
||||
})
|
||||
}
|
||||
|
||||
@ -29,20 +29,25 @@ const Utilities: FC = () => {
|
||||
}
|
||||
|
||||
const zipBlob = await downloadZip(files).blob()
|
||||
// Option A: direct file helper
|
||||
postFile(GetApiRoute(ApiRoutes.FULL_CACHE_UPLOAD_POST).route, zipBlob, 'file', 'cache.zip')
|
||||
.then((_) => {
|
||||
setFiles([])
|
||||
addToast('Files uploaded successfully', 'success')
|
||||
})
|
||||
const response = await postFile(
|
||||
GetApiRoute(ApiRoutes.FULL_CACHE_UPLOAD_POST).route,
|
||||
zipBlob,
|
||||
'file',
|
||||
'cache.zip'
|
||||
)
|
||||
if (!response.ok)
|
||||
return
|
||||
|
||||
setFiles([])
|
||||
addToast('Files uploaded successfully', 'success')
|
||||
}
|
||||
|
||||
const handleDownloadFiles = () => {
|
||||
getBinary(GetApiRoute(ApiRoutes.FULL_CACHE_DOWNLOAD_GET).route
|
||||
).then((response) => {
|
||||
if (!response) return
|
||||
if (!response.ok || !response.payload) return
|
||||
|
||||
const { data, headers } = response
|
||||
const { data, headers } = response.payload
|
||||
const filename = extractFilenameFromHeaders(headers, 'cache.zip')
|
||||
saveBinaryToDisk(data, filename)
|
||||
})
|
||||
@ -50,7 +55,8 @@ const Utilities: FC = () => {
|
||||
|
||||
const handleDestroyFiles = () => {
|
||||
deleteData(GetApiRoute(ApiRoutes.FULL_CACHE_DELETE).route)
|
||||
.then((_) => {
|
||||
.then((response) => {
|
||||
if (!response.ok) return
|
||||
addToast('Cache files destroyed successfully', 'success')
|
||||
})
|
||||
}
|
||||
@ -72,6 +78,7 @@ const Utilities: FC = () => {
|
||||
colspan={6}
|
||||
label={'Select cache files'}
|
||||
multiple={true}
|
||||
files={files}
|
||||
onChange={setFiles}
|
||||
/>
|
||||
|
||||
@ -82,7 +89,7 @@ const Utilities: FC = () => {
|
||||
onClick={handleUploadFiles}
|
||||
/>
|
||||
|
||||
<span className={'col-span-3'}></span>
|
||||
<span className={'col-span-12'}></span>
|
||||
|
||||
<ButtonComponent
|
||||
colspan={3}
|
||||
|
||||
@ -37,7 +37,7 @@ const login = createAsyncThunk(
|
||||
async (requestData: LoginRequest) => {
|
||||
const apiRoute = GetApiRoute(ApiRoutes.identityLogin)
|
||||
const response = await postData<LoginRequest, LoginResponse>(apiRoute.route, requestData)
|
||||
return response
|
||||
return response.payload
|
||||
}
|
||||
)
|
||||
|
||||
@ -53,7 +53,7 @@ const logout = createAsyncThunk(
|
||||
logoutFromAllDevices,
|
||||
token: identity.token,
|
||||
})
|
||||
return response
|
||||
return response.payload
|
||||
}
|
||||
)
|
||||
|
||||
@ -70,7 +70,7 @@ const refreshJwt = createAsyncThunk(
|
||||
force,
|
||||
})
|
||||
|
||||
return response
|
||||
return response.payload
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user