diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a368d5..7a5f708 100644 --- a/CHANGELOG.md +++ b/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>?`** 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`** 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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3676f36..f892729 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 diff --git a/assets/docs/ARCHITECTURE_LAYERING.md b/assets/docs/ARCHITECTURE_LAYERING.md new file mode 100644 index 0000000..7f4908a --- /dev/null +++ b/assets/docs/ARCHITECTURE_LAYERING.md @@ -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` (`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`** 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`** 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`** 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>`** 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>?`** 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> 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?>`** 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()` (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) diff --git a/src/MaksIT.CertsUI.Engine.Tests/RegistrationCachePayloadJsonTests.cs b/src/MaksIT.CertsUI.Engine.Tests/RegistrationCachePayloadJsonTests.cs new file mode 100644 index 0000000..5d0745c --- /dev/null +++ b/src/MaksIT.CertsUI.Engine.Tests/RegistrationCachePayloadJsonTests.cs @@ -0,0 +1,48 @@ +using MaksIT.Core.Extensions; +using MaksIT.CertsUI.Engine.Domain.Certs; +using Xunit; + +namespace MaksIT.CertsUI.Engine.Tests; + +/// +/// Same JSON contract as registration_caches.PayloadJson (load/save in +/// ) +/// and as written into zip entries via RegistrationCache.ToJson() (MaksIT.Core STJ helpers). +/// +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(); + 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(); + 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); + } +} diff --git a/src/MaksIT.CertsUI.Engine/Data/CertsLinq2DbMapping.cs b/src/MaksIT.CertsUI.Engine/Data/CertsLinq2DbMapping.cs index 7c9b47a..29db364 100644 --- a/src/MaksIT.CertsUI.Engine/Data/CertsLinq2DbMapping.cs +++ b/src/MaksIT.CertsUI.Engine/Data/CertsLinq2DbMapping.cs @@ -63,7 +63,8 @@ public static class CertsLinq2DbMapping { // RegistrationCacheDto -> registration_caches builder.Entity() .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"); diff --git a/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowDomainService.cs b/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowDomainService.cs index 887769f..b668da6 100644 --- a/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowDomainService.cs +++ b/src/MaksIT.CertsUI.Engine/DomainServices/CertsFlowDomainService.cs @@ -44,6 +44,11 @@ public interface ICertsFlowDomainService { #region HTTP-01 challenge Task> AcmeChallengeAsync(string fileName, CancellationToken cancellationToken = default); #endregion + + #region Maintenance + /// Deletes HTTP-01 challenge rows older than (used by renewal sweep). + Task> PurgeStaleHttpChallengesAsync(TimeSpan maxAge, CancellationToken cancellationToken = default); + #endregion } /// @@ -56,7 +61,7 @@ public class CertsFlowDomainService : ICertsFlowDomainService { private readonly ILogger _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 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?>> 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?>(_ => 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> 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); } diff --git a/src/MaksIT.CertsUI.Engine/DomainServices/IRegistrationCacheDomainService.cs b/src/MaksIT.CertsUI.Engine/DomainServices/IRegistrationCacheDomainService.cs new file mode 100644 index 0000000..dd0a4a9 --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/DomainServices/IRegistrationCacheDomainService.cs @@ -0,0 +1,40 @@ +using MaksIT.CertsUI.Engine.Domain.Certs; +using MaksIT.Results; + +namespace MaksIT.CertsUI.Engine.DomainServices; + +/// +/// Registration cache use cases (load/save, zip import/export). Orchestrates only from the engine layer. +/// +public interface IRegistrationCacheDomainService { + + #region Read + + Task> LoadAllAsync(CancellationToken cancellationToken = default); + + Task> LoadAsync(Guid accountId, CancellationToken cancellationToken = default); + + #endregion + + #region Write + + Task SaveAsync(Guid accountId, RegistrationCache cache, CancellationToken cancellationToken = default); + + Task DeleteAllAsync(CancellationToken cancellationToken = default); + + Task DeleteAsync(Guid accountId, CancellationToken cancellationToken = default); + + #endregion + + #region Zip import/export + + Task> DownloadCacheZipAsync(CancellationToken cancellationToken = default); + + Task> DownloadAccountCacheZipAsync(Guid accountId, CancellationToken cancellationToken = default); + + Task UploadCacheZipAsync(byte[] zipBytes, CancellationToken cancellationToken = default); + + Task UploadAccountCacheZipAsync(Guid accountId, byte[] zipBytes, CancellationToken cancellationToken = default); + + #endregion +} diff --git a/src/MaksIT.CertsUI.Engine/DomainServices/RegistrationCacheDomainService.cs b/src/MaksIT.CertsUI.Engine/DomainServices/RegistrationCacheDomainService.cs new file mode 100644 index 0000000..463f639 --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/DomainServices/RegistrationCacheDomainService.cs @@ -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; + +/// +/// Domain-level registration cache operations (zip + row persistence). Host delegates here. +/// +public sealed class RegistrationCacheDomainService( + ILogger logger, + IRegistrationCachePersistanceService registrationCachePersistence +) : IRegistrationCacheDomainService { + + private readonly ILogger _logger = logger; + private readonly IRegistrationCachePersistanceService _persistence = registrationCachePersistence; + + public Task> LoadAllAsync(CancellationToken cancellationToken = default) => + _persistence.LoadAllAsync(cancellationToken); + + public Task> LoadAsync(Guid accountId, CancellationToken cancellationToken = default) => + _persistence.LoadAsync(accountId, cancellationToken); + + public Task SaveAsync(Guid accountId, RegistrationCache cache, CancellationToken cancellationToken = default) => + _persistence.SaveAsync(accountId, cache, cancellationToken); + + public Task DeleteAllAsync(CancellationToken cancellationToken = default) => + _persistence.DeleteAllAsync(cancellationToken); + + public Task DeleteAsync(Guid accountId, CancellationToken cancellationToken = default) => + _persistence.DeleteAsync(accountId, cancellationToken); + + public async Task> DownloadCacheZipAsync(CancellationToken cancellationToken = default) { + try { + var allResult = await _persistence.LoadAllAsync(cancellationToken).ConfigureAwait(false); + if (!allResult.IsSuccess || allResult.Value == null) + return Result.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.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.Ok(zipBytes); + } + catch (Exception ex) { + var message = "Error creating registration cache zip."; + _logger.LogError(ex, message); + return Result.InternalServerError(null, [message, .. ex.ExtractMessages()]); + } + } + + public async Task> 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.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.Ok(zipBytes); + } + catch (Exception ex) { + var message = "Error creating account registration cache zip."; + _logger.LogError(ex, message); + return Result.InternalServerError(null, [message, .. ex.ExtractMessages()]); + } + } + + public Task 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 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 ImportZipEntriesAsync(IReadOnlyList 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 '.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(); + 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); + } +} diff --git a/src/MaksIT.CertsUI.Engine/Dto/Certs/RegistrationCacheDto.cs b/src/MaksIT.CertsUI.Engine/Dto/Certs/RegistrationCacheDto.cs index ffefc80..5429a37 100644 --- a/src/MaksIT.CertsUI.Engine/Dto/Certs/RegistrationCacheDto.cs +++ b/src/MaksIT.CertsUI.Engine/Dto/Certs/RegistrationCacheDto.cs @@ -1,10 +1,19 @@ +using MaksIT.Core.Abstractions.Dto; + namespace MaksIT.CertsUI.Engine.Dto.Certs; /// /// PostgreSQL registration_caches row: ACME registration payload as JSON text. /// -public class RegistrationCacheDto { - public Guid AccountId { get; set; } +public class RegistrationCacheDto : DtoDocumentBase { + /// + /// Backward-compatible alias for . + /// + public Guid AccountId { + get => Id; + set => Id = value; + } + public long Version { get; set; } public required string PayloadJson { get; set; } } diff --git a/src/MaksIT.CertsUI.Engine/Dto/Certs/RegistrationCachePayloadDocument.cs b/src/MaksIT.CertsUI.Engine/Dto/Certs/RegistrationCachePayloadDocument.cs new file mode 100644 index 0000000..3ca5624 --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Dto/Certs/RegistrationCachePayloadDocument.cs @@ -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; } + + /// Filled from JSON key Id (ACME account URI). + public string? RootIdCapital { get; set; } + + /// Filled from JSON key id. + public string? RootIdLowercase { get; set; } + + /// Optional key acmeAccountResourceId when present. + 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? CachedCerts { get; set; } + + /// + public Dictionary? AcmeRenewalNotBeforeUtcByHostname { get; set; } +} diff --git a/src/MaksIT.CertsUI.Engine/Dto/Identity/ApiKeyEntityScopeDto.cs b/src/MaksIT.CertsUI.Engine/Dto/Identity/ApiKeyEntityScopeDto.cs new file mode 100644 index 0000000..6e7a74c --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Dto/Identity/ApiKeyEntityScopeDto.cs @@ -0,0 +1,12 @@ +namespace MaksIT.CertsUI.Engine.Dto.Identity; + +/// +/// Placeholder row shape for API key entity scope queries (Vault parity). Not mapped to a table until scope storage exists; used for predicate typing. +/// +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; } +} diff --git a/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs b/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs index faae9f6..8df977b 100644 --- a/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs +++ b/src/MaksIT.CertsUI.Engine/Extensions/ServiceCollectionExtensions.cs @@ -52,6 +52,8 @@ public static class ServiceCollectionExtensions { #region Registration cache services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddSingleton(); @@ -64,7 +66,6 @@ public static class ServiceCollectionExtensions { #endregion #region ACME / Let's Encrypt - services.AddSingleton(); services.AddHttpClient(); #endregion } diff --git a/src/MaksIT.CertsUI.Engine/MaksIT.CertsUI.Engine.csproj b/src/MaksIT.CertsUI.Engine/MaksIT.CertsUI.Engine.csproj index 286db5d..4e737fc 100644 --- a/src/MaksIT.CertsUI.Engine/MaksIT.CertsUI.Engine.csproj +++ b/src/MaksIT.CertsUI.Engine/MaksIT.CertsUI.Engine.csproj @@ -22,7 +22,6 @@ - diff --git a/src/MaksIT.CertsUI.Engine/Persistance/Mappers/AcmeSessionPayloadMapper.cs b/src/MaksIT.CertsUI.Engine/Persistance/Mappers/AcmeSessionPayloadMapper.cs new file mode 100644 index 0000000..17e0e7b --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Persistance/Mappers/AcmeSessionPayloadMapper.cs @@ -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; + +/// +/// Maps ACME browser-session to/from acme_sessions.payload_json. +/// Used by . +/// +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(); + 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; + } + + /// DTO shape stored in payload_json (not the in-memory ). + private sealed class AcmeSessionPayloadSnapshot { + public bool IsStaging { get; set; } + public AcmeDirectory? Directory { get; set; } + public Order? CurrentOrder { get; set; } + public List Challenges { get; set; } = []; + public RegistrationCache? Cache { get; set; } + public Jwk? Jwk { get; set; } + /// RSA account key CSP blob when present (same encoding as ). + public byte[]? AccountKeyCspBlob { get; set; } + } +} diff --git a/src/MaksIT.CertsUI.Engine/Persistance/Services/IAcmeSessionPersistanceService.cs b/src/MaksIT.CertsUI.Engine/Persistance/Services/IAcmeSessionPersistanceService.cs new file mode 100644 index 0000000..e82e649 --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Persistance/Services/IAcmeSessionPersistanceService.cs @@ -0,0 +1,28 @@ +using MaksIT.CertsUI.Engine.Domain.LetsEncrypt; +using MaksIT.Results; + +namespace MaksIT.CertsUI.Engine.Persistance.Services; + +/// +/// PostgreSQL acme_sessions: load/save JSON payload for per-session ACME . +/// +public interface IAcmeSessionPersistanceService { + + #region Read + + /// + /// Loads a non-expired session row. Returns with null when none match. + /// + Task> LoadAsync(Guid sessionId, CancellationToken cancellationToken = default); + + #endregion + + #region Write + + /// + /// Upserts session payload and refreshes updated_at_utc / expires_at_utc. + /// + Task SaveAsync(Guid sessionId, State state, CancellationToken cancellationToken = default); + + #endregion +} diff --git a/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/AcmeSessionPersistanceServiceLinq2Db.cs b/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/AcmeSessionPersistanceServiceLinq2Db.cs new file mode 100644 index 0000000..d5b14b9 --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/AcmeSessionPersistanceServiceLinq2Db.cs @@ -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; + +/// +/// Linq2Db-based implementation of for PostgreSQL. +/// +public sealed class AcmeSessionPersistanceServiceLinq2Db( + ILogger logger, + ICertsDataConnectionFactory connectionFactory +) : IAcmeSessionPersistanceService { + + private static readonly TimeSpan SessionTtl = TimeSpan.FromHours(1); + + private readonly ILogger _logger = logger; + private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory; + + public Task> LoadAsync(Guid sessionId, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + try { + using var db = _connectionFactory.Create(); + var now = DateTimeOffset.UtcNow; + var row = db.GetTable() + .Where(x => x.SessionId == sessionId && x.ExpiresAtUtc > now) + .FirstOrDefault(); + if (row == null) + return Task.FromResult(Result.Ok(null)); + + try { + var state = AcmeSessionPayloadMapper.FromPayloadJson(row.PayloadJson); + return Task.FromResult(Result.Ok(state)); + } + catch (Exception ex) { + _logger.LogWarning(ex, "Failed to deserialize ACME session {SessionId}; returning empty.", sessionId); + return Task.FromResult(Result.Ok(null)); + } + } + catch (Exception ex) { + if (_logger.IsEnabled(LogLevel.Error)) + _logger.LogError(ex, "Error loading ACME session {SessionId}", sessionId); + return Task.FromResult(Result.InternalServerError(null, ["An error occurred while loading the ACME session.", .. ex.ExtractMessages()])); + } + } + + public Task 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() + .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()])); + } + } +} diff --git a/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/ApiKeyPersistanceServiceLinq2Db.cs b/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/ApiKeyPersistanceServiceLinq2Db.cs index 346f082..1ed0aaa 100644 --- a/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/ApiKeyPersistanceServiceLinq2Db.cs +++ b/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/ApiKeyPersistanceServiceLinq2Db.cs @@ -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; diff --git a/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/RegistrationCachePersistanceServiceLinq2Db.cs b/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/RegistrationCachePersistanceServiceLinq2Db.cs index ad807a8..c936852 100644 --- a/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/RegistrationCachePersistanceServiceLinq2Db.cs +++ b/src/MaksIT.CertsUI.Engine/Persistance/Services/Linq2Db/RegistrationCachePersistanceServiceLinq2Db.cs @@ -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 logger, ICertsDataConnectionFactory connectionFactory -) : IRegistrationCachePersistanceService { +) : IRegistrationCachePersistanceService +{ private readonly ILogger _logger = logger; private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory; - public Task> LoadAllAsync(CancellationToken cancellationToken = default) { + public Task> LoadAllAsync(CancellationToken cancellationToken = default) + { cancellationToken.ThrowIfCancellationRequested(); - try { + try + { using var db = _connectionFactory.Create(); var rows = db.GetTable().ToList(); var caches = new List(); - 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(); - 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.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.InternalServerError(null, ["An error occurred while loading registration caches.", .. ex.ExtractMessages()])); } } - public Task> LoadAsync(Guid accountId, CancellationToken cancellationToken = default) { + public Task> LoadAsync(Guid accountId, CancellationToken cancellationToken = default) + { cancellationToken.ThrowIfCancellationRequested(); - try { + try + { using var db = _connectionFactory.Create(); - var row = db.GetTable().FirstOrDefault(r => r.AccountId == accountId); + var row = db.GetTable().FirstOrDefault(r => r.Id == accountId); if (row == null) return Task.FromResult(Result.NotFound(null, $"Registration cache not found for account {accountId}.")); @@ -69,46 +77,52 @@ public sealed class RegistrationCachePersistanceServiceLinq2Db( if (cache == null) return Task.FromResult(Result.InternalServerError(null, $"Registration cache payload is invalid for account {accountId}.")); - cache.AccountId = accountId; cache.ConcurrencyVersion = row.Version; return Task.FromResult(Result.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.InternalServerError(null, ["An error occurred while loading the registration cache.", .. ex.ExtractMessages()])); } } - public Task SaveAsync(Guid accountId, RegistrationCache cache, CancellationToken cancellationToken = default) { + public Task 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().FirstOrDefault(r => r.AccountId == accountId); + var row = db.GetTable().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() - .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 DeleteAllAsync(CancellationToken cancellationToken = default) { + public Task 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 DeleteAsync(Guid accountId, CancellationToken cancellationToken = default) { + public Task DeleteAsync(Guid accountId, CancellationToken cancellationToken = default) + { cancellationToken.ThrowIfCancellationRequested(); - try { + try + { using var db = _connectionFactory.Create(); - var deleted = db.GetTable().Where(r => r.AccountId == accountId).Delete(); - if (deleted == 0) { + var deleted = db.GetTable().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()])); } } + } diff --git a/src/MaksIT.CertsUI.Engine/QueryServices/ExpressionCompose.cs b/src/MaksIT.CertsUI.Engine/QueryServices/ExpressionCompose.cs new file mode 100644 index 0000000..d7cdb8f --- /dev/null +++ b/src/MaksIT.CertsUI.Engine/QueryServices/ExpressionCompose.cs @@ -0,0 +1,28 @@ +using System.Linq.Expressions; + +namespace MaksIT.CertsUI.Engine.QueryServices; + +/// +/// 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). +/// +public static class ExpressionCompose { + /// + /// Composes inner predicate with navigation to produce a predicate on the outer type (Linq2Db/IQueryable translatable to SQL). + /// + public static Expression>? ComposeNavigationPredicate( + Expression>? innerPredicate, + Expression> 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>(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); + } +} diff --git a/src/MaksIT.CertsUI.Engine/QueryServices/Identity/IApiKeyEntityScopeQueryService.cs b/src/MaksIT.CertsUI.Engine/QueryServices/Identity/IApiKeyEntityScopeQueryService.cs index f12cdf5..dd1840d 100644 --- a/src/MaksIT.CertsUI.Engine/QueryServices/Identity/IApiKeyEntityScopeQueryService.cs +++ b/src/MaksIT.CertsUI.Engine/QueryServices/Identity/IApiKeyEntityScopeQueryService.cs @@ -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; /// -/// API key ↔ entity scope search. Certs has no persisted scope graph yet; default implementation returns an empty page. +/// API key ↔ entity scope search (Vault IApiKeyEntityScopeQueryService pattern). Default implementation returns empty results until scope rows exist in PostgreSQL. /// public interface IApiKeyEntityScopeQueryService { - Task>> SearchApiKeyEntityScopesAsync( - Guid? apiKeyId, - int pageNumber, - int pageSize, - CancellationToken cancellationToken = default); + Result?> Search( + Expression>? predicate, + int? skip, + int? limit); + + Result Count(Expression>? predicate); } diff --git a/src/MaksIT.CertsUI.Engine/QueryServices/Identity/IApiKeyQueryService.cs b/src/MaksIT.CertsUI.Engine/QueryServices/Identity/IApiKeyQueryService.cs index 9484b26..96efbeb 100644 --- a/src/MaksIT.CertsUI.Engine/QueryServices/Identity/IApiKeyQueryService.cs +++ b/src/MaksIT.CertsUI.Engine/QueryServices/Identity/IApiKeyQueryService.cs @@ -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; /// -/// Read-only paged API key search for list views. +/// Read-only API key search (Vault IApiKeyQueryService pattern): optional predicate on , skip/limit, and count. /// public interface IApiKeyQueryService { - Task>> SearchApiKeysAsync( - string? descriptionFilter, - int pageNumber, - int pageSize, - CancellationToken cancellationToken = default); + Result?> Search( + Expression>? apiKeysPredicate, + int? skip, + int? limit); + + Result Count(Expression>? apiKeysPredicate); } diff --git a/src/MaksIT.CertsUI.Engine/QueryServices/Identity/IUserQueryService.cs b/src/MaksIT.CertsUI.Engine/QueryServices/Identity/IUserQueryService.cs index 1d0b08e..9028c42 100644 --- a/src/MaksIT.CertsUI.Engine/QueryServices/Identity/IUserQueryService.cs +++ b/src/MaksIT.CertsUI.Engine/QueryServices/Identity/IUserQueryService.cs @@ -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; /// -/// 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 , skip/limit, and a separate count. +/// Host builds optional predicates on for filters and RBAC; use to compose nested predicates (Vault parity). /// public interface IUserQueryService { - Task>> SearchUsersAsync( - string? usernameFilter, - int pageNumber, - int pageSize, - CancellationToken cancellationToken = default); + Result?> Search( + Expression>? usersPredicate, + int? skip, + int? limit); + + Result Count(Expression>? usersPredicate); } diff --git a/src/MaksIT.CertsUI.Engine/QueryServices/Linq2Db/Identity/ApiKeyEntityScopeQueryServiceStub.cs b/src/MaksIT.CertsUI.Engine/QueryServices/Linq2Db/Identity/ApiKeyEntityScopeQueryServiceStub.cs index c2c4e22..2e5301c 100644 --- a/src/MaksIT.CertsUI.Engine/QueryServices/Linq2Db/Identity/ApiKeyEntityScopeQueryServiceStub.cs +++ b/src/MaksIT.CertsUI.Engine/QueryServices/Linq2Db/Identity/ApiKeyEntityScopeQueryServiceStub.cs @@ -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; /// -/// 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. /// public sealed class ApiKeyEntityScopeQueryServiceStub(ILogger logger) : IApiKeyEntityScopeQueryService { - public Task>> SearchApiKeyEntityScopesAsync( - Guid? apiKeyId, - int pageNumber, - int pageSize, - CancellationToken cancellationToken = default) { - _ = apiKeyId; - cancellationToken.ThrowIfCancellationRequested(); + public Result?> Search( + Expression>? 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>.Ok( - new PagedQueryResult([], 0, page, size))); + logger.LogDebug("Api key entity scope search is not persisted in Certs; returning empty list."); + return Result?>.Ok([]); + } + + public Result Count(Expression>? predicate) { + _ = predicate; + if (logger.IsEnabled(LogLevel.Debug)) + logger.LogDebug("Api key entity scope count is not persisted in Certs; returning 0."); + return Result.Ok(0); } } diff --git a/src/MaksIT.CertsUI.Engine/QueryServices/Linq2Db/Identity/ApiKeyQueryServiceLinq2Db.cs b/src/MaksIT.CertsUI.Engine/QueryServices/Linq2Db/Identity/ApiKeyQueryServiceLinq2Db.cs index b153e13..cf0dacc 100644 --- a/src/MaksIT.CertsUI.Engine/QueryServices/Linq2Db/Identity/ApiKeyQueryServiceLinq2Db.cs +++ b/src/MaksIT.CertsUI.Engine/QueryServices/Linq2Db/Identity/ApiKeyQueryServiceLinq2Db.cs @@ -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; /// -/// Linq2Db-based implementation of . +/// Linq2Db-based implementation of (Vault-style predicates on ). /// public class ApiKeyQueryServiceLinq2Db(ILogger logger, ICertsDataConnectionFactory connectionFactory) : IApiKeyQueryService { private readonly ILogger _logger = logger; private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory; - public Task>> SearchApiKeysAsync( - string? descriptionFilter, - int pageNumber, - int pageSize, - CancellationToken cancellationToken = default) { - _ = cancellationToken; + public Result?> Search( + Expression>? 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(); - var filtered = string.IsNullOrWhiteSpace(filter) - ? table - : table.Where(k => (k.Description ?? string.Empty).Contains(filter)); + var query = db.GetTable().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>.Ok(new PagedQueryResult( - data, - total, - page, - size - ))); + if (limit.HasValue) + query = query.Take(limit.Value); + + var rows = query.ToList(); + var results = rows.Select(MapToQueryResult).ToList(); + return Result?>.Ok(results); } catch (Exception ex) { _logger.LogError(ex, "Error occurred while searching API keys."); - return Task.FromResult(Result>.InternalServerError(null, [.. ex.ExtractMessages()])); + return Result?>.InternalServerError(null, [.. ex.ExtractMessages()]); + } + } + + public Result Count(Expression>? apiKeysPredicate) { + try { + using var db = _connectionFactory.Create(); + var query = db.GetTable().AsQueryable(); + if (apiKeysPredicate != null) + query = query.Where(apiKeysPredicate); + + return Result.Ok(query.Count()); + } + catch (Exception ex) { + _logger.LogError(ex, "Error occurred while counting API keys."); + return Result.InternalServerError(null, [.. ex.ExtractMessages()]); } } diff --git a/src/MaksIT.CertsUI.Engine/QueryServices/Linq2Db/Identity/UserQueryServiceLinq2Db.cs b/src/MaksIT.CertsUI.Engine/QueryServices/Linq2Db/Identity/UserQueryServiceLinq2Db.cs index 23482ed..8d4b91a 100644 --- a/src/MaksIT.CertsUI.Engine/QueryServices/Linq2Db/Identity/UserQueryServiceLinq2Db.cs +++ b/src/MaksIT.CertsUI.Engine/QueryServices/Linq2Db/Identity/UserQueryServiceLinq2Db.cs @@ -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; /// -/// Linq2Db-based implementation of . +/// Linq2Db-based implementation of (Vault-style predicates on ). /// public class UserQueryServiceLinq2Db(ILogger logger, ICertsDataConnectionFactory connectionFactory) : IUserQueryService { private readonly ILogger _logger = logger; private readonly ICertsDataConnectionFactory _connectionFactory = connectionFactory; - public Task>> SearchUsersAsync( - string? usernameFilter, - int pageNumber, - int pageSize, - CancellationToken cancellationToken = default) { - _ = cancellationToken; + public Result?> Search( + Expression>? 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(); - var filtered = string.IsNullOrWhiteSpace(filter) - ? table - : table.Where(u => u.Name.Contains(filter!)); + var query = db.GetTable().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 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>.Ok(new PagedQueryResult( - data, - total, - page, - size - ))); + var results = rows.Select(r => MapToQueryResult(r, recoveryCountByUser.GetValueOrDefault(r.Id))).ToList(); + return Result?>.Ok(results); } catch (Exception ex) { _logger.LogError(ex, "Error occurred while searching users."); - return Task.FromResult(Result>.InternalServerError(null, [.. ex.ExtractMessages()])); + return Result?>.InternalServerError(null, [.. ex.ExtractMessages()]); + } + } + + public Result Count(Expression>? usersPredicate) { + try { + using var db = _connectionFactory.Create(); + var query = db.GetTable().AsQueryable(); + if (usersPredicate != null) + query = query.Where(usersPredicate); + + return Result.Ok(query.Count()); + } + catch (Exception ex) { + _logger.LogError(ex, "Error occurred while counting users."); + return Result.InternalServerError(null, [.. ex.ExtractMessages()]); } } diff --git a/src/MaksIT.CertsUI.Engine/Services/AcmePostgresSessionStore.cs b/src/MaksIT.CertsUI.Engine/Services/AcmePostgresSessionStore.cs deleted file mode 100644 index e509c89..0000000 --- a/src/MaksIT.CertsUI.Engine/Services/AcmePostgresSessionStore.cs +++ /dev/null @@ -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; - -/// PostgreSQL-backed ACME session state (replaces in-process IMemoryCache). -public sealed class AcmePostgresSessionStore( - ICertsEngineConfiguration config, - ILogger 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 LoadOrCreateAsync(Guid sessionId, CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - using var db = CreateConnection(); - var now = DateTimeOffset.UtcNow; - var row = db.GetTable() - .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() - .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; - } -} diff --git a/src/MaksIT.CertsUI.Engine/Services/AcmeSessionJsonSerializer.cs b/src/MaksIT.CertsUI.Engine/Services/AcmeSessionJsonSerializer.cs deleted file mode 100644 index e6ecca7..0000000 --- a/src/MaksIT.CertsUI.Engine/Services/AcmeSessionJsonSerializer.cs +++ /dev/null @@ -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(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; - } -} diff --git a/src/MaksIT.CertsUI.Engine/Services/AcmeSessionSnapshot.cs b/src/MaksIT.CertsUI.Engine/Services/AcmeSessionSnapshot.cs deleted file mode 100644 index 73b220c..0000000 --- a/src/MaksIT.CertsUI.Engine/Services/AcmeSessionSnapshot.cs +++ /dev/null @@ -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; - -/// JSON-serializable projection of for acme_sessions.payload_json. -internal sealed class AcmeSessionSnapshot { - public bool IsStaging { get; set; } - public AcmeDirectory? Directory { get; set; } - public Order? CurrentOrder { get; set; } - public List Challenges { get; set; } = []; - public RegistrationCache? Cache { get; set; } - public Jwk? Jwk { get; set; } - /// RSA account key as CSP blob when present (same encoding as ). - public byte[]? AccountKeyCspBlob { get; set; } -} diff --git a/src/MaksIT.CertsUI.Engine/Services/IAcmeSessionStore.cs b/src/MaksIT.CertsUI.Engine/Services/IAcmeSessionStore.cs deleted file mode 100644 index 67db18d..0000000 --- a/src/MaksIT.CertsUI.Engine/Services/IAcmeSessionStore.cs +++ /dev/null @@ -1,9 +0,0 @@ -using MaksIT.CertsUI.Engine.Domain.LetsEncrypt; - -namespace MaksIT.CertsUI.Engine.Services; - -/// Loads and persists per-browser ACME so any replica can continue the flow. -public interface IAcmeSessionStore { - Task LoadOrCreateAsync(Guid sessionId, CancellationToken cancellationToken = default); - Task PersistAsync(Guid sessionId, State state, CancellationToken cancellationToken = default); -} diff --git a/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.Helpers.cs b/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.Helpers.cs index 29f779c..a0acf58 100644 --- a/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.Helpers.cs +++ b/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.Helpers.cs @@ -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 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 WithPersistedSessionAsync( Guid sessionId, CancellationToken cancellationToken, Func> 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>> 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); } } diff --git a/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.cs b/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.cs index bdd28cc..3863477 100644 --- a/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.cs +++ b/src/MaksIT.CertsUI.Engine/Services/LetsEncryptService.cs @@ -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 _logger; private readonly ICertsEngineConfiguration _engineConfiguration; private readonly HttpClient _httpClient; - private readonly IAcmeSessionStore _sessionStore; + private readonly IAcmeSessionPersistanceService _acmeSessionPersistence; public LetsEncryptService( ILogger logger, ICertsEngineConfiguration engineConfiguration, HttpClient httpClient, - IAcmeSessionStore sessionStore + IAcmeSessionPersistanceService acmeSessionPersistence ) { _logger = logger; _engineConfiguration = engineConfiguration; _httpClient = httpClient; - _sessionStore = sessionStore; + _acmeSessionPersistence = acmeSessionPersistence; } public Task> GetRegistrationCacheAsync(Guid sessionId, CancellationToken cancellationToken = default) => diff --git a/src/MaksIT.CertsUI.Tests/Infrastructure/InMemoryUserStore.cs b/src/MaksIT.CertsUI.Tests/Infrastructure/InMemoryUserStore.cs index 9f4c64e..620c79f 100644 --- a/src/MaksIT.CertsUI.Tests/Infrastructure/InMemoryUserStore.cs +++ b/src/MaksIT.CertsUI.Tests/Infrastructure/InMemoryUserStore.cs @@ -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>.Ok([.. _users])); } - public Task>> SearchUsersAsync( - string? usernameFilter, - int pageNumber, - int pageSize, - CancellationToken cancellationToken = default) { + public Result Count(Expression>? usersPredicate) { lock (_lock) { - IEnumerable 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>.Ok(new PagedQueryResult( - data, - total, - page, - size - ))); + var q = _users.Select(ToUserDto).AsQueryable(); + if (usersPredicate != null) + q = q.Where(usersPredicate); + return Result.Ok(q.Count()); } } + public Result?> Search( + Expression>? 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?>.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> GetByIdAsync(Guid id, CancellationToken cancellationToken = default) { lock (_lock) { var u = _users.FirstOrDefault(x => x.Id == id); diff --git a/src/MaksIT.CertsUI.Tests/Services/AccountServicePatchAccountIntegrationTests.cs b/src/MaksIT.CertsUI.Tests/Services/AccountServicePatchAccountIntegrationTests.cs index 1839f4e..38391a3 100644 --- a/src/MaksIT.CertsUI.Tests/Services/AccountServicePatchAccountIntegrationTests.cs +++ b/src/MaksIT.CertsUI.Tests/Services/AccountServicePatchAccountIntegrationTests.cs @@ -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.Instance, pg.ConnectionFactory); - var cacheSvc = new CacheService(NullLogger.Instance, fx.AppOptions, cachePersistence); + var cacheDomain = new RegistrationCacheDomainService(NullLogger.Instance, cachePersistence); + var cacheSvc = new CacheService(NullLogger.Instance, fx.AppOptions, cacheDomain); await cacheSvc.SaveToCacheAsync(accountId, reg); var cacheMock = new Mock(); diff --git a/src/MaksIT.CertsUI.Tests/Services/ApiKeyQueryServiceIntegrationTests.cs b/src/MaksIT.CertsUI.Tests/Services/ApiKeyQueryServiceIntegrationTests.cs index c158ba7..5a74d7a 100644 --- a/src/MaksIT.CertsUI.Tests/Services/ApiKeyQueryServiceIntegrationTests.cs +++ b/src/MaksIT.CertsUI.Tests/Services/ApiKeyQueryServiceIntegrationTests.cs @@ -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); } } diff --git a/src/MaksIT.CertsUI.Tests/Services/CacheServiceTests.cs b/src/MaksIT.CertsUI.Tests/Services/CacheServiceTests.cs index ff84ddf..93f66ff 100644 --- a/src/MaksIT.CertsUI.Tests/Services/CacheServiceTests.cs +++ b/src/MaksIT.CertsUI.Tests/Services/CacheServiceTests.cs @@ -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.Instance, - pg.Config.AppOptions, - new RegistrationCachePersistanceServiceLinq2Db(NullLogger.Instance, pg.ConnectionFactory) - ); + private CacheService CreateSut() { + var persistence = new RegistrationCachePersistanceServiceLinq2Db(NullLogger.Instance, pg.ConnectionFactory); + var domain = new RegistrationCacheDomainService(NullLogger.Instance, persistence); + return new CacheService(NullLogger.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(); diff --git a/src/MaksIT.CertsUI.Tests/Services/CertsFlowServiceTests.cs b/src/MaksIT.CertsUI.Tests/Services/CertsFlowServiceTests.cs index 15aacf4..2f4ecf8 100644 --- a/src/MaksIT.CertsUI.Tests/Services/CertsFlowServiceTests.cs +++ b/src/MaksIT.CertsUI.Tests/Services/CertsFlowServiceTests.cs @@ -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(); + var registrationDomain = new RegistrationCacheDomainService( + NullLogger.Instance, + registrationCache.Object); agent ??= new Mock(); var tosCacheProvided = termsOfServiceCache is not null; termsOfServiceCache ??= new Mock(); @@ -69,7 +72,7 @@ public sealed class CertsFlowServiceTests NullLogger.Instance, httpClient, le.Object, - registrationCache.Object, + registrationDomain, agent.Object, new TestCertsFlowEngineConfiguration(fx), termsOfServiceCache.Object, diff --git a/src/MaksIT.CertsUI/HostedServices/AutoRenewal.cs b/src/MaksIT.CertsUI/HostedServices/AutoRenewal.cs index ddde2e2..14abf14 100644 --- a/src/MaksIT.CertsUI/HostedServices/AutoRenewal.cs +++ b/src/MaksIT.CertsUI/HostedServices/AutoRenewal.cs @@ -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(); + var registrationCacheDomain = scope.ServiceProvider.GetRequiredService(); + var certsFlowDomain = scope.ServiceProvider.GetRequiredService(); var certsFlowService = scope.ServiceProvider.GetRequiredService(); - var httpChallenges = scope.ServiceProvider.GetRequiredService(); - 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); diff --git a/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj b/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj index 9bd8d90..34ada3e 100644 --- a/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj +++ b/src/MaksIT.CertsUI/MaksIT.CertsUI.csproj @@ -1,7 +1,7 @@ - 3.4.0 + 3.4.1 net10.0 Linux ..\docker-compose.dcproj diff --git a/src/MaksIT.CertsUI/Services/AccoutService.cs b/src/MaksIT.CertsUI/Services/AccoutService.cs index 8d74dd5..8e60bb9 100644 --- a/src/MaksIT.CertsUI/Services/AccoutService.cs +++ b/src/MaksIT.CertsUI/Services/AccoutService.cs @@ -98,7 +98,7 @@ public class AccountService( if (requestData.Description == null) return PatchFieldIsNotDefined(nameof(requestData.Description)); - cache.Description = requestData.Description; + cache.Description = requestData.Description; break; default: return UnsupportedPatchOperationResponse(); diff --git a/src/MaksIT.CertsUI/Services/ApiKeyService.cs b/src/MaksIT.CertsUI/Services/ApiKeyService.cs index c99ce0f..c91a0ac 100644 --- a/src/MaksIT.CertsUI/Services/ApiKeyService.cs +++ b/src/MaksIT.CertsUI/Services/ApiKeyService.cs @@ -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?>(_ => null); + var filter = requestData.DescriptionFilter?.Trim(); + Expression>? 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?>(_ => null); + + var searchResult = apiKeyQueryService.Search(predicate, skip, size); + if (!searchResult.IsSuccess || searchResult.Value == null) + return searchResult.ToResultOfType?>(_ => null); + + var total = countResult.Value ?? 0; + var list = searchResult.Value; return Result?>.Ok(new PagedResponse { - 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?>(_ => null); + Expression>? 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?>(_ => null); + + var searchResult = apiKeyEntityScopeQueryService.Search(predicate, skip, size); + if (!searchResult.IsSuccess || searchResult.Value == null) + return searchResult.ToResultOfType?>(_ => null); + + var total = countResult.Value ?? 0; + var list = searchResult.Value; return Result?>.Ok(new PagedResponse { - 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, }); } diff --git a/src/MaksIT.CertsUI/Services/CacheService.cs b/src/MaksIT.CertsUI/Services/CacheService.cs index 499c374..5a1e315 100644 --- a/src/MaksIT.CertsUI/Services/CacheService.cs +++ b/src/MaksIT.CertsUI/Services/CacheService.cs @@ -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 DeleteAccountCacheAsync(Guid accountId); } +/// Web API façade for registration cache operations; delegates to . public class CacheService( ILogger logger, IOptions appSettings, - IRegistrationCachePersistanceService registrationCachePersistence + IRegistrationCacheDomainService registrationCacheDomain ) : ServiceBase( logger, appSettings ), ICacheService { - public Task> LoadAccountsFromCacheAsync() { - return registrationCachePersistence.LoadAllAsync(); - } + public Task> LoadAccountsFromCacheAsync() => + registrationCacheDomain.LoadAllAsync(); public Task> LoadAccountFromCacheAsync(Guid accountId) => - registrationCachePersistence.LoadAsync(accountId); + registrationCacheDomain.LoadAsync(accountId); public Task SaveToCacheAsync(Guid accountId, RegistrationCache cache) => - registrationCachePersistence.SaveAsync(accountId, cache); + registrationCacheDomain.SaveAsync(accountId, cache); - public async Task> DownloadCacheZipAsync() { - try { - var allResult = await registrationCachePersistence.LoadAllAsync(); - if (!allResult.IsSuccess || allResult.Value == null) - return Result.InternalServerError(null, allResult.Messages?.ToArray() ?? ["Could not load registration caches."]); + public Task> 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.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.Ok(zipBytes); - } - catch (Exception ex) { - var message = "Error creating registration cache zip."; - logger.LogError(ex, message); - return Result.InternalServerError(null, [message, .. ex.ExtractMessages()]); - } - } + public Task> DownloadAccountCacheZipAsync(Guid accountId) => + registrationCacheDomain.DownloadAccountCacheZipAsync(accountId); - public async Task> 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.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.Ok(zipBytes); - } - catch (Exception ex) { - var message = "Error creating account registration cache zip."; - logger.LogError(ex, message); - return Result.InternalServerError(null, [message, .. ex.ExtractMessages()]); - } - } + public Task UploadCacheZipAsync(byte[] zipBytes) => + registrationCacheDomain.UploadCacheZipAsync(zipBytes); - public Task 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 UploadAccountCacheZipAsync(Guid accountId, byte[] zipBytes) => + registrationCacheDomain.UploadAccountCacheZipAsync(accountId, zipBytes); - public Task 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 DeleteCacheAsync() => + registrationCacheDomain.DeleteAllAsync(); - private async Task ImportZipEntriesAsync(IReadOnlyList 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(); - 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 DeleteCacheAsync() => registrationCachePersistence.DeleteAllAsync(); - - public Task DeleteAccountCacheAsync(Guid accountId) => registrationCachePersistence.DeleteAsync(accountId); + public Task DeleteAccountCacheAsync(Guid accountId) => + registrationCacheDomain.DeleteAsync(accountId); } diff --git a/src/MaksIT.CertsUI/Services/IdentityService.cs b/src/MaksIT.CertsUI/Services/IdentityService.cs index 1c7ab58..a341369 100644 --- a/src/MaksIT.CertsUI/Services/IdentityService.cs +++ b/src/MaksIT.CertsUI/Services/IdentityService.cs @@ -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>> SearchUsersAsync(JwtTokenData jwtTokenData, SearchUserRequest requestData); + Task?>> SearchUsersAsync(JwtTokenData jwtTokenData, SearchUserRequest requestData); Task> ReadUserAsync(JwtTokenData jwtTokenData, Guid id); Task> PostUserAsync(JwtTokenData jwtTokenData, CreateUserRequest requestData); Task> PatchUserAsync(JwtTokenData jwtTokenData, Guid id, PatchUserRequest requestData); @@ -37,27 +39,42 @@ public sealed class IdentityService( private readonly ITwoFactorSettingsConfiguration _twoFactorSettings = twoFactorSettings; - public async Task>> SearchUsersAsync(JwtTokenData _jwtTokenData, SearchUserRequest requestData) { + public Task?>> 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>? 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>(_ => new MaksIT.Models.LetsEncryptServer.Common.PagedResponse { + var skip = (page - 1) * size; + var countResult = userQueryService.Count(predicate); + if (!countResult.IsSuccess) + return Task.FromResult(countResult.ToResultOfType?>(_ => new MaksIT.Models.LetsEncryptServer.Common.PagedResponse { Data = [], TotalRecords = 0, PageNumber = page, PageSize = size, - })!; + })!); - var paged = query.Value; - return Result>.Ok(new MaksIT.Models.LetsEncryptServer.Common.PagedResponse { - 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?>(_ => new MaksIT.Models.LetsEncryptServer.Common.PagedResponse { + Data = [], + TotalRecords = 0, + PageNumber = page, + PageSize = size, + })!); + + var total = countResult.Value ?? 0; + var list = searchResult.Value; + return Task.FromResult(Result?>.Ok(new MaksIT.Models.LetsEncryptServer.Common.PagedResponse { + Data = [.. list.Select(userToResponseMapper.MapToSearchResponse)], + TotalRecords = total, + PageNumber = page, + PageSize = size, + })); } public async Task> ReadUserAsync(JwtTokenData _jwtTokenData, Guid id) { diff --git a/src/MaksIT.WebUI/src/axiosConfig.ts b/src/MaksIT.WebUI/src/axiosConfig.ts index 2082e95..cffb9be 100644 --- a/src/MaksIT.WebUI/src/axiosConfig.ts +++ b/src/MaksIT.WebUI/src/axiosConfig.ts @@ -13,6 +13,12 @@ interface RequestOptions { skipLoader?: boolean } +interface ApiResponse { + 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 ( url: string, timeout?: number, options?: RequestOptions -): Promise => { +): Promise> => { try { const config: any = { headers: { @@ -180,13 +186,13 @@ const getData = async ( } const response = await axiosInstance.get(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 ( data?: TRequest, timeout?: number, options?: RequestOptions -): Promise => { +): Promise> => { try { const config: any = { headers: { @@ -213,14 +219,13 @@ const postData = async ( } const response = await axiosInstance.post(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 ( data: TRequest, timeout?: number, options?: RequestOptions -): Promise => { +): Promise> => { try { const config: any = { headers: { @@ -247,12 +252,12 @@ const patchData = async ( } const response = await axiosInstance.patch(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 ( data: TRequest, timeout?: number, options?: RequestOptions -): Promise => { +): Promise> => { try { const config: any = { headers: { @@ -280,12 +285,12 @@ const putData = async ( } const response = await axiosInstance.put(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 ( url: string, timeout?: number, options?: RequestOptions -): Promise => { +): Promise> => { try { const config: any = { headers: { @@ -311,13 +316,13 @@ const deleteData = async ( } const response = await axiosInstance.delete(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 ( data: Blob | ArrayBuffer | Uint8Array, timeout?: number, options?: RequestOptions -): Promise => { +): Promise> => { try { const config: any = { headers: { @@ -344,13 +349,13 @@ const postBinary = async ( } const response = await axiosInstance.post(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 } | undefined> => { +): Promise }>> => { 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 + payload: { + data: response.data, + headers: response.headers as Record + }, + 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 ( form: FormData | Record, timeout?: number, options?: RequestOptions -): Promise => { +): Promise> => { try { const formData = form instanceof FormData @@ -428,14 +437,13 @@ const postFormData = async ( } const response = await axiosInstance.post(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 ( extraFields?: Record, timeout?: number, options?: RequestOptions -): Promise => { +): Promise> => { 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 ( return postFormData(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 ( url: string, timeout?: number -): Promise => +): Promise> => getData(url, timeout, noLoaderOptions) /** @@ -485,10 +494,11 @@ const postDataWithoutLoader = async ( url: string, data?: TRequest, timeout?: number -): Promise => +): Promise> => postData(url, data, timeout, noLoaderOptions) export { + type ApiResponse, axiosInstance, getData, postData, diff --git a/src/MaksIT.WebUI/src/components/DataTable/DataTableFilter.tsx b/src/MaksIT.WebUI/src/components/DataTable/DataTableFilter.tsx index 77f26cb..b94f48d 100644 --- a/src/MaksIT.WebUI/src/components/DataTable/DataTableFilter.tsx +++ b/src/MaksIT.WebUI/src/components/DataTable/DataTableFilter.tsx @@ -58,9 +58,9 @@ const DataTableFilter = (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) diff --git a/src/MaksIT.WebUI/src/components/DataTable/DataTableLabel.tsx b/src/MaksIT.WebUI/src/components/DataTable/DataTableLabel.tsx index 6932fb9..a6b8197 100644 --- a/src/MaksIT.WebUI/src/components/DataTable/DataTableLabel.tsx +++ b/src/MaksIT.WebUI/src/components/DataTable/DataTableLabel.tsx @@ -45,9 +45,9 @@ const DataTableLabel = (props: LabelProps) = getDataWithoutLoader(route) .then(response => { - if (!response) return + if (!response.ok || !response.payload) return - setRemoteLabel(response[accessorKey]) + setRemoteLabel(response.payload[accessorKey]) }).finally(() => {}) }, [props]) diff --git a/src/MaksIT.WebUI/src/components/editors/FileUploadComponent.tsx b/src/MaksIT.WebUI/src/components/editors/FileUploadComponent.tsx index d3dd35f..1ee1cb3 100644 --- a/src/MaksIT.WebUI/src/components/editors/FileUploadComponent.tsx +++ b/src/MaksIT.WebUI/src/components/editors/FileUploadComponent.tsx @@ -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 = ({ label = 'Select files', colspan = 6, multiple = true, + files, onChange, disabled = false, }) => { @@ -30,16 +32,47 @@ const FileUploadComponent: React.FC = ({ } }, [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) => { - 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 = ({ 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' : ''} - {showPopup && selectedFiles.length > 0 && ( + {showPopup && displayFiles.length > 0 && (
= ({ onFocus={() => {}} >
    - {selectedFiles.map((file, idx) => ( + {displayFiles.map((file, idx) => (
  • {file.name}
  • @@ -127,7 +160,7 @@ const FileUploadComponent: React.FC = ({ diff --git a/src/MaksIT.WebUI/src/components/editors/RemoteSelectBoxComponent.tsx b/src/MaksIT.WebUI/src/components/editors/RemoteSelectBoxComponent.tsx index 6c0db6c..dbd34e9 100644 --- a/src/MaksIT.WebUI/src/components/editors/RemoteSelectBoxComponent.tsx +++ b/src/MaksIT.WebUI/src/components/editors/RemoteSelectBoxComponent.tsx @@ -70,8 +70,8 @@ const RemoteSelectBoxComponent = (props: RemoteSe postData>(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) diff --git a/src/MaksIT.WebUI/src/components/editors/SecretComponent.tsx b/src/MaksIT.WebUI/src/components/editors/SecretComponent.tsx index 36d22e4..89a6dd3 100644 --- a/src/MaksIT.WebUI/src/components/editors/SecretComponent.tsx +++ b/src/MaksIT.WebUI/src/components/editors/SecretComponent.tsx @@ -50,10 +50,10 @@ const SecretComponent: FC = (props) => { const handleGenerateSecret = () => { getData(`${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 handleOnChange(fakeEvent) diff --git a/src/MaksIT.WebUI/src/forms/ApiKeys/CreateApiKey.tsx b/src/MaksIT.WebUI/src/forms/ApiKeys/CreateApiKey.tsx index 5da2451..09bd897 100644 --- a/src/MaksIT.WebUI/src/forms/ApiKeys/CreateApiKey.tsx +++ b/src/MaksIT.WebUI/src/forms/ApiKeys/CreateApiKey.tsx @@ -77,9 +77,9 @@ const CreateApiKey: FC = (props) => { postData(GetApiRoute(ApiRoutes.apikeyPost).route, request.data) .then(response => { - if (!response) return + if (!response.ok || !response.payload) return - onSubmitted?.(response) + onSubmitted?.(response.payload) }) } diff --git a/src/MaksIT.WebUI/src/forms/ApiKeys/EditApiKey.tsx b/src/MaksIT.WebUI/src/forms/ApiKeys/EditApiKey.tsx index 16c4e33..25f0139 100644 --- a/src/MaksIT.WebUI/src/forms/ApiKeys/EditApiKey.tsx +++ b/src/MaksIT.WebUI/src/forms/ApiKeys/EditApiKey.tsx @@ -125,7 +125,7 @@ const EditApiKey: FC = (props) => { getData(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 = (props) => { return } - handleInitialization(response) + handleInitialization(response.payload) }) .finally(() => { setHasLoaded(true) @@ -169,10 +169,10 @@ const EditApiKey: FC = (props) => { patchData(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) }) } diff --git a/src/MaksIT.WebUI/src/forms/ApiKeys/SearchApiKey.tsx b/src/MaksIT.WebUI/src/forms/ApiKeys/SearchApiKey.tsx index 05b151c..6bc1373 100644 --- a/src/MaksIT.WebUI/src/forms/ApiKeys/SearchApiKey.tsx +++ b/src/MaksIT.WebUI/src/forms/ApiKeys/SearchApiKey.tsx @@ -95,7 +95,7 @@ const SearchApiKey: FC = () => { const loadData = useCallback(() => { postData>(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 = () => { diff --git a/src/MaksIT.WebUI/src/forms/EditAccount.tsx b/src/MaksIT.WebUI/src/forms/EditAccount.tsx index 6eb6577..37609cc 100644 --- a/src/MaksIT.WebUI/src/forms/EditAccount.tsx +++ b/src/MaksIT.WebUI/src/forms/EditAccount.tsx @@ -111,9 +111,9 @@ const EditAccount: FC = (props) => { getData(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 = (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) } diff --git a/src/MaksIT.WebUI/src/forms/Home.tsx b/src/MaksIT.WebUI/src/forms/Home.tsx index cc7f0fe..75d4a3c 100644 --- a/src/MaksIT.WebUI/src/forms/Home.tsx +++ b/src/MaksIT.WebUI/src/forms/Home.tsx @@ -17,8 +17,8 @@ const Home: FC = () => { const loadData = useCallback(() => { getData(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( 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(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') }) } diff --git a/src/MaksIT.WebUI/src/forms/LetsEncryptTermsOfService.tsx b/src/MaksIT.WebUI/src/forms/LetsEncryptTermsOfService.tsx index 8110e9f..a060b82 100644 --- a/src/MaksIT.WebUI/src/forms/LetsEncryptTermsOfService.tsx +++ b/src/MaksIT.WebUI/src/forms/LetsEncryptTermsOfService.tsx @@ -20,15 +20,15 @@ const LetsEncryptTermsOfService: FC = () => { isStaging: true }) .then(response => { - if (!response) return + if (!response.ok || !response.payload) return return getData( - 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.') } diff --git a/src/MaksIT.WebUI/src/forms/Register.tsx b/src/MaksIT.WebUI/src/forms/Register.tsx index a709391..955f9bb 100644 --- a/src/MaksIT.WebUI/src/forms/Register.tsx +++ b/src/MaksIT.WebUI/src/forms/Register.tsx @@ -137,7 +137,7 @@ const Register: FC = () => { 120000 ) - if (!response) return + if (!response.ok) return navigate('/') } diff --git a/src/MaksIT.WebUI/src/forms/Users/CreateUser.tsx b/src/MaksIT.WebUI/src/forms/Users/CreateUser.tsx index 1150904..c4cd84b 100644 --- a/src/MaksIT.WebUI/src/forms/Users/CreateUser.tsx +++ b/src/MaksIT.WebUI/src/forms/Users/CreateUser.tsx @@ -76,9 +76,9 @@ const CreateUser: FC = (props) => { postData(GetApiRoute(ApiRoutes.identityPost).route, request.data) .then((response) => { - if (!response) return + if (!response.ok || !response.payload) return setInitialState(createUserFormPropsProto()) - onSubmitted?.(response) + onSubmitted?.(response.payload) }) } diff --git a/src/MaksIT.WebUI/src/forms/Users/EditUser/ChangePassword.tsx b/src/MaksIT.WebUI/src/forms/Users/EditUser/ChangePassword.tsx index 14d15f2..72145f3 100644 --- a/src/MaksIT.WebUI/src/forms/Users/EditUser/ChangePassword.tsx +++ b/src/MaksIT.WebUI/src/forms/Users/EditUser/ChangePassword.tsx @@ -61,10 +61,10 @@ const ChangePassword: FC = (props) => { data ) - if (!response) return + if (!response.ok || !response.payload) return addToast('Password updated.', 'success') - onSubmitted?.(response) + onSubmitted?.(response.payload) handleOnClose() } diff --git a/src/MaksIT.WebUI/src/forms/Users/EditUser/index.tsx b/src/MaksIT.WebUI/src/forms/Users/EditUser/index.tsx index 20edfcd..a0a3aa2 100644 --- a/src/MaksIT.WebUI/src/forms/Users/EditUser/index.tsx +++ b/src/MaksIT.WebUI/src/forms/Users/EditUser/index.tsx @@ -48,11 +48,11 @@ const EditUser: FC = (props) => { const handleLoad = useCallback(() => { getData(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 = (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 = (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( 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) }) } }} diff --git a/src/MaksIT.WebUI/src/forms/Users/SearchUser.tsx b/src/MaksIT.WebUI/src/forms/Users/SearchUser.tsx index 9a3aff9..49a7173 100644 --- a/src/MaksIT.WebUI/src/forms/Users/SearchUser.tsx +++ b/src/MaksIT.WebUI/src/forms/Users/SearchUser.tsx @@ -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) => { deleteData(GetApiRoute(ApiRoutes.identityDelete).route.replace('{userId}', ids.id)) - .then(() => loadData()) + .then((response) => { + if (!response.ok) return + loadData() + }) } const handleEditCancel = () => { diff --git a/src/MaksIT.WebUI/src/forms/Utilities.tsx b/src/MaksIT.WebUI/src/forms/Utilities.tsx index 55ffd74..8d72d28 100644 --- a/src/MaksIT.WebUI/src/forms/Utilities.tsx +++ b/src/MaksIT.WebUI/src/forms/Utilities.tsx @@ -16,9 +16,9 @@ const Utilities: FC = () => { const hadnleTestAgent = () => { getData(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} /> - + { const apiRoute = GetApiRoute(ApiRoutes.identityLogin) const response = await postData(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 } )