This commit is contained in:
HueByte
2026-07-23 09:48:40 +00:00
parent 37bd8c0f57
commit 32c664518a
144 changed files with 5098 additions and 6498 deletions
@@ -8,11 +8,4 @@ public class JwtTokenService
```
JwtTokenService centralizes the creation of JSON Web Tokens used for authenticating API requests. It reads the signing secret, issuer, and audience from configuration and exposes two overloads of GenerateAccessToken for User and UserProfileDto, returning the token string along with its expiration timestamp. Each generated token includes standard claims such as sub (the user/profile id), username, display_name (falling back to username if not provided), role, and a unique jti, and is signed with HmacSha256 using the configured secret. Access tokens expire after 15 minutes, while a companion refresh token can be generated with GenerateRefreshToken and hashed with HashToken for secure storage.
## Remarks
JwtTokenService centralizes token creation, ensuring consistent signing, claims, and expiry semantics across authentication flows. By loading Jwt:Secret, Jwt:Issuer, and Jwt:Audience from configuration in one place, it reduces the risk of mismatched values and scattered configuration access. The two overloads for GenerateAccessToken allow tokens to be produced from either a User or a UserProfileDto while preserving a uniform JWT shape and claims set, including a unique jti for traceability.
## Notes
- If Jwt:Secret, Jwt:Issuer, or Jwt:Audience is missing from configuration, the constructor throws an InvalidOperationException with a clear message, preventing startup with a misconfigured token engine.
- GenerateRefreshToken produces a cryptographically random 64-byte value and returns it as a base64 string; HashToken provides a SHA-256-based digest suitable for secure, persisted storage. The class itself does not persist refresh tokens, so you should implement storage and revocation logic in your authentication flow if needed.
JwtTokenService centralizes creation of JWTs for API authentication: it issues short-lived access tokens via `GenerateAccessToken` (from a [`User`](../../EchoHub.Core/Models/User.cs.md) or a [`UserProfileDto`](../../EchoHub.Core/DTOs/ProfileDtos.cs.md)) and provides a cryptographically secure refresh token generator via `GenerateRefreshToken` (and `HashToken` to store a hashed form). It reads its secret, issuer, and audience from configuration and enforces token lifetimes defined by `AccessTokenLifetime` and `RefreshTokenLifetime`.
@@ -8,4 +8,11 @@ public sealed class ServerLogsOptions
```
Live server-log room configuration is encapsulated by this strongly-typed options class. It binds to the ServerLogs config section and supports environment overrides, controlling whether the live streaming channel is created and who can view it. When Enabled is true, a read-only system channel is auto-created and log events are streamed in real time; log lines themselves are not stored as messages in the database, with persistence remaining in the rolling Serilog log files.
`ServerLogsOptions` is a configuration model that binds to the `ServerLogs` configuration section (and supports environment overrides via keys like `ServerLogs__Enabled`). When `Enabled` is true, the system automatically creates a read-only system channel named from `RoomName` (default `server-logs`) and streams log events to that channel in real time; log lines are never persisted as messages in the database, with persistence limited to the rolling `Serilog` log files. It exposes several tunables: `RoomName` sets the auto-created channel name and must satisfy the normal channel-name rules; the name is reserved so users cannot create a channel with it. `MinRole` defines the minimum server role that can see and join the log room (default `ServerRole.Mod`); `MinLevel` selects the minimum log event level to stream (default `LogEventLevel.Information`); `BacklogLines` controls how many recent log entries are replayed from the log files when the room is opened (default 100); `LogDirectory` and `LogFilePattern` point to where the rolling log files live and how they are named (defaults `logs` and `echohub-server-*.log`). The derived `NormalizedRoomName` provides a lowercased, trimmed variant of `RoomName` for comparisons.
## Remarks
The `ServerLogsOptions` abstraction centralizes live-log streaming behind a configuration object, separating real-time visibility from persistent message storage. It ensures a consistent, auto-created channel for server logs (named by `RoomName`, default `server-logs`) and uses `MinRole`/`MinLevel` to control who and what they can see, without requiring code changes to enable the feature. The `NormalizedRoomName` aids robust comparisons elsewhere in the system.
## Notes
- Live-streaming may expose sensitive information; ensure `MinRole` and `MinLevel` align with privacy expectations.
- Backlog replay relies on the Serilog file sink configuration; ensure `LogDirectory` exists and matches `LogFilePattern`.
@@ -8,12 +8,30 @@ public sealed class SpamOptions
```
SpamOptions is a configuration object that encapsulates the anti-spam thresholds used by the server. It is bound from the Spam config section and exposes the toggles and numeric limits that govern how the system enforces per-user rate limits, duplicate message handling, auto-muting behavior, and the protections around first-time channel joins and channel creation. The defaults are intentionally lenient so a fast typist wont trip them, and moderators (and above) are exempt from these protections. Use this class to adjust spam-protection policy without changing code.
SpamOptions is a configuration class bound to the 'Spam' config section that stores all anti-spam thresholds. It centralizes rate limits, duplicate suppression, auto-mute behavior, and onboarding quotas so enforcement logic can apply consistent rules; adjust these values here rather than hard-coding them throughout.
## Remarks
SpamOptions centralizes policy decisions for anti-spam enforcement, serving as a single source of truth for the thresholds consumed by the spam protection subsystem. By binding to configuration, it keeps rules out of hard-coded logic and enables runtime tuning via the Spam section. The design separates concerns across rate limiting (per-user messages), duplicate detection, auto-mute behavior, and early channel-join/channel-create protections, making it easier to tune each facet without collateral impact. The auto-mute behavior ties into the existing moderation tooling (MuteExpirationService), illustrating cohesive behavior with the broader user-suspension lifecycle. The note about end-to-end encrypted rooms clarifies that identical plaintext can yield different ciphertext, so the duplicate-detection rule may not apply in those contexts.
SpamOptions acts as the configuration contract for anti-spam behavior. It centralizes all thresholds so the enforcement and moderation subsystems can apply consistent rules without hard-coded values scattered through the codebase. It coordinates rate limiting, duplicate suppression, auto-mute behavior, and first-join/channel-creation limits via a single, testable object that can be configured at startup.
## Example
```csharp
var options = new SpamOptions
{
Enabled = true,
MaxMessagesPerWindow = 12,
WindowSeconds = 10,
MaxDuplicateMessages = 2,
AutoMuteMinutes = 10,
ViolationThreshold = 6,
ViolationWindowMinutes = 3,
MaxJoinsPerWindow = 30,
JoinWindowSeconds = 20,
MaxChannelCreatesPerWindow = 2,
ChannelCreateWindowMinutes = 15
};
```
## Notes
- Auto-mute is controlled by AutoMuteMinutes. Setting AutoMuteMinutes to 0 disables auto-mute (rejections still apply if thresholds are reached).
- MaxMessagesPerWindow and WindowSeconds govern per-user message rate; adjust them with awareness of your typical user pacing to avoid false positives.
- MaxJoinsPerWindow and JoinWindowSeconds apply to first-time channel joins; joins to channels the user already belongs to do not count toward the limit, ensuring normal reconnects dont trigger protections.
- Auto-mute is disabled when `AutoMuteMinutes` is 0; rejections still apply.
- The first-join burst behavior relies on `MaxJoinsPerWindow` being large enough for your public channel count.
- These values are loaded from the config and may be adjusted to balance user experience against protection needs.
@@ -8,12 +8,7 @@ public sealed class StatsOptions
```
StatsOptions is a bound configuration object that governs the periodic server-stats reporter. When Enabled is true, a background job periodically snapshots server activity, logs the snapshot as pretty-printed JSON, and persists it to the database; IntervalHours controls cadence, and RetentionDays controls how long reports are kept. The environment override Stats__Enabled allows turning the reporter on or off via environment configuration without changing code.
StatsOptions is a configuration-bound class that governs the periodic server-stats reporting behavior of the application. It binds from the Stats config section (with environment overrides like Stats__Enabled) and, when Enabled is true, drives a background job that periodically snapshots server activity, logs the snapshot as pretty-printed JSON, and persists it to the database. Developers would adjust IntervalHours to change how often reports are generated and RetentionDays to control how long reports are kept, or toggle Enabled to enable/disable the reporting; defaults are Enabled = true, IntervalHours = 6, and RetentionDays = 90.
## Remarks
StatsOptions serves as a simple, sealed data contract that the configuration system binds to at startup, providing a single source of truth for the reporter settings. Centralizing these knobs here avoids scattering config keys throughout the code and makes it easy to swap configuration providers or add validation in one place. The defaults (Enabled = true, IntervalHours = 6, RetentionDays = 90) define the out-of-the-box behavior and can be overridden by environment or configuration.
## Notes
- RetentionDays: 0 means keep reports indefinitely; any positive number prunes older entries.
- IntervalHours is a double; fractional values (e.g., 1.5) are allowed, but scheduling resolution depends on the hosting environment.
- Enabled acts as the master switch for the background job; disabling it stops snapshots until re-enabled.
This class serves as the configuration object consumed by the background stats collection service, isolating configuration from implementation and enabling the Stats job to be controlled entirely via config.
@@ -8,24 +8,21 @@ public sealed class UploadLimits
```
UploadLimits is a configuration-bound value object that centralizes the admin-defined upload size caps. It reads sizes in megabytes from the Uploads configuration and exposes corresponding byte-sized properties used during enforcement. When the Uploads section is missing or incomplete, the defaults mirror HubConstants to preserve the historical built-in limits.
UploadLimits provides the admin-configurable ceilings for uploads, bound from the `Uploads` configuration section and converted to bytes for enforcement. Values are expressed in megabytes in configuration and exposed as byte-based properties for the enforcement layer; if the `Uploads` section is absent or partial, defaults mirror [`HubConstants`](../../EchoHub.Core/Constants/HubConstants.cs.md) to preserve historical limits.
The class exposes MB-based properties for each category (MaxFileSizeMB, MaxImageSizeMB, MaxAudioSizeMB, MaxAvatarSizeMB) and a per-message attachment cap (MaxAttachmentsPerMessage). It also exposes computed byte-based counterparts (MaxFileSizeBytes, MaxImageSizeBytes, MaxAudioSizeBytes, MaxAvatarSizeBytes) derived from the MB properties. The per-kind limit is exposed via `MaxForKind(AttachmentKind)`, which returns the corresponding byte limit for images, audio, or the general file size for other kinds. Finally, `MaxRequestBodyBytes` represents the absolute ceiling for a single message request body, calculated as `MaxFileSizeBytes * MaxAttachmentsPerMessage`, ensuring that increased configuration actually scales the request payload footprint.
## Remarks
UploadLimits centralizes the policy governing uploads (files, images, audio, avatars) and the maximum number of attachments per message. The MB-based properties feed their byte-sized counterparts (MaxFileSizeBytes, MaxImageSizeBytes, etc.) for enforcement. MaxForKind provides a per-kind ceiling, while MaxRequestBodyBytes computes the overall request-body cap (largest file size multiplied by the attachment limit) to ensure configuration changes actually take effect at the HTTP boundary.
UploadLimits serves as a focused bridge between configuration and enforcement. By centralizing unit conversion (MB to bytes) and collating per-kind and per-message constraints, it reduces the risk of inconsistent bounds across the upload pipeline and makes it straightforward to adjust limits in one place. The design anticipates future extension to additional attachment kinds without altering enforcement sites, while preserving backward-compatible defaults when the configuration is incomplete.
## Example
```csharp
var limits = new UploadLimits
{
MaxFileSizeMB = 64,
MaxAttachmentsPerMessage = 4
};
long maxImageBytes = limits.MaxImageSizeBytes;
long imageCeiling = limits.MaxForKind(AttachmentKind.Image);
long requestBody = limits.MaxRequestBodyBytes;
var limits = new UploadLimits();
long imageBytes = limits.MaxImageSizeBytes;
long imageCapForKind = limits.MaxForKind(AttachmentKind.Image);
```
## Notes
- Changing MaxAttachmentsPerMessage scales the MaxRequestBodyBytes non-linearly; the request-body cap will constrain multipart uploads even if per-file size increases.
- Defaults are tied to HubConstants; if those constants change, the default limits change too unless overridden in the Uploads configuration.
- The `MaxRequestBodyBytes` computation ties the per-attachment cap to the file-size ceiling, so increasing `MaxAttachmentsPerMessage` scales the maximum allowed request body accordingly.
- All byte-based properties are derived from their MB counterparts, so changes to the configuration flow through to enforcement automatically.
- If [`AttachmentKind`](../../EchoHub.Core/Models/AttachmentKind.cs.md) includes kinds beyond Image and Audio, those other kinds fall back to the general `MaxFileSizeBytes` in `MaxForKind`.
@@ -11,11 +11,4 @@ public class AuthController : ControllerBase
```
AuthController is the API surface that coordinates user authentication. It exposes endpoints for registering, logging in, refreshing tokens, and logging out under /api/auth, and ties together user management, JWT token generation, and refresh-token persistence.
## Remarks
AuthController centralizes authentication concerns to enable consistent security policies such as token lifetimes and rotation. It orchestrates between user management (IUserService), token generation (JwtTokenService), and persistence of refresh tokens (EchoHubDbContext), including rotation semantics to revoke old tokens on each refresh.
## Notes
- Refresh token rotation: on a successful refresh, the old token is revoked (RevokedAt is set) and a new token pair is issued. Clients should replace the old token with the new one and avoid reusing the former.
- Security handles: access tokens have shorter lifetimes, refresh tokens are hashed in storage, and all token exchanges occur over HTTPS. Treat tokens as highly sensitive data and store them securely on the client side.
AuthController is an API controller that hosts authentication endpoints under `/api/auth`, handling user registration, login, token refresh, and logout. It coordinates user management via [`IUserService`](../../EchoHub.Core/Contracts/IUserService.cs.md), issues access tokens with [`JwtTokenService`](../Auth/JwtTokenService.cs.md), and persists refresh tokens through the application's EF Core context [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md).
@@ -5,18 +5,18 @@
## Contents
- [ChannelsController](#channelscontroller)
- [ChannelsController (constructor)](#channelscontroller-constructor)
- [CreateChannel](#createchannel)
- [DeleteChannel](#deletechannel)
- [GetChannelCrypto](#getchannelcrypto)
- [GetChannelMeta](#getchannelmeta)
- [GetChannels](#getchannels)
- [MapChannelError](#mapchannelerror)
- [ParseKind](#parsekind)
- [GetChannelMeta](#getchannelmeta)
- [GetChannels](#getchannels)
- [RekeyChannel](#rekeychannel)
- [SendMessageWithAttachments](#sendmessagewithattachments)
- [SendUrl](#sendurl)
- [UpdateTopic](#updatetopic)
- [RekeyChannel](#rekeychannel)
- [SendMessageWithAttachments](#sendmessagewithattachments)
- [SendUrl](#sendurl)
- [UpdateTopic](#updatetopic)
- [ChannelsController (constructor)](#channelscontroller-constructor)
---
@@ -33,19 +33,328 @@ public class ChannelsController : ControllerBase
```
Exposes the HTTP surface for channel-related operations under the route prefix api/channels. Authenticated clients use this controller to list and create channels, retrieve public crypto metadata and human-facing channel summaries, perform passphrase rewraps (rekey), update topics, delete channels, and post messages (including multipart uploads). Prefer calling these endpoints from client code or tests; use the underlying services (IChannelService, IMessageEncryptionService, etc.) directly only when you need to bypass HTTP semantics or perform server-side orchestration.
Exposes the channel-oriented HTTP API beneath `api/channels` for listing, creating, updating and deleting channels, for retrieving channel metadata and public crypto parameters, for changing an encrypted channel's passphrase, and for posting messages (including attachments). Reach for `ChannelsController` when implementing server-side channel management or wiring client HTTP calls: it is the main HTTP surface that enforces authentication, rate limits and upload policies for channel operations.
## Remarks
This controller is a thin HTTP façade that orchestrates several backend services rather than implementing business logic itself. It enforces [Authorize] and configurable rate-limiting (attributes show general and upload policy groups) and delegates persistence, file storage, ASCII preview generation, encryption operations, and chat routing to injected dependencies such as IChannelService, EchoHubDbContext, FileStorageService, ImageToAsciiService, IMessageEncryptionService, IChatService and UploadLimits. Upload size and multipart limits are applied at runtime using the UploadLimits configuration rather than compile-time attributes so the controller can honor configurable limits for large attachments.
`ChannelsController` is a thin HTTP façade that delegates domain work to services such as [`IChannelService`](../../EchoHub.Core/Contracts/IChannelService.cs.md), [`IChatService`](../../EchoHub.Core/Contracts/IChatService.cs.md) and [`IMessageEncryptionService`](../../EchoHub.Core/Contracts/IMessageEncryptionService.cs.md) while persisting metadata via [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md). It centralizes cross-cutting concerns: request authorization (`[Authorize]`), rate limiting (the controller is annotated with `EnableRateLimiting("general")` and the attachment upload endpoint uses `EnableRateLimiting("upload")`), runtime-configured upload limits via the injected [`UploadLimits`](../Config/UploadLimits.cs.md), file handling via [`FileStorageService`](../Services/FileStorageService.cs.md), and image preview generation via [`ImageToAsciiService`](../../EchoHub.Core/Services/ImageToAsciiService.cs.md). The controller intentionally keeps cryptographic secrets off the public endpoints — for example, `GetChannelCrypto` returns only public metadata (including the PBKDF2 salt) and never hands out the wrapped room key; `RekeyChannel` re-wraps a channel's room key without re-encrypting historical messages.
## Notes
- GetChannelCrypto returns public crypto metadata and the PBKDF2 salt clients need to derive a join credential; it never returns the wrapped room key (that is issued only after a successful join).
- RekeyChannel re-wraps the room key to change the passphrase; historical messages are not re-encrypted (the room content key itself does not change).
- SendMessageWithAttachments applies upload limits at runtime from UploadLimits; the controller trusts clients for encrypted-channel attachments (clients must declare each file's kind and provide room-encrypted previews), while for non-encrypted channels the server may inspect files and generate ASCII previews for images.
- The server does not attempt to decrypt or inspect message contents for end-to-end encrypted channels; encrypted attachments must be uploaded as ciphertext and the client must provide the declared `kind` and the room-encrypted `preview` aligned with attachment order. The controller treats those blobs as opaque.
- Request body and multipart limits are applied at runtime from the injected [`UploadLimits`](../Config/UploadLimits.cs.md) rather than using compile-time attributes like `[RequestSizeLimit]`. The implementation raises the request body ceiling from [`UploadLimits`](../Config/UploadLimits.cs.md) before the body is read to support configurable upload maxima.
- `RekeyChannel` changes how the room key is wrapped (the passphrase) but does not re-encrypt existing history — the underlying room content key remains the same, so historical ciphertext is not rewritten.
---
### ChannelsController (constructor)
### CreateChannel
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpPost]
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `request` | [`CreateChannelRequest`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) | — |
**Returns:** `[HttpPost]
public async `Task<IActionResult>``
Creates a new channel for the authenticated user via HTTP POST. It first authenticates by reading the `NameIdentifier` from `ClaimTypes.NameIdentifier` in [`User`](../../EchoHub.Core/Models/User.cs.md); if missing, it returns `Unauthorized` with an [`ErrorResponse`](../../EchoHub.Core/DTOs/CommonDtos.cs.md). On success, it calls `_channelService.CreateChannelAsync` with the parsed GUID from the `NameIdentifier` claim and the fields from `request` (`Name`, `Topic`, `IsPublic`, `Password`, `EncryptionSalt`, `WrappedRoomKey`). If the result indicates failure, it returns the mapped error via `MapChannelError`. If the created channel is public, it notifies clients by calling `_chatService.BroadcastChannelUpdatedAsync`. Finally it returns `Created` with the new channel at `/api/channels/{result.Channel.Name}`.
---
### DeleteChannel
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpDelete("{channel}")]
public async Task<IActionResult> DeleteChannel(string channel)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}"` | — | — |
Deletes a channel for the currently authenticated user. It first validates authentication by reading the `NameIdentifier` claim from [`User`](../../EchoHub.Core/Models/User.cs.md) and returns `Unauthorized` with an [`ErrorResponse`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) if missing; otherwise it calls `_channelService.DeleteChannelAsync` with the parsed `Guid` user id and the provided `channel` name. If the deletion succeeds it broadcasts the channel deletion to the chat subsystem via `_chatService.BroadcastChannelDeletedAsync` (the channel name lowercased and trimmed) and returns `NoContent`; if it fails, it returns the mapped error using `MapChannelError`.
## Remarks
This method acts as an orchestration boundary, ensuring only authenticated users can delete their channels and coordinating the domain operation with cross-service notification to keep clients in sync.
## Notes
- Potential exception if the `NameIdentifier` claim isn't a valid GUID; consider using `Guid.TryParse` or additional validation.
---
### GetChannelCrypto
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpGet("{channel}/crypto")]
public async Task<IActionResult> GetChannelCrypto(string channel)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}/crypto"` | — | — |
GetChannelCrypto is an HTTP GET endpoint that returns the public crypto metadata for a given channel, including whether the channel is end-to-end encrypted and the PBKDF2 salt used to derive the join credential. It never returns the wrapped room key; if the channel doesn't exist, the endpoint responds with `NotFound` and an [`ErrorResponse`](../../EchoHub.Core/DTOs/CommonDtos.cs.md); otherwise it returns the metadata with an `Ok(crypto)` result.
## Remarks
This endpoint centralizes crypto-configuration retrieval for a channel, keeping actual keys out of reach and clarifying that the response is metadata only. It delegates to `_channelService.GetChannelCryptoAsync(channel)` to obtain the data and uses the 404/not-found path to signal missing channels or missing crypto metadata. It sits in the `ChannelsController` and complements the security model by exposing minimal, auditable information required by clients to participate in encrypted joins.
## Notes
- If `_channelService.GetChannelCryptoAsync(channel)` returns null, the API responds with 404 via the same messaging, conflating a missing channel with missing crypto metadata.
- The endpoint does not expose any cryptographic material beyond the publicly exposable metadata; actual keys are never returned.
---
### GetChannelMeta
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpGet("{channel}/meta")]
public async Task<IActionResult> GetChannelMeta(string channel)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}/meta"` | — | — |
GetChannelMeta exposes channel metadata for a given `channel` via HTTP GET to `"{channel}/meta"`. It delegates to `_channelService.GetChannelMetaAsync(channel)` to assemble metadata such as message count, unique posters, estimated size, creation date, and room id. This remains available for encrypted channels as well since the server tracks this metadata independent of the messages. If the channel does not exist, the endpoint returns `NotFound(new ErrorResponse($"Channel '{channel}' does not exist."))`; otherwise it returns the metadata payload with `Ok(meta)`.
---
### GetChannels
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpGet]
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `offset` | `int` | `0` |
| `limit` | `int` | `50` |
**Returns:** `[HttpGet]
public async `Task<IActionResult>``
GetChannels is an HTTP GET endpoint on the `ChannelsController` that returns a paged list of channels for the currently authenticated user. It reads the query parameters `offset` and `limit`, clamps them to sane bounds, parses the user GUID from the `NameIdentifier` claim, delegates to `_channelService.GetChannelsAsync(Guid.Parse(userIdClaim), offset, limit)`, and returns the data in an `Ok` response.
## Remarks
As an HTTP boundary, this method coordinates authentication and paging concerns, keeping the controller thin by delegating data retrieval to `_channelService.GetChannelsAsync(...)`. It relies on [`ErrorResponse`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) to signal authentication failures and on the service to fetch domain data, forming a simple, testable conduit between the HTTP layer and business logic.
## Notes
- It assumes the `NameIdentifier` claim contains a valid GUID; if not, `Guid.Parse` will throw. Consider using `Guid.TryParse` or stricter claim validation to avoid runtime exceptions.
---
### MapChannelError
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
private IActionResult MapChannelError(ChannelOperationResult result) => result.Error switch
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `result` | [`ChannelOperationResult`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) | — |
**Returns:** `IActionResult`
MapChannelError is a private helper in `ChannelsController` that translates a [`ChannelOperationResult`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) into an HTTP response by switching on `result.Error`. It centralizes the mapping from domain channel errors (the [`ChannelError`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) enum) to HTTP status results, covering common cases: `ChannelError.ValidationFailed` yields a `BadRequest` with an [`ErrorResponse`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) payload containing the error message, `ChannelError.AlreadyExists` yields `Conflict`, `ChannelError.NotFound` yields `NotFound`, `ChannelError.Forbidden` yields a 403 via `StatusCode(403, ...)`, and `ChannelError.Protected` yields `BadRequest`; any unlisted error falls back to a `BadRequest` with either the provided `ErrorMessage` or the string `"Unknown error."`. All branches construct the error payload with `new ErrorResponse(result.ErrorMessage!)` (except the fallback) to deliver structured error information to the client.
## Remarks
This helper encapsulates the error-to-HTTP translation for channel operations, ensuring consistent client-facing semantics across the controller. By funneling all channel-related errors through a single switch, changes to HTTP status mappings or payload shape can be made in one place. The method returns an `IActionResult` and always uses an [`ErrorResponse`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) payload to provide a predictable error contract to clients; callers do not need to repeat boilerplate error handling.
## Notes
- The code uses the null-forgiving operator on `ErrorMessage` in most branches; ensure `ErrorMessage` is populated for those [`ChannelError`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) values, or risk a runtime null allocation.
- The default branch returns a `BadRequest` with either the provided message or a fallback of `"Unknown error."`, which avoids leaking a null payload but may obscure the underlying error if messages are not consistently set.
---
### ParseKind
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
private static AttachmentKind ParseKind(string? kind) => kind?.ToLowerInvariant() switch
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `kind` | `string?` | — |
**Returns:** [`AttachmentKind`](../../EchoHub.Core/Models/AttachmentKind.cs.md)
Converts an optional string describing an attachment into the corresponding [`AttachmentKind`](../../EchoHub.Core/Models/AttachmentKind.cs.md) enum value. It normalizes the input with `ToLowerInvariant()` and returns `AttachmentKind.Image` for `image`, `AttachmentKind.Audio` for `audio`, or `AttachmentKind.File` for any other value (including when the input is `null`).
## Remarks
By centralizing this mapping in a private helper, the server ensures consistent classification of attachments across callers and makes future changes to the mapping straightforward. The use of `ToLowerInvariant()` guarantees predictable behavior regardless of the runtime culture.
## Notes
- If a new attachment kind is introduced, this method must be updated; otherwise unknown values default to `AttachmentKind.File`.
---
### RekeyChannel
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpPost("{channel}/rekey")]
public async Task<IActionResult> RekeyChannel(string channel, [FromBody] RekeyChannelRequest request)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}/rekey"` | — | — |
RekeyChannel rotates an encrypted channel's passphrase by re-wrapping its room key. It authenticates the caller via the `ClaimTypes.NameIdentifier` claim and requires knowledge of the old password (provided as `OldPassword` in the request) to authorize the change; the operation preserves history by not re-encrypting the room content key.
## Remarks
RekeyChannel delegates the actual rotation to `_channelService.RekeyChannelAsync`, which performs the rewrapping logic and returns a result. If the operation succeeds, the updated channel is returned with `Ok`, otherwise `MapChannelError` translates failures into the appropriate HTTP error response. The endpoint is exposed at the route `"{channel}/rekey"`, enforcing authentication at the boundary via the user identity claim.
## Notes
- Authentication relies on the presence of the `NameIdentifier` claim in [`User`](../../EchoHub.Core/Models/User.cs.md); if it is missing, the method responds with `Unauthorized(new ErrorResponse("Authentication required."))`.
- The code calls `Guid.Parse(userIdClaim)` on the claim value; if the `NameIdentifier` claim is present but not a valid GUID, an exception could be thrown at runtime.
- This operation re-wraps the room key to rotate the channel's passphrase without altering the underlying room content key, preserving historical data while changing access material.
---
### SendMessageWithAttachments
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpPost("{channel}/messages")]
[EnableRateLimiting("upload")]
public async Task<IActionResult> SendMessageWithAttachments(string channel, [FromQuery] string? size = null)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}/messages"` | — | — |
SendMessageWithAttachments posts a single chat message to a named channel, optionally including plaintext `content` and zero or more attachments delivered as multipart form data.
For non-encrypted channels the server may inspect attachments and render ASCII previews; for room-encrypted channels the client supplies ciphertext with per-file `kind` and a pre-rendered `preview`, and the server never inspects the ciphertext.
The action enforces runtime upload limits, validates authentication and channel state, requires multipart content with at least one attachment, and observes per-channel constraints such as maximum attachments per message and maximum message length for non-encrypted content.
## Remarks
`SendMessageWithAttachments` is a boundary between the chat surface and the attachment pipeline. It coordinates authentication, channel resolution, and per-channel policy (read-only channels, allowed attachment counts, and length limits), then delegates the heavier lifting of encryption handling and persistence to the underlying services (`_encryption`, `_channelService`, and the database context). By centralizing multipart request handling and per-file metadata (such as `Attachment.Kind` and `Attachment.AsciiPreview`), it provides a single, secure entry point for composing rich messages that may include both plaintext and encrypted payloads, while ensuring that encrypted channels never disclose raw attachment data to the server.
## Notes
- If the channel is encrypted, the endpoint relies on the client-provided per-file metadata (e.g., `kind` and `preview`) and does not perform server-side inspection of the ciphertext blobs; ensure consistency between client-provided metadata and channel state to avoid mismatches.
---
### SendUrl
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpPost("{channel}/send-url")]
[EnableRateLimiting("upload")]
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}/send-url"` | — | — |
SendUrl is an HTTP POST action that enables an authenticated user to attach an image to a channel by URL. It coordinates authentication, channel validation, image download, format and size validation, storage, and ASCII preview generation, applying channel policies (such as read-only `IsSystem` channels and end-to-end encrypted `IsEncrypted` channels) before persisting the asset.
## Remarks
This action centralizes URL-based image delivery, delegating channel lookup to `` `_channelService` ``, remote download/validation to the HTTP client path, and persistence to ``_fileStorage``. It ensures that content is only added to writable channels and that encrypted channels disallow URL-based image sending, thereby reducing risk and keeping concerns isolated. The composition makes testing and reuse consistent with other upload flows in the codebase, leveraging collaborators such as [`FileValidationHelper`](../../EchoHub.Core/Services/FileValidationHelper.cs.md) for image validation and [`ImageToAsciiService`](../../EchoHub.Core/Services/ImageToAsciiService.cs.md) for the ASCII preview.
## Example
```csharp
using System.Net.Http;
using System.Text;
using System.Text.Json;
var payload = new { Url = "https://example.com/image.png" };
var json = JsonSerializer.Serialize(payload);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var client = new HttpClient(); // configure base address and authentication as needed
var response = await client.PostAsync("/channels/general/send-url?size=1024", content);
```
## Notes
- Requires authentication; requests without credentials yield `Unauthorized` with an [`ErrorResponse`](../../EchoHub.Core/DTOs/CommonDtos.cs.md).
- Validates channel name via `ValidationConstants.ChannelNameRegex` and checks channel existence (`NotFound`) and state (`IsSystem` / `IsEncrypted`).
- Downloads the image using the named HttpClient `"ImageDownload"`, enforces the maximum size via `_uploadLimits.MaxImageSizeBytes`, and validates the actual image content with `FileValidationHelper.IsValidImage`.
- If the downloaded data cannot be interpreted as a supported image, returns a `BadRequest` with an explanatory message.
---
### UpdateTopic
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpPut("{channel}/topic")]
public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}/topic"` | — | — |
UpdateTopic handles PUT requests to update a channel's topic for the authenticated user. It reads the `NameIdentifier` claim and, if missing, returns `Unauthorized(new ErrorResponse("Authentication required."))`; otherwise it calls `_channelService.UpdateTopicAsync(Guid.Parse(userIdClaim), channel, request.Topic)`, maps errors via `MapChannelError` on failure, and on success broadcasts the update with `_chatService.BroadcastChannelUpdatedAsync(result.Channel!, channel.ToLowerInvariant().Trim())` before returning `Ok(result.Channel)`.
## Remarks
This endpoint centralizes authentication checks and cross-service coordination for topic changes. It ensures only authenticated users can modify a channel topic and that updates are propagated to connected clients via the `_chatService.BroadcastChannelUpdatedAsync` call.
---
## ChannelsController (constructor)
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** constructor
@@ -77,377 +386,13 @@ public ChannelsController(
| `logger` | `ILogger<ChannelsController>` | — |
The ChannelsController constructor wires up the controller by receiving its dependencies through dependency injection and assigning them to private fields. This pattern allows the controller to orchestrate channel-related functionality by delegating to dedicated services such as IChannelService, EchoHubDbContext, FileStorageService, ImageToAsciiService, IHttpClientFactory, IChatService, IMessageEncryptionService, UploadLimits, and `ILogger<ChannelsController>`. The framework supplies these collaborators at creation time, enabling a testable, loosely coupled design where concerns are separated and easily mockable for unit tests. This constructor is invoked by the ASP.NET Core runtime during request handling, not by consumer code directly.
The `ChannelsController` constructor wires the controller to its collaborators by accepting all required services via dependency injection and storing them for use in action methods. It is invoked by the ASP.NET Core DI container when handling channel-related requests, meaning developers should avoid manual instantiation and instead provide mocks or fakes for its dependencies in tests.
## Remarks
The constructor centralizes the wiring of the controller's collaborators, which supports clean separation of concerns and testability. It enables the ChannelsController to delegate specialized tasks (e.g., data access, file handling, image processing, HTTP calls, chat interactions, and encryption) to dedicated services rather than embedding logic directly.
The lack of explicit null validation means misconfigured dependency injection (missing service registrations) could surface as NullReferenceExceptions later when members are used. Relying on the DI container to validate registrations is common, but tests should provide explicit mocks to ensure predictable behavior.
By composing [`IChannelService`](../../EchoHub.Core/Contracts/IChannelService.cs.md), [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md), [`FileStorageService`](../Services/FileStorageService.cs.md), [`ImageToAsciiService`](../../EchoHub.Core/Services/ImageToAsciiService.cs.md), `IHttpClientFactory`, [`IChatService`](../../EchoHub.Core/Contracts/IChatService.cs.md), [`IMessageEncryptionService`](../../EchoHub.Core/Contracts/IMessageEncryptionService.cs.md), [`UploadLimits`](../Config/UploadLimits.cs.md), and `ILogger<ChannelsController>` in a single place, the constructor positions `ChannelsController` as a coordinator that delegates work to specialized services. This composition reflects a separation of concerns across persistence, media processing, HTTP communication, chat orchestration, encryption, and logging.
## Notes
- The constructor does not perform null checks; ensure all dependencies are registered in the DI container to avoid runtime null reference issues.
- When writing unit tests for ChannelsController, provide concrete or mock implementations for all injected services to exercise behavior reliably.
---
### CreateChannel
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpPost]
public async Task<IActionResult> CreateChannel([FromBody] CreateChannelRequest request)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `request` | [`CreateChannelRequest`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) | — |
**Returns:** `[HttpPost]
public async `Task<IActionResult>``
Source Code
The CreateChannel action handles the HTTP POST to create a new channel for the authenticated user. It first verifies authentication by pulling the user ID from the current users claims; if the claim is missing, it responds with Unauthorized and an ErrorResponse indicating that authentication is required. It then delegates the actual creation to the channel service via CreateChannelAsync, passing the callers GUID along with the channel properties supplied in the request (Name, Topic, IsPublic, Password, EncryptionSalt, WrappedRoomKey). If the service reports a failure, the action returns a mapped error via MapChannelError. If a channel is successfully created and it is public, it broadcasts the updated channel through the chat service to notify connected clients. Finally, it returns a 201 Created response with the location of the new channel and the Channel data in the response body.
Dependencies
- IActionResult
- ErrorResponse
- User
- ClaimTypes
- Guid
- Channel
Dependency APIs (verified signatures)
- record [`ErrorResponse`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) (`src/EchoHub.Core/DTOs/CommonDtos.cs`)
- property [`User`](../../EchoHub.Core/Models/User.cs.md) (`src/EchoHub.Core/Models/RefreshToken.cs`)
- class [`Channel`](../../EchoHub.Core/Models/Channel.cs.md) (`src/EchoHub.Core/Models/Channel.cs`)
- `Guid Id`
- `string Name`
- `string? Topic`
- `bool IsPublic`
- `bool IsSystem`
- `string? PasswordHash`
- `string? EncryptionSalt`
- `string? WrappedRoomKey`
- `DateTimeOffset CreatedAt`
- `Guid CreatedByUserId`
- `List<Message> Messages`
Symbol To Document
- Name: CreateChannel
- Kind: method
- File: src/EchoHub.Server/Controllers/ChannelsController.cs
- Language: csharp
- ID: 373f63c8-2a87-460c-9821-46640d93a9fc
## Remarks
Creates a channel on behalf of the authenticated user and encapsulates the orchestration between the domain service and the HTTP response surface. It relies on _channelService to enforce business rules and persistence, and on _chatService to refresh client views when appropriate. This action adheres to RESTful semantics by returning 401 for unauthenticated requests, propagating domain errors via MapChannelError, broadcasting updates for public channels, and signaling successful creation with 201 and the new channel resource.
## Notes
- Be aware that Guid.Parse is used on the user ID claim. If the claim value is not a valid GUID, this will throw. Consider validating with Guid.TryParse at the call site if you anticipate non-GUID claim values.
- The publication check (IsPublic) gates whether a channel update is broadcast to clients; non-public channels skip broadcasting to peers.
---
### DeleteChannel
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpDelete("{channel}")]
public async Task<IActionResult> DeleteChannel(string channel)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}"` | — | — |
Deletes a channel for the authenticated user by handling an HTTP DELETE request to the channel route. It reads the user's ID from the authentication claims, delegates the deletion to the channel service using that ID and the channel name, and, on success, broadcasts the deletion to the chat service before returning HTTP 204 No Content. If authentication is missing, the method responds with 401 Unauthorized and an ErrorResponse.
## Remarks
This endpoint acts as a thin HTTP boundary that orchestrates authentication, domain deletion, and cross-service notification. It centralizes HTTP-level error handling (Unauthorized, error mapping) while delegating business rules to the channel service and the side-effect of notifying the chat service. The normalization of the channel name for the broadcast (lowercase and trimmed) helps ensure consumers react to a consistent channel identifier.
## Notes
- Authentication is required; requests without a valid NameIdentifier claim result in 401 Unauthorized with an ErrorResponse.
- The broadcast step uses channel.ToLowerInvariant().Trim(); differences between input casing and broadcast casing could affect downstream consumers.
- If the channel is deleted successfully but the broadcast fails, the method will surface a failure (no explicit retry here); consider compensating actions if eventual consistency is important.
---
### GetChannelCrypto
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpGet("{channel}/crypto")]
public async Task<IActionResult> GetChannelCrypto(string channel)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}/crypto"` | — | — |
GetChannelCrypto is an HTTP GET action on ChannelsController that exposes essential cryptographic metadata for a channel. It indicates whether the channel is end-to-end encrypted and provides the PBKDF2 salt clients need to derive their join credential. The endpoint deliberately does not return the wrapped room key; that secret is only handed out after a successful join. The action delegates retrieval to the channel service and translates the result into standard HTTP responses: 200 OK with the crypto data when the channel exists, or 404 Not Found with an ErrorResponse if the channel does not exist.
## Remarks
By wrapping the service call behind a minimal HTTP surface, this symbol centralizes how cryptographic metadata is surfaced while keeping the actual cryptographic material protected. It demonstrates a clear separation of concerns: business logic lives in the ChannelService, while the controller handles HTTP semantics and error translation. The exposed salt enables client-side credential derivation, while the wrapped key remains strictly withheld until the proper join flow.
## Notes
- The action does not perform explicit authorization; ensure the surrounding middleware or route configuration enforces the intended access policy.
- It returns 404 with a generic ErrorResponse when the channel does not exist; clients should handle this scenario as an absence of channel crypto metadata.
- Do not rely on this endpoint to retrieve any sensitive material beyond allowed cryptographic metadata; the wrapped key must never be exposed through this action.
---
### MapChannelError
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
private IActionResult MapChannelError(ChannelOperationResult result) => result.Error switch
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `result` | [`ChannelOperationResult`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) | — |
**Returns:** `IActionResult`
Converts a ChannelOperationResult into an API response by pattern-matching on result.Error and returning an appropriate HTTP result that wraps an ErrorResponse. It centralizes the translation from channel-domain errors to standard HTTP status codes (400, 403, 404, 409) so the rest of the controller does not duplicate error handling logic.
## Remarks
This encapsulates the error-handling policy for channel operations, ensuring clients see consistent HTTP semantics across all channel actions. It decouples domain error codes from HTTP choices, so updates to status codes or payload shapes can be made in one place rather than at every call site.
## Notes
- The branches pass result.ErrorMessage! into ErrorResponse; if ErrorMessage can be null for any mapped error, this will throw at runtime.
- New ChannelError values require extending this switch to preserve the API's error contract.
---
### ParseKind
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
private static AttachmentKind ParseKind(string? kind) => kind?.ToLowerInvariant() switch
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `kind` | `string?` | — |
**Returns:** [`AttachmentKind`](../../EchoHub.Core/Models/AttachmentKind.cs.md)
This private helper translates a nullable string that labels an attachment into a concrete AttachmentKind enum. It uses a case-insensitive comparison (ToLowerInvariant) to recognize 'image' and 'audio' and map them to AttachmentKind.Image and AttachmentKind.Audio, respectively; any other label (including null) falls back to AttachmentKind.File. Callers rely on this mapping when normalizing incoming attachment metadata before further processing in the channel/server pipeline.
## Remarks
Centralizes the normalization logic so all attachment-kind labels are interpreted consistently across the server. By funneling strings through this method, the rest of the attachment processing can operate on a well-defined enum, reducing branching and potential mismatches.
## Notes
- Unknown labels are treated as File by design; if a new kind is introduced, update this method or extend the enum.
- Because the method is private, it's exercised via the class's public APIs; ensure tests cover scenarios that exercise this mapping through those entry points.
---
## GetChannelMeta
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpGet("{channel}/meta")]
public async Task<IActionResult> GetChannelMeta(string channel)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}/meta"` | — | — |
Retrieves the channel metadata for a given channel identifier via HTTP GET. It returns key overview details such as message count, the number of unique posters, an estimated size, the creation date, and the room id. These metadata are tracked by the server and are available even for encrypted channels, where the server cannot access the actual messages. If the channel does not exist, it responds with 404 and an ErrorResponse; otherwise it returns the metadata payload with a 200 OK.
## Remarks
This endpoint provides a read-only surface for obtaining channel overview information without exposing message contents. It enables clients to populate channel lists or dashboards while preserving message privacy, including for encrypted channels. By delegating the data retrieval to _channelService.GetChannelMetaAsync, the API keeps data access concerns centralized and allows the underlying storage/collection strategy to evolve without changing the surface contract.
## Notes
- The caller must handle a 404 NotFound with an ErrorResponse when the channel is missing. The error payload documents the failure reason.
- The endpoint exposes only metadata about a channel; actual messages remain inaccessible, preserving privacy for encrypted channels.
- The operation is asynchronous; consider service performance characteristics or potential caching strategies if metadata is requested frequently.
---
## GetChannels
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpGet]
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `offset` | `int` | `0` |
| `limit` | `int` | `50` |
**Returns:** `[HttpGet]
public async `Task<IActionResult>``
Gets a paged list of channels for the authenticated user. It enforces authentication by checking the user claims, reads the user's GUID from the claims, normalizes paging parameters (offset non-negative; limit clamped to 1100), and delegates to the channel service to retrieve the channels, returning the result in an HTTP 200 response.
## Remarks
This action is intentionally thin: it performs authentication, input normalization, and orchestration between the API layer and the domain service. Centralizing paging bounds and user identification here provides consistent behavior and error handling for per-user channel retrieval across clients.
## Notes
- Be aware that if the NameIdentifier claim is present but is not a valid GUID, Guid.Parse will throw. Prefer Guid.TryParse or ensure identity claims are well-formed.
- The limit is clamped to the range 1100; requests outside that range are adjusted to the nearest bound.
---
## RekeyChannel
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpPost("{channel}/rekey")]
public async Task<IActionResult> RekeyChannel(string channel, [FromBody] RekeyChannelRequest request)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}/rekey"` | — | — |
Changes an encrypted channel's passphrase by re-wrapping its room key. The caller proves knowledge of the existing passphrase via the old authentication key, and this operation preserves history by not changing the room content key.
## Remarks
RekeyChannel acts as a thin HTTP boundary that enforces authentication and delegates the cryptographic work to the channel service. By re-wrapping the existing room key instead of re-encrypting the historical content, it minimizes disruption while changing access controls. The controller handles authentication and error translation, while RekeyChannelAsync encapsulates the cryptographic policy in the domain layer.
## Notes
- Authentication is mandatory; if the user is not authenticated, the endpoint returns 401 Unauthorized with ErrorResponse("Authentication required.").
- The code uses Guid.Parse on the NameIdentifier claim; if the claim is present but not a valid GUID, a runtime exception may be thrown.
---
## SendMessageWithAttachments
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpPost("{channel}/messages")]
[EnableRateLimiting("upload")]
public async Task<IActionResult> SendMessageWithAttachments(string channel, [FromQuery] string? size = null)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}/messages"` | — | — |
Use SendMessageWithAttachments when you need to post a chat message to a channel that includes optional text and one or more attachments, while enforcing per-channel upload limits and channel permissions.
It supports both cleartext and end-to-end encrypted channels: in cleartext channels the server inspects file kinds to render ASCII previews and decrypts the content, while in encrypted channels the client uploads ciphertext with per-file kind and a pre-rendered encrypted preview and the server never inspects the ciphertext.
## Remarks
This endpoint centralizes the server-side orchestration for uploading messages with attachments, coordinating authentication via user claims, channel validation and mutability checks, multipart form handling, per-attachment processing, and interaction with the encryption and upload-limit subsystems. It relies on collaborators such as the channel service, the database context, and the encryption helper to enforce read-only channels, mute state, and maximum message length in a consistent manner. By encapsulating these concerns, it ensures secure, policy-compliant message delivery and prevents plaintext exposure of encrypted payloads. In short, it is the single integration point for sending rich messages with attachments in EchoHub.Server.
## Notes
- The request size is governed at runtime by UploadLimits; configure this to control maximum allowed payloads.
- For encrypted channels, ensure that per-file previews are provided in the ciphertext workflow and that file order remains aligned with the declared previews to avoid misrendering on the client.
---
## SendUrl
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpPost("{channel}/send-url")]
[EnableRateLimiting("upload")]
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}/send-url"` | — | — |
SendUrl is an HTTP POST endpoint on ChannelsController that accepts a channel name, a request body containing an image URL, and an optional size parameter. It authenticates the caller, validates the channel, enforces channel policies (rejects system/read-only and end-to-end encrypted channels), validates the URL, downloads the image server-side, enforces size limits, validates the image format, saves the file, and generates an ASCII preview for display in the channel.
## Remarks
Centralizes remote image ingestion with strict, server-side validation to prevent improper content, inconsistent client behavior, or abuse. The endpoint relies on the application's security and storage abstractions: it checks user claims, ensures channel permissions, uses FileStorage to persist the file, and uses ImageToAsciiService to produce a lightweight ASCII representation for previews. The EnableRateLimiting("upload") attribute signals this is a potentially resource-intensive operation and should be throttled to guard against abuse.
## Notes
- Requires authentication; missing user claims yield Unauthorized responses with a helpful error.
- Validates channel state: if the channel does not exist, is system (read-only), or is encrypted, it responds with NotFound/403/400 and an ErrorResponse explaining the reason.
- Validates the supplied URL and only accepts http/https URLs; invalid URLs or unsupported schemes produce a BadRequest with a descriptive message.
- Downloads the image server-side using an HttpClient named "ImageDownload". It handles timeouts and HTTP errors by returning BadRequest with a clear message.
- Enforces file size limits via _uploadLimits.MaxImageSizeBytes before and after downloading the content.
- Validates that the downloaded content is a real image (JPEG, PNG, GIF, WebP) before persisting.
- Determines a filename from the URL or Content-Type; if missing, it falls back to a generated name with an appropriate extension.
- Persists the file and creates an ASCII representation (via ImageToAsciiService) for downstream use.
---
## UpdateTopic
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** method
```csharp
[HttpPut("{channel}/topic")]
public async Task<IActionResult> UpdateTopic(string channel, [FromBody] UpdateTopicRequest request)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `"{channel}/topic"` | — | — |
Updates a channel's topic for the currently authenticated user via HTTP PUT. It verifies authentication, calls ChannelService.UpdateTopicAsync with the user's ID, the channel, and the new topic, and on success broadcasts the channel update before returning the updated channel; on failure or missing authentication, it yields an HTTP error.
## Remarks
Acts as the HTTP API boundary for updating a channel topic, delegating the actual update to the domain service and handling authentication. It centralizes error translation via MapChannelError and ensures clients are informed of changes in real time by broadcasting after a successful update.
## Notes
- Be aware that Guid.Parse could throw if the user claim is not a valid GUID; consider Guid.TryParse to avoid runtime exceptions.
- The broadcast channel is normalized by lowercasing and trimming the channel name; this affects how subscribers perceive channel identifiers in updates.
- Do not instantiate `ChannelsController` yourself; rely on the DI container so tests can provide mocks or fakes.
- A constructor with many dependencies can indicate the controller has multiple responsibilities; consider extracting a higher-level service if you find yourself needing to mock many collaborators in tests.
---
@@ -12,12 +12,14 @@ public class FilesController : ControllerBase
```
Serves an uploaded file anonymously by design: the unguessable GUID in the URL is the access token (Discord-CDN-style capability URL), so attachment links can be opened directly in a browser and shared to IRC clients. E2E-encrypted room blobs are ciphertext at rest, so anonymous access reveals nothing for those channels.
Serves an uploaded file via the GET endpoint `api/files/{fileId}` and intentionally allows anonymous access through the unguessable GUID in the URL, enabling direct browser viewing or sharing of attachments. The controller uses [`FileStorageService`](../Services/FileStorageService.cs.md) (via `_fileStorage`) to resolve the file path and returns the file with an appropriate `Content-Type`; images and audio render inline in the browser, while other types trigger a download with the original filename.
## Remarks
This symbol provides a minimal, token-based file access surface that does not require user authentication. It delegates path resolution to FileStorageService and consolidates content-type handling in one place, so callers can rely on consistent delivery behavior across file types. The design emphasizes shareable, browser-friendly links while safeguarding sensitive payloads behind the GUID-based URL.
FilesController decouples storage concerns from HTTP delivery, enabling shareable, tokenized links without per-request authentication. It relies on `_fileStorage.GetFilePath` to verify existence and obtain a path, while validating the input with `Guid.TryParse` to guard against malformed requests. The design relies on the GUID in the URL as an access token, so the security model hinges on the token being effectively unguessable to limit exposure of attachments.
## Notes
- The endpoint validates the fileId as a GUID before attempting any storage access; invalid IDs yield a BadRequest response.
- Content types are determined by file extension with a broad fallback to application/octet-stream; unknown extensions will download as a generic binary.
- Images and audio files are rendered inline in the browser, while other types are delivered as attachments with the original file name.
- Anonymous access means links can be shared; treat the `fileId` as a security token and rotate or revoke links as needed.
- The MIME type is derived from the file extension via `Path.GetExtension`; ensure file extensions are correct to avoid misrepresented MIME types or unintended inline rendering.
- Images and audio render inline (`Content-Type` starts with `image/` or `audio/`); all other files are delivered as attachments with the file name.
@@ -12,12 +12,14 @@ public class InvitesController : ControllerBase
```
InvitesController provides admin-only endpoints to manage invite codes used for invite-gated registration. It stores and governs the lifecycle of codes within this server's own database, rather than delegating to a central service, and should be used whenever an administrator needs to issue, review, or revoke invites.
InvitesController is the Admin-only API surface that manages invite codes used to gate registrations on this server. It stores codes in the servers own database (via `EchoHubDbContext.InviteCodes`) and exposes endpoints to create, list, and revoke codes, keeping the lifetime and usage policy centralized on the local instance rather than a central service. Codes are generated by `GenerateCode` as unguessable, human-friendly strings in the form `XXXX-XXXX` drawn from a carefully chosen alphabet, and are surfaced to clients through a lightweight [`InviteDto`](../../EchoHub.Core/DTOs/InviteDtos.cs.md) via `ToDto`. Each operation is guarded by `GetCallerAsync(ServerRole.Admin)` to ensure only administrators can participate, with additional server-side validation of usage and expiration constraints. A hard cap of active invites (`MaxActiveInvites`) prevents unbounded growth, ensuring revocation activities stay in sync with availability. The controller logs creation and revocation events for auditability, reinforcing accountability around invite management.
## Remarks
Centralizing invite data in this controller creates a self-contained, auditable lifecycle for invites without relying on external services. It enforces admin ownership of creation, supports expiration and usage limits, and records actions for traceability via logs. By separating persistence (InviteCodes) from presentation (DTOs) and API responses, the design keeps concerns well-scoped and maintainable in this deployment.
InvitesController encapsulates invite-code policy in a single server-local component, enabling straightforward auditing and revocation without cross-service coordination. By keeping codes in this servers own database, it offers immediate effect when invites are revoked and aligns with admin-driven registration flows. The combination of strict input validation, cryptographically strong code generation, and explicit admin authorization provides a clear boundary around who can issue or revoke invites and under what constraints.
## Notes
- There is a potential race around the MaxActiveInvites check with concurrent create requests; consider transactional safeguards if concurrent admins can issue invites.
- ExpiresInHours is optional; omitting it yields non-expiring invites; the code only applies an expiration when a value is provided.
- Generated codes use a reserved alphabet that excludes ambiguous characters and follow the XXXX-YYYY pattern, aiding readability and reducing mis-typing.
- The active-invite cap enforces a maximum of 200 invites with unused uses; attempting to create a new invite when this cap is reached results in a BadRequest and requires revoking unused invites first.
- Codes are stored in uppercase and normalized on revoke (via `FirstOrDefaultAsync` with a case-insensitive match); clients can supply codes in any case, but the server stores and compares in a normalized form.
- If an `ExpiresInHours` value is not supplied, `ExpiresAt` remains null, meaning the invite never expires. If supplied, it must be between 1 and 8760 hours (1 year).
@@ -8,46 +8,32 @@
```mermaid
%%{init: {'theme':'base','themeVariables':{'background':'#faf7ef','primaryColor':'#f0e2c2','primaryTextColor':'#1f2840','primaryBorderColor':'#8a7548','secondaryColor':'#d9efec','secondaryBorderColor':'#1d8a80','secondaryTextColor':'#1f2840','tertiaryColor':'#f2ebd8','tertiaryBorderColor':'#8a7548','tertiaryTextColor':'#1f2840','lineColor':'#1d8a80','titleColor':'#1f2840','fontSize':'14px','edgeLabelBackground':'#faf7ef','clusterBkg':'#f2ebd8','clusterBorder':'#8a7548','actorBkg':'#f0e2c2','actorBorder':'#8a7548','actorTextColor':'#1f2840','actorLineColor':'#8a7548','signalColor':'#1d8a80','signalTextColor':'#1f2840','activationBkgColor':'#d9efec','activationBorderColor':'#1d8a80','noteBkgColor':'#f2ebd8','noteBorderColor':'#8a7548','noteTextColor':'#1f2840','labelBoxBkgColor':'#f0e2c2','labelBoxBorderColor':'#8a7548','labelTextColor':'#1f2840','transitionColor':'#1d8a80','transitionLabelColor':'#1f2840','stateLabelColor':'#1f2840','altBackground':'#f2ebd8'}}}%%
flowchart TB
Start["POST api/moderation/role - AssignRoleRequest"]
GetCaller["Call GetCallerAsync(ServerRole.Admin)"]
CallerError{"GetCaller returned error?"}
ReturnError["Return ErrorResponse and stop"]
A["ModerationController POST role receives AssignRoleRequest"]
A --> B["Call GetCallerAsync(ServerRole.Admin)"]
B -->|"error != null"|C["Return ErrorResponse"]
B -->|"caller authorized"|D["If request.Role == ServerRole.Owner -> BadRequest(ErrorResponse)"]
D -->|"true"|C
D -->|"false"|E["Query EchoHubDbContext.Users for target Username (toLower)"]
E -->|"not found"|F["Return NotFound(ErrorResponse)"]
E -->|"found"|G["If target.Role == ServerRole.Owner -> BadRequest(ErrorResponse)"]
G -->|"true"|C
G -->|"false"|H["If request.Role >= caller.Role -> BadRequest(ErrorResponse)"]
H -->|"true"|C
H -->|"false"|I["Set previousRole and assign target.Role = request.Role"]
I --> J["Call EchoHubDbContext.SaveChangesAsync()"]
J --> K["Log information about role change"]
K --> L["Return Ok with success message"]
CheckOwnerReq{"request.Role == ServerRole.Owner?"}
BadRequestOwner["Return BadRequest(ErrorResponse: Cannot assign the Owner role.)"]
FindTarget["Query EchoHubDbContext.Users for request.Username.ToLower()"]
TargetNotFound{"target is null?"}
ReturnNotFound["Return NotFound(ErrorResponse: user not found)"]
TargetIsOwner{"target.Role == ServerRole.Owner?"}
BadRequestOwner2["Return BadRequest(ErrorResponse: Cannot change the server owner role.)"]
RoleTooHigh{"request.Role >= caller.Role?"}
BadRequestRole["Return BadRequest(ErrorResponse: Cannot assign a role equal to or above your own.)"]
ApplyChange["Set previousRole, assign request.Role to target, call EchoHubDbContext.SaveChangesAsync()"]
ReturnOk["Return Ok(message: user is now role)"]
Start --> GetCaller
GetCaller --> CallerError
CallerError -->|Yes| ReturnError
CallerError -->|No| CheckOwnerReq
CheckOwnerReq -->|Yes| BadRequestOwner
CheckOwnerReq -->|No| FindTarget
FindTarget --> TargetNotFound
TargetNotFound -->|Yes| ReturnNotFound
TargetNotFound -->|No| TargetIsOwner
TargetIsOwner -->|Yes| BadRequestOwner2
TargetIsOwner -->|No| RoleTooHigh
RoleTooHigh -->|Yes| BadRequestRole
RoleTooHigh -->|No| ApplyChange
ApplyChange --> ReturnOk
M["ModerationController POST kick/{username} receives KickRequest?"]
M --> N["Call GetCallerAsync(ServerRole.Mod)"]
N -->|"error != null"|C
N -->|"caller authorized"|O["Query EchoHubDbContext.Users for target Username"]
O -->|"not found"|F
O -->|"found"|P["If target.Role >= caller.Role -> BadRequest(ErrorResponse)"]
P -->|"true"|C
P -->|"false"|Q["channels = PresenceTracker.GetChannelsForUser(target.Username)"]
Q --> R["For each Channel in channels: broadcast kick via IChatBroadcaster and clean presence"]
R --> S["Proceed to perform broadcast and cleanup (truncated)"]
```
```csharp
@@ -59,13 +45,14 @@ public class ModerationController : ControllerBase
```
Exposes HTTP endpoints under api/moderation for server moderation operations such as assigning roles, kicking users, and banning users. Reach for this controller when implementing administrative or moderation features (web UI, automated moderation tools, or internal scripts) that must enforce role hierarchy, persist changes to the user store, notify connected clients, and clean up presence/connection state.
Provides HTTP endpoints under `api/moderation` for server moderation operations such as assigning roles, kicking and banning users. Use `ModerationController` when you need a centralized, authenticated API surface to perform privileged user-management actions that update persistent state and notify connected clients.
## Remarks
This controller centralizes server-side moderation logic and enforces policy at the API boundary: callers must be authenticated and possess the appropriate ServerRole before actions are performed. It coordinates several responsibilities through injected services — persisting role changes via the DbContext, enumerating and notifying affected channels via the PresenceTracker and IChatBroadcaster implementations, forcing connection teardown and cleanup, and recording moderation metrics with ServerStatsCollector. The design keeps authorization and business rules (for example, preventing Owner reassignment and preventing actors from assigning or acting on users with equal or higher roles) inside the controller so callers cannot bypass them.
`ModerationController` centralizes moderation workflows: it validates the caller's privileges (via the controller's caller-checking helpers), performs database updates through [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md), emits real-time notifications to connected clients through [`IChatBroadcaster`](../../EchoHub.Core/Contracts/IChatBroadcaster.cs.md) implementations, and updates runtime state via [`PresenceTracker`](../Services/PresenceTracker.cs.md) and [`ServerStatsCollector`](../Services/Stats/ServerStatsCollector.cs.md). The class is decorated with `[Authorize]` and `[EnableRateLimiting("general")]`, so all endpoints require an authenticated caller and are subject to the configured rate limits. Actions that modify user connectivity (for example kicking a user) will both broadcast the event to affected channels and invoke the controller's disconnect/cleanup logic to remove presence and force client disconnects.
## Notes
- Usernames are normalized (lowercased) before lookup; callers should supply usernames case-insensitively.
- Role comparisons rely on the numeric ordering of ServerRole (the controller rejects assigning or acting on roles that are equal to or higher than the caller).
- Methods have observable side effects: database updates, broadcasts to connected clients, forced disconnects and presence cleanup, and server-stat increments — consumers should treat these endpoints as state-changing and potentially long-running operations.
- The controller logs moderation actions (role changes, kicks, etc.); ensure logging and monitoring are configured appropriately for audit purposes.
- User lookup uses a lowercased username (e.g. `username.ToLowerInvariant()`), so callers should supply the canonical username form; mismatched casing can lead to `NotFound` responses.
- Role hierarchy is enforced: the controller prevents assigning the `ServerRole.Owner`, prevents changing the server owner's role, and disallows assigning or acting on users with roles equal to or higher than the caller (see the `AssignRole` and `KickUser` checks).
- Persistent changes are saved via `EchoHubDbContext.SaveChangesAsync()` and important actions are logged with the injected `ILogger<ModerationController>`, so moderation operations are durable and auditable.
- Because the controller broadcasts moderation events using [`IChatBroadcaster`](../../EchoHub.Core/Contracts/IChatBroadcaster.cs.md) and may call `ForceDisconnectAndCleanupAsync`, clients connected to channels may be forcibly disconnected as part of an action — callers should expect immediate real-time side effects beyond the HTTP response.
- The `[EnableRateLimiting("general")]` attribute can cause requests to be throttled under high load; plan client-side retry/backoff for operator tooling that calls these endpoints.
@@ -10,14 +10,13 @@ public class ServerController : ControllerBase
```
ServerController is an ASP.NET Core API controller that exposes the EchoHub server's administrative surface: endpoints to fetch live server statistics, retrieve the configured encryption key (when authorized), and inspect the directory registration state without exposing the claim token itself. Use it when you need operational visibility or admin actions, rather than wiring multiple components yourself.
ServerController is an ASP.NET Core API controller that exposes server-wide information and administrative operations under the `/api/server` route. It wires together runtime configuration, persistence, and directory-state to provide a concise snapshot of the server and a small admin surface for privileged tasks. The public `GetInfo` endpoint returns a [`ServerStatusDto`](../../EchoHub.Core/DTOs/ServerDtos.cs.md) containing the server name, description, user and channel counts, and the current registration mode derived from config. The `GetEncryptionKey` endpoint is protected by `[Authorize]` and returns an [`EncryptionKeyResponse`](../../EchoHub.Core/DTOs/ServerDtos.cs.md) containing the configured key, or a 503 if encryption is not configured. The `GetDirectoryStatus` endpoint is admin-only and surfaces directory registration state, including the server identifier and whether a claim token exists, while never exposing the token itself. A private helper `GetCallerAsync` centralizes authentication and authorization checks for admin actions.
## Remarks
This symbol acts as a unified HTTP boundary for server-wide concerns, coordinating three collaborators: EchoHubDbContext for live data (Users and Channels), IConfiguration for server configuration (name, description, and registration mode), and DirectoryClaimStore for directory registration state. The private GetCallerAsync helper centralizes authentication and role checks, ensuring privileged endpoints (e.g., GetDirectoryStatus) are accessible only to Admins. By composing a ServerStatusDto from runtime metrics and configuration-derived values, the controller provides a lightweight, admin-focused surface without leaking sensitive tokens.
By centralizing server-wide information and admin operations in a single controller, the architecture cleanly separates concerns: data access ([`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md)), configuration (`IConfiguration`), and directory registration state ([`DirectoryClaimStore`](../Services/DirectoryClaimStore.cs.md)) are coordinated behind stable, contract-driven DTOs ([`ServerStatusDto`](../../EchoHub.Core/DTOs/ServerDtos.cs.md), [`EncryptionKeyResponse`](../../EchoHub.Core/DTOs/ServerDtos.cs.md)). Authorization boundaries are explicit: open information through `GetInfo`, authenticated access for the encryption key, and admin-only access for directory status. The internal `GetCallerAsync` encapsulates common identity/role validation, reducing duplication and potential security gaps across admin endpoints.
## Notes
- GetEncryptionKey returns 503 if Encryption:Key is not configured on the server, signaling that encryption readiness is unavailable.
- GetDirectoryStatus is admin-only; if the caller lacks Admin rights, the endpoint yields an Unauthorized/403 response via GetCallerAsync.
- GetCallerAsync enforces authentication by reading the NameIdentifier claim, loading the user from the database, and validating their ServerRole; failures surface as Unauthorized or 403 with a clear message.
- The admin surface is guarded: `GetDirectoryStatus` relies on `GetCallerAsync` to enforce that the caller has at least `ServerRole.Admin`; non-admins will receive an appropriate 403/Unauthorized response.
- If encryption is not configured on the server, the `GetEncryptionKey` endpoint returns a 503 Service Unavailable, signaling to clients that encryption is not currently available despite the endpoint being accessible.
@@ -8,36 +8,36 @@
```mermaid
%%{init: {'theme':'base','themeVariables':{'background':'#faf7ef','primaryColor':'#f0e2c2','primaryTextColor':'#1f2840','primaryBorderColor':'#8a7548','secondaryColor':'#d9efec','secondaryBorderColor':'#1d8a80','secondaryTextColor':'#1f2840','tertiaryColor':'#f2ebd8','tertiaryBorderColor':'#8a7548','tertiaryTextColor':'#1f2840','lineColor':'#1d8a80','titleColor':'#1f2840','fontSize':'14px','edgeLabelBackground':'#faf7ef','clusterBkg':'#f2ebd8','clusterBorder':'#8a7548','actorBkg':'#f0e2c2','actorBorder':'#8a7548','actorTextColor':'#1f2840','actorLineColor':'#8a7548','signalColor':'#1d8a80','signalTextColor':'#1f2840','activationBkgColor':'#d9efec','activationBorderColor':'#1d8a80','noteBkgColor':'#f2ebd8','noteBorderColor':'#8a7548','noteTextColor':'#1f2840','labelBoxBkgColor':'#f0e2c2','labelBoxBorderColor':'#8a7548','labelTextColor':'#1f2840','transitionColor':'#1d8a80','transitionLabelColor':'#1f2840','stateLabelColor':'#1f2840','altBackground':'#f2ebd8'}}}%%
flowchart TB
UsersController["UsersController: receives HTTP request"]
UserService["UserService: GetUserProfileAsync(username)"]
ErrorResponse["ErrorResponse: create error response (message)"]
User["User: profile DTO / updated user"]
UpdateProfileRequest["UpdateProfileRequest: request body"]
IUserService["IUserService: UpdateProfileAsync(userId, displayName, bio, nicknameColor)"]
UserOperationResult["UserOperationResult: IsSuccess + User"]
UserError["UserError: domain error result"]
UploadLimits["UploadLimits: MaxAvatarSizeBytes"]
start["UsersController receives HTTP request"]
route{"Route: which endpoint?"}
UsersController -->|"GET {username}/profile"| UserService
UserService -->|"returns null"| ErrorResponse
ErrorResponse -->|"404 NotFound (User not found)"| UsersController
UserService -->|"returns profile"| User
User -->|"200 OK (profile)"| UsersController
start --> route
UsersController -->|"PUT /profile with UpdateProfileRequest"| UpdateProfileRequest
UpdateProfileRequest -->|"no userId claim"| ErrorResponse
UpdateProfileRequest -->|"has userId claim -> call UpdateProfileAsync"| IUserService
IUserService -->|"returns UserOperationResult"| UserOperationResult
UserOperationResult -->|"IsSuccess == false"| UserError
UserError -->|"MapUserError -> ErrorResponse"| ErrorResponse
UserOperationResult -->|"IsSuccess == true"| User
User -->|"200 OK (updated user)"| UsersController
%% GET profile flow
route -->|"GET {username}/profile"| gpCall["Call IUserService.GetUserProfileAsync(username)"]
gpCall --> profileNull{"Profile is null?"}
profileNull -- Yes --> notFound["Return 404 NotFound(ErrorResponse)"]
profileNull -- No --> okProfile["Return 200 Ok(profile)"]
UsersController -->|"POST /avatar"| UsersController
UsersController -->|"no userId claim"| ErrorResponse
UsersController -->|"no form content or files"| ErrorResponse
UsersController -->|"file obtained from Request.Form.Files[0] -> check length"| UploadLimits
UploadLimits -->|"file length > MaxAvatarSizeBytes -> BadRequest"| ErrorResponse
%% Update profile flow
route -->|"PUT profile"| updAuth["Extract userIdClaim from User"]
updAuth --> updAuthNull{"userIdClaim is null?"}
updAuthNull -- Yes --> updUnauthorized["Return 401 Unauthorized(ErrorResponse)"]
updAuthNull -- No --> updCall["Call IUserService.UpdateProfileAsync(Guid.Parse(userIdClaim), UpdateProfileRequest fields)"]
updCall --> updResult{"result.IsSuccess?"}
updResult -- No --> updError["Return mapped UserError response (UserError)"]
updResult -- Yes --> updOk["Return 200 Ok(result.User)"]
%% Upload avatar flow (truncated)
route -->|"POST avatar"| uploadAuth["Extract userIdClaim from User"]
uploadAuth --> uploadAuthNull{"userIdClaim is null?"}
uploadAuthNull -- Yes --> uploadUnauthorized["Return 401 Unauthorized(ErrorResponse)"]
uploadAuthNull -- No --> formCheck{"Request.HasFormContentType && Request.Form.Files.Count > 0?"}
formCheck -- No --> noFile["Return 400 BadRequest(ErrorResponse: No file uploaded.)"]
formCheck -- Yes --> fileAssign["Select first file from Request.Form.Files (file)"]
fileAssign --> sizeCheck{"file.Length > UploadLimits.MaxAvatarSizeBytes?"}
sizeCheck -- Yes --> tooLarge["Return 400 BadRequest(ErrorResponse: File size exceeds maximum)"]
sizeCheck -- No --> continue["Proceed with avatar processing (truncated)"]
```
```csharp
@@ -49,12 +49,15 @@ public class UsersController : ControllerBase
```
Handles HTTP endpoints rooted at /api/users for authenticated user operations such as retrieving a user's public profile, updating the caller's profile, and uploading an avatar. The controller delegates business logic to IUserService and ImageToAsciiService, applies rate limiting and upload-size checks, and returns standard DTOs like ErrorResponse and AvatarUploadResponse.
Controller that exposes the HTTP surface for user profile and account-related operations under `api/users`, including profile retrieval, profile updates and avatar upload. Reach for `UsersController` when you need to translate authenticated HTTP requests into calls to the user, storage and broadcasting services (for example, calling [`IUserService`](../../EchoHub.Core/Contracts/IUserService.cs.md) to update a profile or [`ImageToAsciiService`](../../EchoHub.Core/Services/ImageToAsciiService.cs.md) to convert an uploaded avatar).
## Remarks
This controller is the HTTP adapter for user-focused features: it validates requests and authorization, enforces upload and rate limits, converts uploaded images to ASCII art via ImageToAsciiService, and forwards profile and avatar changes to IUserService. It centralizes request-level concerns (model binding, auth, error translation) so the underlying services can remain framework-agnostic.
`UsersController` is an orchestration layer: it validates and normalizes incoming HTTP requests, enforces authentication and rate-limiting policies, performs lightweight validation (for example file size and image format checks), and delegates the domain work to collaborators such as [`IUserService`](../../EchoHub.Core/Contracts/IUserService.cs.md), [`ImageToAsciiService`](../../EchoHub.Core/Services/ImageToAsciiService.cs.md), [`FileStorageService`](../Services/FileStorageService.cs.md), [`PresenceTracker`](../Services/PresenceTracker.cs.md) and the collection of [`IChatBroadcaster`](../../EchoHub.Core/Contracts/IChatBroadcaster.cs.md) implementations. The controller centralizes common web concerns (claim extraction via `User.FindFirstValue`, mapping service results to HTTP responses with `MapUserError`, and producing [`ErrorResponse`](../../EchoHub.Core/DTOs/CommonDtos.cs.md)/[`AvatarUploadResponse`](../../EchoHub.Core/DTOs/ProfileDtos.cs.md) payloads) so the underlying services can remain focused on business logic. The `DeletedUserName` constant is a reserved tombstone username: [`UserService`](../Services/UserService.cs.md) will refuse to register it, and it is used when re-attributing messages after account deletion (messages re-attributed to `DeletedUserName`). Note also that exported account data includes stored messages but that any end-to-end encrypted room content remains ciphertext on the server (the controller preserves what the server stores, it does not decrypt client-side E2E content).
## Notes
- UploadAvatar requires a multipart/form POST (Request.HasFormContentType) and will reject requests with no files or files exceeding the configured UploadLimits.MaxAvatarSizeBytes.
- The controller reads the caller's user id from ClaimTypes.NameIdentifier and uses Guid.Parse; if the claim is present but malformed the parse will throw. The implementation assumes authenticated tokens supply a well-formed GUID.
- Take care when extending or changing image validation: FileValidationHelper.IsValidImage is called on the uploaded stream before ImageToAsciiService.ConvertToAscii is invoked. If validation reads the stream to its end without rewinding, the conversion will receive an empty stream — ensure the validation either rewinds the stream or operates on a buffered/copy of the data.
- The controller is annotated with `Authorize`, so every action requires authentication by default. An action must be decorated with `AllowAnonymous` to be reachable without credentials.
- `UploadAvatar` expects a multipart/form-data request and will return `BadRequest` when `Request.Form.Files` is empty. It also enforces size limits using `_uploadLimits.MaxAvatarSizeBytes` and reports the limit in MB in the error text.
- `FileValidationHelper.IsValidImage` is used to allow only images (it recognizes JPEG, PNG, GIF and WebP). Because both validation and ASCII conversion operate on the same `Stream` (`file.OpenReadStream()`), ensure the validation method does not consume the stream or that the stream position is reset before calling `ImageToAsciiService.ConvertToAscii` — otherwise the conversion may see an empty stream.
- The controller extracts the caller identity using `User.FindFirstValue(ClaimTypes.NameIdentifier)` and then calls `Guid.Parse(...)`. If the claim is present but not a valid GUID this will throw; callers should ensure the claim is a GUID or the parsing should be hardened (for example with `Guid.TryParse`).
- Upload endpoints have a more specific rate limit: the controller-level `[EnableRateLimiting("general")]` applies broadly while `UploadAvatar` additionally uses `[EnableRateLimiting("upload")]`, so be aware of which policy will throttle a client.
- Many methods rely on `MapUserError` to convert domain errors into HTTP responses; consumers of these endpoints should expect standardized [`ErrorResponse`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) payloads for error cases.
@@ -8,4 +8,74 @@ public class EchoHubDbContext : DbContext
```
EchoHubDbContext serves as the EF Core persistence gateway for EchoHub's domain model. It exposes `DbSet<User>`, `DbSet<Channel>`, `DbSet<Message>`, `DbSet<Attachment>`, `DbSet<RefreshToken>`, `DbSet<ChannelMembership>`, `DbSet<InviteCode>`, and `DbSet<ServerStatsReport>`, enabling queries and updates against the underlying SQLite store. When not configured by the application host, it configures a file-based SQLite database named echohub.db located under the application base directory, providing a simple local data store for development and testing. In OnModelCreating it enforces domain rules through keys, indices, field length constraints, and relationship mappings (for example, a Channel has many Messages; a Message has many Attachments; ChannelMembership uses a composite key of UserId and ChannelId), ensuring data integrity and cascade behaviors across related entities.
EchoHubDbContext is the EF Core `DbContext` that exposes the EchoHub data model to the database. It provides `DbSet` properties for core aggregates such as [`User`](../../EchoHub.Core/Models/User.cs.md), [`Channel`](../../EchoHub.Core/Models/Channel.cs.md), [`Message`](../../EchoHub.Core/Models/Message.cs.md), [`Attachment`](../../EchoHub.Core/Models/Attachment.cs.md), `RefreshToken`, [`ChannelMembership`](../../EchoHub.Core/Models/ChannelMembership.cs.md), [`InviteCode`](../../EchoHub.Core/Models/InviteCode.cs.md), and [`ServerStatsReport`](../../EchoHub.Core/Models/ServerStatsReport.cs.md), enabling typed queries and persistence throughout the application. When the context is not configured by the host, `OnConfiguring` automatically wires up a SQLite database file named `echohub.db` in the application's base directory via `AppContext.BaseDirectory` and the `UseSqlite` provider.
## Remarks
`EchoHubDbContext` centralizes data access for the domain, acting as the bridge between in-memory entities and their persisted representations. The `OnModelCreating` configuration defines keys, unique constraints, indices, and relationships that enforce data integrity and shape the underlying schema: [`User`](../../EchoHub.Core/Models/User.cs.md) enforces a unique `Username` with length limits; [`Channel`](../../EchoHub.Core/Models/Channel.cs.md) and [`ChannelMembership`](../../EchoHub.Core/Models/ChannelMembership.cs.md) establish channel scopes and membership rules; [`Message`](../../EchoHub.Core/Models/Message.cs.md) and [`Attachment`](../../EchoHub.Core/Models/Attachment.cs.md) model the document and media relationships with cascade deletes to maintain referential integrity; and [`ServerStatsReport`](../../EchoHub.Core/Models/ServerStatsReport.cs.md) records runtime metrics. This design keeps persistence concerns isolated from business logic while ensuring consistent, queryable access to all EchoHub data.
## Dependencies
- `DbContext`
- [`User`](../../EchoHub.Core/Models/User.cs.md)
- [`Channel`](../../EchoHub.Core/Models/Channel.cs.md)
- [`Message`](../../EchoHub.Core/Models/Message.cs.md)
- [`Attachment`](../../EchoHub.Core/Models/Attachment.cs.md)
- `RefreshToken`
- [`ChannelMembership`](../../EchoHub.Core/Models/ChannelMembership.cs.md)
- [`InviteCode`](../../EchoHub.Core/Models/InviteCode.cs.md)
- [`ServerStatsReport`](../../EchoHub.Core/Models/ServerStatsReport.cs.md)
## Dependency APIs
- property [`User`](../../EchoHub.Core/Models/User.cs.md) (`src/EchoHub.Core/Models/RefreshToken.cs`)
- class [`Channel`](../../EchoHub.Core/Models/Channel.cs.md) (`src/EchoHub.Core/Models/Channel.cs`)
- property `Guid Id`
- property `string Name`
- property `string? Topic`
- property `bool IsPublic`
- property `bool IsSystem`
- property `string? PasswordHash`
- property `string? EncryptionSalt`
- property `string? WrappedRoomKey`
- property `DateTimeOffset CreatedAt`
- property `Guid CreatedByUserId`
- property `List<Message> Messages`
- property [`Message`](../../EchoHub.Core/Models/Message.cs.md) (`src/EchoHub.Core/Models/Attachment.cs`)
- class [`Attachment`](../../EchoHub.Core/Models/Attachment.cs.md) (`src/EchoHub.Core/Models/Attachment.cs`)
- property `Guid Id`
- property `Guid MessageId`
- property `Message? Message`
- property `AttachmentKind Kind`
- property `string Url`
- property `string FileName`
- property `long FileSize`
- property `string? AsciiPreview`
- property `RefreshToken` (`src/EchoHub.Client/Config/ClientConfig.cs`)
- class [`ChannelMembership`](../../EchoHub.Core/Models/ChannelMembership.cs.md) (`src/EchoHub.Core/Models/ChannelMembership.cs`)
- property `Guid UserId`
- property `Guid ChannelId`
- property `DateTimeOffset JoinedAt`
- class [`InviteCode`](../../EchoHub.Core/Models/InviteCode.cs.md) (`src/EchoHub.Core/Models/InviteCode.cs`)
- property `Guid Id`
- property `string Code`
- property `Guid CreatedByUserId`
- property `string CreatedByUsername`
- property `DateTimeOffset CreatedAt`
- property `DateTimeOffset? ExpiresAt`
- property `int MaxUses`
- property `int UseCount`
- class [`ServerStatsReport`](../../EchoHub.Core/Models/ServerStatsReport.cs.md) (`src/EchoHub.Core/Models/ServerStatsReport.cs`)
- property `Guid Id`
- property `DateTimeOffset GeneratedAt`
- property `DateTimeOffset PeriodStart`
- property `DateTimeOffset PeriodEnd`
- property `double WindowHours`
- property `int MessagesSent`
- property `int FilesUploaded`
- property `long BytesUploaded`
- property `int NewMembers`
- property `int ActiveMembers`
- property `int Connections`
- property `int Disconnections`
- …and 5 more member(s) not shown
## Notes
- The `EchoHubDbContext` relies on SQLite as the backing store when not configured externally; ensure the application process has write access to the base directory where `echohub.db` is created.
@@ -8,38 +8,34 @@
```mermaid
%%{init: {'theme':'base','themeVariables':{'background':'#faf7ef','primaryColor':'#f0e2c2','primaryTextColor':'#1f2840','primaryBorderColor':'#8a7548','secondaryColor':'#d9efec','secondaryBorderColor':'#1d8a80','secondaryTextColor':'#1f2840','tertiaryColor':'#f2ebd8','tertiaryBorderColor':'#8a7548','tertiaryTextColor':'#1f2840','lineColor':'#1d8a80','titleColor':'#1f2840','fontSize':'14px','edgeLabelBackground':'#faf7ef','clusterBkg':'#f2ebd8','clusterBorder':'#8a7548','actorBkg':'#f0e2c2','actorBorder':'#8a7548','actorTextColor':'#1f2840','actorLineColor':'#8a7548','signalColor':'#1d8a80','signalTextColor':'#1f2840','activationBkgColor':'#d9efec','activationBorderColor':'#1d8a80','noteBkgColor':'#f2ebd8','noteBorderColor':'#8a7548','noteTextColor':'#1f2840','labelBoxBkgColor':'#f0e2c2','labelBoxBorderColor':'#8a7548','labelTextColor':'#1f2840','transitionColor':'#1d8a80','transitionLabelColor':'#1f2840','stateLabelColor':'#1f2840','altBackground':'#f2ebd8'}}}%%
flowchart TB
User["User connects or calls JoinChannel"]
CH_OnConnected["ChatHub: OnConnectedAsync()"]
IChatServiceConn["IChatService: UserConnectedAsync(Context.ConnectionId, CurrentUserId, CurrentUsername)"]
BaseOnConnected["Call base.OnConnectedAsync()"]
OnConnectedCatch["On exception: Log error and rethrow"]
start["Incoming Hub action to ChatHub"]
CH_Join["ChatHub: JoinChannel(channelName, password)"]
IChatServiceJoin["IChatService: JoinChannelAsync(Context.ConnectionId, CurrentUserId, CurrentUsername, channelName, password)"]
CheckError{"error is not null?"}
ReturnFail["Return JoinChannelResult(false, [], error, passwordRequired)"]
AddGroup["Add connection to SignalR group 'channelName.ToLowerInvariant().Trim()'"]
IChannelServiceNode["IChannelService: GetChannelKeyEnvelopeAsync(channelName)"]
ReturnSuccess["Return JoinChannelResult(true, history, EncryptionSalt, WrappedRoomKey)"]
JoinCatch["On exception: Log error"]
ReturnJoinCatch["Return JoinChannelResult(false, [], 'Failed to join channel: ex.Message')"]
start --> onConn["OnConnectedAsync"]
onConn --> callUserConnected["Call IChatService.UserConnectedAsync(Context.ConnectionId, CurrentUserId, CurrentUsername)"]
callUserConnected --> baseOnConn["Call base.OnConnectedAsync()"]
callUserConnected -->|"exception"| onConnLog["Log error via ILogger and rethrow"]
baseOnConn --> onConnEnd["OnConnectedAsync returns"]
User --> CH_OnConnected
CH_OnConnected --> IChatServiceConn
IChatServiceConn --> BaseOnConnected
CH_OnConnected -->|"exception"| OnConnectedCatch
start --> onDisc["OnDisconnectedAsync"]
onDisc --> callUserDisconnected["Call IChatService.UserDisconnectedAsync(Context.ConnectionId)"]
callUserDisconnected --> baseOnDisc["Call base.OnDisconnectedAsync(exception)"]
callUserDisconnected -->|"exception"| onDiscLog["Log error via ILogger and rethrow"]
baseOnDisc --> onDiscEnd["OnDisconnectedAsync returns"]
User --> CH_Join
CH_Join --> IChatServiceJoin
IChatServiceJoin --> CheckError
CheckError -->|"yes"| ReturnFail
CheckError -->|"no"| AddGroup
AddGroup --> IChannelServiceNode
IChannelServiceNode --> ReturnSuccess
start --> join["JoinChannel(channelName, password?)"]
join --> callJoinService["Call IChatService.JoinChannelAsync(Context.ConnectionId, CurrentUserId, CurrentUsername, channelName, password)"]
callJoinService --> joinDecision{"error is not null?"}
joinDecision -->|"yes"| joinReturnError["Return JoinChannelResult(false, [], error, passwordRequired)"]
joinDecision -->|"no"| addGroup["Call Groups.AddToGroupAsync(Context.ConnectionId, channelName.ToLowerInvariant().Trim())"]
addGroup --> getEnvelope["Call IChannelService.GetChannelKeyEnvelopeAsync(channelName)"]
getEnvelope --> joinReturnSuccess["Return JoinChannelResult(true, history, EncryptionSalt, WrappedRoomKey)"]
callJoinService -->|"exception"| joinExceptionLog["Log error via ILogger; Return JoinChannelResult(false, [], Failed to join channel: ex.Message)"]
CH_Join -->|"exception"| JoinCatch
IChatServiceJoin -->|"exception"| JoinCatch
JoinCatch --> ReturnJoinCatch
start --> leave["LeaveChannel(channelName)"]
leave --> normalize["Normalize channelName to lowerInvariant and trim"]
normalize --> callLeaveService["Call IChatService.LeaveChannelAsync(Context.ConnectionId, CurrentUsername, channelName)"]
callLeaveService --> removeFromGroup["Call Groups.RemoveFromGroupAsync(Context.ConnectionId, channelName)"]
callLeaveService -->|"exception"| leaveExceptionLog["Log error via ILogger"]
```
```csharp
@@ -48,13 +44,12 @@ public class ChatHub : Hub<IEchoHubClient>
```
A SignalR Hub that exposes real-time chat operations to authenticated clients. ChatHub mediates between connected clients and the server-side chat logic (IChatService) and channel management (IChannelService), handling user connection lifecycle, channel join/leave actions, message sending, and delivery of channel encryption envelopes when applicable.
A SignalR hub that exposes real-time chat operations (connect/disconnect, join/leave channel, send messages) and bridges authenticated SignalR connections with the domain services that manage presence, channels and message delivery. Reach for `ChatHub` when you need a server-side, authenticated entry point that coordinates [`IChatService`](../../EchoHub.Core/Contracts/IChatService.cs.md) and [`IChannelService`](../../EchoHub.Core/Contracts/IChannelService.cs.md), manages SignalR groups, and forwards notifications to clients via the [`IEchoHubClient`](../../EchoHub.Core/Contracts/IEchoHubClient.cs.md) callbacks.
## Remarks
ChatHub is an authorization-guarded entrypoint for real-time chat behavior: it uses the caller's claims to identify the user, registers and deregisters connection state with IChatService on connect/disconnect, and forwards channel and message operations to the underlying domain services. It centralizes error logging and converts service-level outcomes into client-facing responses (for example, returning a JoinChannelResult or invoking Error on the caller). The hub also normalizes group names (lowercasing and trimming) and returns channel key envelopes from IChannelService for encrypted rooms so clients can unwrap room keys locally.
`ChatHub` is a thin application-layer adapter: it enforces authentication (the class is decorated with `Authorize`), resolves the current user from the SignalR `Context` claims via `CurrentUserId` and `CurrentUsername`, and delegates core logic to [`IChatService`](../../EchoHub.Core/Contracts/IChatService.cs.md) and [`IChannelService`](../../EchoHub.Core/Contracts/IChannelService.cs.md). It is responsible for SignalR group membership using `Groups.AddToGroupAsync` / `Groups.RemoveFromGroupAsync`, for logging errors via `ILogger<ChatHub>`, and for returning protocol-shaped results such as [`JoinChannelResult`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) and client-facing error messages through the `IEchoHubClient.Error` callback. The hub intentionally does not perform domain operations itself — it translates connection-level events and client requests into calls to the underlying services and shapes the responses for connected clients.
## Notes
- The hub requires authentication claims: CurrentUserId and CurrentUsername read from the connection's ClaimsPrincipal and will throw a HubException if the expected claims are missing. Ensure clients authenticate and include these claims.
- Channel names are normalized via ToLowerInvariant().Trim() before being added to or removed from SignalR groups; callers should expect case-insensitive channel membership.
- When joining encrypted channels the hub obtains an encryption salt and a wrapped room key from IChannelService and returns them to the client so the client can decrypt room keys locally — the server does not hold the raw room key.
- Connection lifecycle failures in OnConnectedAsync/OnDisconnectedAsync are logged and rethrown, while most per-operation failures return structured results or invoke Clients.Caller.Error so callers receive a clear error message without server-side leaks.
- `CurrentUserId` and `CurrentUsername` throw a `HubException` if the expected claims are absent; the `Authorize` attribute reduces this risk, but any token must contain `ClaimTypes.NameIdentifier` and a `"username"` claim for the hub to function.
- Channel names are normalized with `channelName.ToLowerInvariant().Trim()` before being used with SignalR groups; callers and services must use the same normalization to avoid mismatches in group membership.
- When `JoinChannel` succeeds for an encrypted channel the hub returns the channel key envelope obtained from `IChannelService.GetChannelKeyEnvelopeAsync` (the server comment notes members unwrap the room key client-side); do not expect the server to decrypt room content for clients.
@@ -4,12 +4,4 @@
> **Kind:** file
Bootstraps and hosts the EchoHub.Server application as a resilient, self-hosting startup routine. It is the entrypoint that wires configuration, logging, data access, authentication, and service registrations, then starts the ASP.NET Core web host inside a self-healing loop that restarts on startup failures.
## Remarks
Program serves as the composition root of EchoHub.Server. It orchestrates essential cross-cutting concerns—initial configuration via FirstRunSetup, bootstrap logging, server-logs integration with Serilog, data access via EF Core SQLite, and authentication via JWT—by registering the relevant services and options early in the host lifecycle. The self-restarting loop provides resilience during startup and deploy-time hiccups, ensuring the server recovers automatically while debugging and monitoring can observe repeated failures. It ties together the server's operational concerns and acts as the single entry point that other components rely upon to start the web application.
## Notes
- The startup loop restarts the host on failures, enabling resilience but potentially causing rapid churn if issues persist; external monitoring is recommended to detect persistent problems.
- Jwt:Secret must be configured; missing configuration leads to startup failure (an InvalidOperationException is thrown during startup).
- The app uses SQLite by default (echohub.db) located in AppContext.BaseDirectory; ensure filesystem permissions and migrations are properly managed in deployment.
Program.cs is the entry point for the EchoHub.Server application. It bootstraps the server by performing the one-time setup via `FirstRunSetup.EnsureAppSettings()`, configuring the bootstrap logger, and then starting the ASP.NET Core host via `WebApplication.CreateBuilder(args)` inside an auto-restart loop. It wires core infrastructure such as [`EchoHubDbContext`](Data/EchoHubDbContext.cs.md) for EF Core and authentication, and server features like [`ServerLogsOptions`](Config/ServerLogsOptions.cs.md)/[`ServerLogsService`](Services/ServerLogs/ServerLogsService.cs.md), enabling the app to recover from startup failures by rebuilding and running the web host repeatedly.
@@ -13,15 +13,15 @@
- [EnsureSystemChannelAsync](#ensuresystemchannelasync)
- [GetChannelByNameAsync](#getchannelbynameasync)
- [GetChannelCryptoAsync](#getchannelcryptoasync)
- [GetChannelKeyEnvelopeAsync](#getchannelkeyenvelopeasync)
- [GetChannelListAsync](#getchannellistasync)
- [GetChannelMetaAsync](#getchannelmetaasync)
- [GetChannelTopicAsync](#getchanneltopicasync)
- [GetChannelsAsync](#getchannelsasync)
- [RekeyChannelAsync](#rekeychannelasync)
- [SetChannelPasswordAsync](#setchannelpasswordasync)
- [UpdateTopicAsync](#updatetopicasync)
- [ValidateChannelPassword](#validatechannelpassword)
- [GetChannelKeyEnvelopeAsync](#getchannelkeyenvelopeasync)
- [GetChannelTopicAsync](#getchanneltopicasync)
---
@@ -34,16 +34,15 @@ public class ChannelService : IChannelService
```
Manages server-side channel (room) operations: creation, deletion, listing and metadata, membership enforcement, password gating, and end-to-end encryption key envelopes. Reach for ChannelService when you need authoritative server logic that enforces channel rules and persists channel state (including password and E2E envelope handling), rather than making client-side assumptions or manipulating storage directly.
A high-level service that implements [`IChannelService`](../../EchoHub.Core/Contracts/IChannelService.cs.md) and centralizes channel lifecycle and membership operations for the server: listing and paging channels (`GetChannelsAsync`), creating/updating/deleting channels (`CreateChannelAsync`, `UpdateTopicAsync`, `DeleteChannelAsync`), password and encryption envelope management (`SetChannelPasswordAsync`, `RekeyChannelAsync`, `GetChannelKeyEnvelopeAsync`), and retrieving channel metadata/crypto details (`GetChannelMetaAsync`, `GetChannelCryptoAsync`). Use `ChannelService` when you need the server-side orchestration for channel policies, membership checks and the authoritative source of channel metadata and cryptographic envelopes rather than calling lower-level storage or presence primitives directly.
## Remarks
ChannelService is the central server implementation of IChannelService and enforces policy around channels: who may see or join rooms, how passwords and encryption envelopes are handled, and how the system "log" room is treated differently from ordinary channels. It coordinates presence tracking, spam-throttling (via SpamGuard), and server logging to ensure operations such as channel creation, rekeying, and membership checks are performed consistently and safely. The service preserves the distinction between password-gated channels and end-to-end (E2E) encrypted channels by exposing separate operations for setting/clearing passwords and for rekeying the wrapped room key.
`ChannelService` acts as the application-level coordinator for channel-related concerns. It composes smaller services such as [`PresenceTracker`](PresenceTracker.cs.md), [`SpamGuard`](SpamGuard.cs.md), and [`ServerLogsService`](ServerLogs/ServerLogsService.cs.md), and enforces business rules (creator/admin permissions, role-gated system channels, creation throttling) so callers do not need to reimplement policy logic. The class is responsible for keeping cryptographic envelope state (`EncryptionSalt` / `WrappedRoomKey`) separate from message content keys and for exposing those envelopes through `GetChannelKeyEnvelopeAsync` and `GetChannelCryptoAsync` while preserving server-side metadata like sender identity counts and storage footprint.
## Notes
- SetChannelPasswordAsync is not applicable to end-to-end encrypted channels; encrypted rooms change access by rekeying via RekeyChannelAsync so the room key envelope remains consistent. Clearing a password is performed by passing null as the password parameter.
- RekeyChannelAsync is restricted to the channel creator: administrators who do not know the current passphrase cannot rekey a channel on the creator's behalf.
- The system "live log" room is role-gated and its name is reserved even when the feature is disabled; this prevents user-owned channels from accidentally becoming the stream target if the feature is enabled later.
- Channel creation is subject to spam-throttling; moderators and higher roles are exempt from the throttle enforced by SpamGuard.
- The system "live log" channel has a reserved name and is role-gated: it is visible only to configured roles regardless of membership; the name remains reserved even if the feature is disabled. Be careful when creating channels with that name.
- End-to-end encrypted channels use a different flow: `SetChannelPasswordAsync` is not available for E2E channels; to change a passphrase the service uses `RekeyChannelAsync`, which swaps the join-gate hash and the wrapped room key but does not rotate the room content key (so history remains readable to clients that can re-wrap the key).
- Channel creation is subject to throttling via [`SpamGuard`](SpamGuard.cs.md) (moderators and above are exempt) and creators are automatically added as members; callers should handle [`ChannelOperationResult`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) responses (success/failure and error messages) rather than assuming the operation always succeeds.
---
@@ -71,15 +70,10 @@ public ChannelService(
| `logger` | `ILogger<ChannelService>` | — |
Initializes ChannelService by wiring its required collaborators into private fields for later use. The constructor accepts a scope factory, a presence tracker, a spam guard, a server logs service, and a logger, and stores them for use by the instance. In typical applications, the dependency injection container supplies these services, so ChannelService can create short-lived scopes when needed, track user presence, guard against spam, record server-side events, and emit contextual logs.
Constructs a `ChannelService` by taking its required collaborators from the dependency injection container and caching them in private fields for later use. This constructor is invoked by the DI framework when creating a `ChannelService` instance, so consumers typically rely on DI rather than invoking it directly.
## Remarks
By taking dependencies through constructor injection, ChannelService remains loosely coupled and highly testable, since test doubles can be supplied in place of real implementations. This composition root clarifies the service's responsibilities—managing channel state with awareness of presence, applying spam protection, and observability through logs.
## Notes
- If ChannelService is registered as a singleton, ensure that the injected services are thread-safe or have appropriate lifetimes; otherwise adjust registrations to avoid unsafe sharing.
- If the class creates scopes via the IServiceScopeFactory, dispose them promptly to avoid memory leaks or disposed-service access.
- Verify the DI container can resolve all dependencies at startup; a misconfiguration will surface as a runtime resolution failure.
This constructor wires together a set of collaborators required by `ChannelService`: `IServiceScopeFactory` for creating scoped services, [`PresenceTracker`](PresenceTracker.cs.md) for tracking user presence, [`SpamGuard`](SpamGuard.cs.md) for abuse protection, [`ServerLogsService`](ServerLogs/ServerLogsService.cs.md) for server-side logging, and `ILogger<ChannelService>` for structured logging. By storing these dependencies in private fields, the class remains focused on channel-related behavior while delegating infrastructure concerns to dedicated services. This separation also improves testability by allowing mocks or fakes to replace the collaborators during unit tests.
---
@@ -90,7 +84,7 @@ By taking dependencies through constructor injection, ChannelService remains loo
```csharp
public async Task<ChannelOperationResult> CreateChannelAsync(
Guid creatorUserId, string name, string? topic, bool isPublic,
string? [REDACTED:CONNECTION_STRING_PASSWORD] string? encryptionSalt = null, string? wrappedRoomKey = null)
string? password = null, string? encryptionSalt = null, string? wrappedRoomKey = null)
```
**Parameters:**
@@ -101,21 +95,22 @@ public async Task<ChannelOperationResult> CreateChannelAsync(
| `name` | `string` | — |
| `topic` | `string?` | — |
| `isPublic` | `bool` | — |
| `encryptionSalt` | `string? [REDACTED:CONNECTION_STRING_PASSWORD] string?` | `null` |
| `password` | `string?` | `null` |
| `encryptionSalt` | `string?` | `null` |
| `wrappedRoomKey` | `string?` | `null` |
**Returns:** `Task<ChannelOperationResult>`
Creates a new chat channel using the provided parameters, validating the name, enforcing reserved names, optionally handling a password (hashed) and an end-to-end encryption envelope, and persisting the channel with the creator as a member. Use this when you need to create a channel with consistent validation, security, and membership semantics.
Creates a new channel with the given `creatorUserId`, `name`, optional `topic`, visibility via `isPublic`, and optional security settings (`password`, `encryptionSalt`, `wrappedRoomKey`). It validates the input (name presence, name pattern via `ValidationConstants.ChannelNameRegex()`, and reserved names against `_serverLogs.Options.NormalizedRoomName`), ensures the channel name is unique, optionally hashes a password with BCrypt, and stores envelope data only when both `encryptionSalt` and `wrappedRoomKey` are supplied. If a password or envelope is provided, the corresponding fields are populated accordingly; otherwise they remain null. The creator automatically becomes a member, and the operation is throttled by a spam guard for non-exempt users. The method persists changes and returns a successful [`ChannelOperationResult`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) containing a [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md), or a failure with a [`ChannelError`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) and message in cases of validation failure, duplication, or other policy violations.
## Remarks
This method centralizes all channel-creation logic, applying business rules such as name normalization (lowercasing and trimming), reserved-name protection for the log room, password requirements for encrypted channels, and spam throttling before persisting data. It leverages a scoped database context to create the channel and automatically adds the creator as a member, ensuring the creator has immediate access. The reserved log room name is enforced regardless of feature toggles, preventing accidental conflicts with system channels.
**Remarks**
This method centralizes channel creation concerns, including input validation, security policy, and persistence, so callers dont need to implement these cross-cutting concerns separately. It coordinates between domain entities ([`Channel`](../../EchoHub.Core/Models/Channel.cs.md), [`ChannelMembership`](../../EchoHub.Core/Models/ChannelMembership.cs.md)) and their DTOs, while enforcing organizational policies (e.g., reserved names, password requirements for encrypted channels, and anti-spam). The return shape guarantees a consistent success path with a populated [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) or a clear failure path via `ChannelOperationResult.Fail`.
## Notes
- The channel name is normalized to lowercase and trimmed, which makes channel uniqueness effectively case-insensitive.
- If an end-to-end envelope is supplied (encryptionSalt and wrappedRoomKey), a password must also be provided; otherwise creation fails with a validation error.
- A race on channel name creation is possible in highly concurrent scenarios; the code checks for existence prior to insert and relies on the database to enforce final uniqueness if necessary.
**Notes**
- Normalization and validation: the stored channel name is the lowercased, trimmed form and must pass `ValidationConstants.ChannelNameRegex()`; attempting to create a channel with a name that already exists yields `ChannelError.AlreadyExists`.
- Security coupling: if an envelope is provided, a non-empty `password` is required, and the password (if any) is hashed with BCrypt; envelope data is only stored when both `encryptionSalt` and `wrappedRoomKey` are present.
- Anti-spam policy: channel creation is guarded by `_spamGuard` (non-exempt users may be blocked for rapid creation), reinforcing rate-limiting behavior at the data access boundary.
---
@@ -138,14 +133,7 @@ public async Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId,
**Returns:** `Task<ChannelOperationResult>`
Deletes a channel by name for a given caller, enforcing that only the channel creator or an administrator can perform the deletion and that protected/default channels cannot be removed. It normalizes the channel name, validates existence and non-system status, removes the channel from the database, saves changes, and returns a ChannelOperationResult containing a ChannelDto with the channels identity and metadata; on failure it maps to a corresponding ChannelError with a descriptive message.
## Remarks
This method encapsulates the channel-deletion policy in a single place, ensuring consistent authorization checks and error signaling across call sites. It delegates data access to EchoHubDbContext via a scoped DI container and returns a ChannelDto representing the deleted channels identity and basic attributes, which can be used by clients to refresh UI state or logs.
## Notes
- The ChannelDto is constructed after the channel row is removed and SaveChangesAsync completes, so the returned DTO serves as a confirmation of what was deleted rather than a live snapshot of a remaining entity.
Deletes a channel by name, enforcing that only the channel's creator or an administrator can perform the deletion while protecting the default and system channels. The input channel name is normalized to lower-case and trimmed, a scoped [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) is used to locate the channel, and the operation returns a [`ChannelOperationResult`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) with a specific [`ChannelError`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) if the channel does not exist or cannot be deleted. If authorized, the channel is removed from the `db.Channels`, changes are persisted, and a [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) describing the deleted channel is returned inside a successful [`ChannelOperationResult`](../../EchoHub.Core/DTOs/CommonDtos.cs.md).
---
@@ -155,7 +143,7 @@ This method encapsulates the channel-deletion policy in a single place, ensuring
```csharp
public async Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(
Guid userId, string channelName, string? [REDACTED:CONNECTION_STRING_PASSWORD]
Guid userId, string channelName, string? password = null)
```
**Parameters:**
@@ -167,15 +155,20 @@ public async Task<(bool Success, string? Error, bool PasswordRequired)> EnsureCh
| `PasswordRequired` | `bool` | — |
Ensures that a user is granted membership to a named channel, creating or restoring the channel when appropriate, and enforcing access rules including password protection. Call this when a user attempts to join or access a channel so the system can validate eligibility, auto-provision special channels, and persist the membership relationship in the database. The method returns a tuple (Success, Error, PasswordRequired) to indicate whether entry was granted, an error message if any, and whether the caller should prompt for a password.
Ensures that a user identified by `Guid userId` becomes a member of the channel named `channelName`, creating or restoring the channel as needed, enforcing gating rules, and returning a structured result that indicates success, a possible error message, and whether a password is required for first-time joins.
## Remarks
Centralizes channel-join semantics within ChannelService, encapsulating rules around default channels, system/log channels, and password gates. It coordinates with the database context, server configuration, and validation utilities to decide whether entry should be granted, a channel recreated, or a password prompt issued. By funneling join logic through a single path, it reduces duplication and ensures consistent behavior across different join entry points (TUI, REST, IRC).
The method normalizes the channel name using `ToLowerInvariant()` and `Trim()`, then validates it with `ValidationConstants.ChannelNameRegex()`. If the name is invalid, it returns a failed result along with an error message describing the required channel name constraints. It then opens a scope and obtains an [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) to inspect and modify data related to users, channels, and memberships.
If the target channel is a live-logs channel (as determined by `_serverLogs.IsLogsChannel`), the callers ability to view that channel is verified via `_serverLogs.CanView` against the users role; otherwise membership is denied.
If the channel does not exist, the method may auto-recreate it: the default channel (as defined by `HubConstants.DefaultChannel`) is recreated with safe defaults, or a logs channel is recreated with a non-public, system-owned flag and a predefined room topic. If neither special case applies, the method reports that the channel does not exist and should be created first via the channel list.
If the channel exists but is a system channel and the request is not for a logs channel, access is blocked and the join is rejected.
When the caller is not already a member, the method enforces password protection if the channel has a `PasswordHash`. If no password is supplied, it returns success = false with `PasswordRequired` set to true. If a password is supplied but is incorrect (verified via `BCrypt.Verify`), it returns the same shape with `PasswordRequired` = true. On successful password verification (or if no password is needed), a new [`ChannelMembership`](../../EchoHub.Core/Models/ChannelMembership.cs.md) entry is created and persisted.
The function returns a 3-tuple: `(bool Success, string? Error, bool PasswordRequired)`. A successful join yields `(true, null, false)`; otherwise, `Error` describes the failure and `PasswordRequired` signals whether a password is needed for the join.
## Notes
- Automatic channel provisioning: If the requested channel does not exist, the method may recreate the default channel or the log channel and logs a warning. Callers should not assume a static channel list.
- Password gate: For channels with a PasswordHash, a password is required on first-time joins and validated with BCrypt. The method returns PasswordRequired = true in those cases and updates membership only after successful verification.
- Database scope and side effects: The operation creates a short-lived DI scope to access EchoHubDbContext and persists changes (new ChannelMembership, and possibly a newly created Channel). Callers should be mindful of potential race conditions if multiple concurrent joins occur for the same channel.
---
@@ -196,14 +189,13 @@ private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
**Returns:** `Task`
Ensures the application has a canonical default channel in the EchoHub database by checking the Channels collection for a channel named HubConstants.DefaultChannel and seeding one if it does not exist. This bootstrapping helper is intended to be invoked during initialization to guarantee a general discussion channel is present without duplicating the initialization logic elsewhere.
Ensures that the default channel exists in the database by checking for a channel named `HubConstants.DefaultChannel`. If none exists, it creates a new [`Channel`](../../EchoHub.Core/Models/Channel.cs.md) with a generated `Id` (`Guid.NewGuid()`), the default name, a `Topic` of `General discussion`, and a system `CreatedByUserId` of `Guid.Empty`, then saves changes with `SaveChangesAsync`.
## Remarks
By centralizing the default-channel bootstrapping in EnsureDefaultChannelAsync, callers avoid duplicating the existence check and channel-creation code across startup paths. It ties together the Channel entity, the HubConstants default channel name, and the database context, so changes to the default channel semantics propagate from this single place. The method is private and static, reinforcing that it is an internal bootstrap concern rather than a reusable operation for callers.
Centralizes the provisioning of the default channel, letting startup and runtime logic rely on a known channel name without duplicating initialization checks. By using `HubConstants.DefaultChannel` and `Guid.Empty` as the creator, it signals that the record is system-generated and intended as a baseline rather than user-created.
## Notes
- Potential race condition under concurrent invocations: the existence check followed by insertion is not atomic, which could raise a constraint violation if two callers run at the same time.
- CreatedByUserId = Guid.Empty marks system-generated creation; auditing considerations may require handling.
- Potential race condition if this method is invoked concurrently during initialization; ensure it runs once or enforce a database constraint on `Channels.Name` to prevent duplicates.
---
@@ -225,15 +217,10 @@ public async Task<ChannelDto> EnsureSystemChannelAsync(string channelName, strin
**Returns:** `Task<ChannelDto>`
Ensures there is a system-owned channel with the specified name by normalizing the name and either creating a new system channel or converting an existing non-system channel into a system channel. It then returns a ChannelDto describing the channels identity, topic, visibility, and system status.
Ensures that a system channel with the specified name exists in the database by normalizing the name and looking it up. If none is found, it creates a new system channel (not public) with CreatedByUserId set to an empty GUID and logs its creation. If a non-system channel already exists with that name, it is claimed as a system channel by updating its IsSystem and IsPublic flags and clearing the PasswordHash, logging a warning. It returns a ChannelDto describing the channel's identity and status.
## Remarks
Guarantees a canonical system channel identity for internal communications and server content streaming. It encapsulates the create-or-claim logic behind a single API and logs whether a channel was created or claimed. If the target channel already exists and is already marked as system, the method is effectively a no-op and simply returns its ChannelDto.
## Notes
- There is a potential race condition when two concurrent invocations try to create the same system channel; relying on database constraints or proper isolation is recommended to avoid duplicates.
- If a non-system channel exists with the same name, the code will convert it to a system channel by setting IsSystem = true, IsPublic = false, and clearing PasswordHash; CreatedAt remains the original timestamp.
- The channel name is lower-cased and trimmed before the lookup, so callers should not rely on case-sensitive or whitespace-sensitive channel naming.
This method centralizes the architectural concept of system channels by guaranteeing a canonical system channel for a given name, creating or reclaiming it as needed and thereby preventing user-owned channels from shadowing system channels with reserved identifiers.
---
@@ -254,15 +241,14 @@ public async Task<ChannelDto?> GetChannelByNameAsync(string channelName)
**Returns:** `Task<ChannelDto?>`
GetChannelByNameAsync fetches a channel by its name after normalizing the input to lowercase and trimming whitespace. It creates a new DI scope to obtain EchoHubDbContext, queries the Channels set for a channel whose Name matches the normalized input, and, if found, counts the number of Messages belonging to that channel. If no matching channel exists, it returns null. The returned ChannelDto includes the channels Id, Name, Topic, visibility (IsPublic), the total MessageCount, CreatedAt timestamp, and two boolean flags indicating whether a password hash exists and whether a WrappedRoomKey is present, plus whether the channel is a system channel. This method centralizes the data-shaping of channel metadata for consumers (e.g., channel listings or details) and hides direct EF queries behind a concise API.
Fetches a channel by name in a case-insensitive manner and returns a compact [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) that includes the channels identity, metadata, and the current message count. It normalizes the input, creates a short-lived DI scope to obtain the [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md), resolves the channel by its lowercased name, counts its related [`Message`](../../EchoHub.Core/Models/Message.cs.md)s, and returns a [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) populated with the channels id, name, topic, visibility, created timestamp, and flags indicating whether a password or a wrapped room key exists, plus whether it is a system channel. If no channel matches, it returns `null`.
## Remarks
This abstraction centralizes channel metadata retrieval for UI and API surfaces, ensuring consistent ChannelDto shaping and hiding data-access details behind a single, strongly-typed API. It also clarifies that a null return indicates a non-existent channel.
By encapsulating the read path behind `GetChannelByNameAsync`, callers avoid dealing with EF queries or DI lifetimes directly. It centralizes how channel metadata is retrieved and projected into a [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md), which helps maintain consistent data contracts across the application. The per-call scope ensures proper disposal of the [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) and aligns with typical request-scoped lifetimes.
## Notes
- Returns null when no channel matches the provided name; callers should handle the nullable result.
- Performs two database queries (FirstOrDefaultAsync for the channel, then CountAsync for its messages) when a channel exists; this is straightforward but has a potential perf cost.
- Relies on input normalization to lowercase; if stored channel names are not stored in a comparable form, the lookup could miss matches.
- Potential ambiguity if multiple channels share the same normalized name; `FirstOrDefaultAsync` may return any one of them.
- Two database round-trips per invocation: one to fetch the channel and another to count its messages; consider combining into a single query if profiling shows this as a bottleneck.
---
@@ -283,7 +269,42 @@ public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
**Returns:** `Task<ChannelCryptoDto?>`
Retrieves the ChannelCryptoDto describing the cryptographic state of a channel. The method normalizes the input channel name to lowercase and trims whitespace, then queries the EchoHubDbContext for a Channel with the matching name. If no channel is found, it returns null. If a channel exists, it returns a ChannelCryptoDto where the first value indicates whether a WrappedRoomKey is present (WrappedRoomKey != null) and includes the channel's EncryptionSalt. Data access occurs within a short-lived DI scope created from _scopeFactory, resolving EchoHubDbContext for the lookup.
GetChannelCryptoAsync retrieves the cryptographic metadata for a named channel. It normalizes the input channel name by lowercasing and trimming, opens a short-lived scoped [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md), and queries the `Channels` set for a channel whose `Name` matches the normalized value. If the channel is found, it returns a [`ChannelCryptoDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) whose first component indicates whether a `WrappedRoomKey` is present and whose second component carries the channel's `EncryptionSalt`; if no channel matches, it returns null.
## Remarks
By encapsulating this logic in a dedicated method, callers avoid duplicating the database query and the cryptographic-state interpretation across the codebase. It centralizes encryption-metadata access behind a simple, asynchronous call and uses a scoped DbContext to minimize lifetime and concurrency issues.
## Notes
- Returns null when the channel does not exist.
- The first component of [`ChannelCryptoDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) indicates the presence of a `WrappedRoomKey`; the `EncryptionSalt` may be null depending on data, so callers should handle null salts.
---
### GetChannelKeyEnvelopeAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `EncryptionSalt` | `string?` | — |
| `WrappedRoomKey` | `string?` | — |
Gets the encryption envelope for a channel by name. It normalizes the input with `ToLowerInvariant()` and `Trim()`, opens a short-lived DI scope to resolve [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md), and queries the `Channels` set for a channel whose `Name` matches. It returns a tuple of the channel's `EncryptionSalt` and `WrappedRoomKey` (as `string?`); if no matching channel exists, both values are `null`.
## Remarks
This method centralizes access to channel encryption metadata and hides the details of DI-scoped DbContext usage from callers. It provides a single, easy-to-consume envelope for encryption-related data, which is useful when preparing to decrypt or unwrap channel-specific material. By returning `(string? EncryptionSalt, string? WrappedRoomKey)` as nullable values instead of throwing when a channel is absent, callers must handle the absence gracefully.
## Notes
- Returns `(null, null)` when the channel cannot be found.
- Each invocation creates a new DI scope, which is appropriate for isolated data access but may have perf implications in hot paths; consider scope management or caching at a higher level if this method is called frequently.
---
@@ -298,7 +319,13 @@ public async Task<List<ChannelListItem>> GetChannelListAsync()
**Returns:** `Task<List<ChannelListItem>>`
Fetches and returns a list of channel summaries. The method creates a scoped DI container, reads the EchoHubDbContext, loads all channels ordered by name, and maps each channel to a ChannelListItem that includes the channel's name, topic, the number of online users in that channel (via the presence tracker), whether the channel is public, and whether a password is set. This is typically used to populate a channel directory or lobby UI with up-to-date channel metadata and presence information.
GetChannelListAsync asynchronously loads all channels from the database, orders them by `Name`, and projects each channel into a [`ChannelListItem`](../../EchoHub.Core/Contracts/IChannelService.cs.md) that includes the channel's `Name`, `Topic`, the current online user count from `_presenceTracker.GetOnlineUsersInChannel(c.Name).Count`, the public status (`c.IsPublic`), and whether a password is configured (`c.PasswordHash != null`). The method returns a `List<ChannelListItem>` suitable for rendering a channel catalog in a UI or API response.
## Remarks
GetChannelListAsync acts as an orchestrator between the persistent store ([`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md)) and the inmemory presence tracker (`_presenceTracker`). It centralizes channel-list assembly so callers don't need to know how presence counts are computed or how channels are stored. By resolving [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) within a shortlived scope via `_scopeFactory.CreateScope()`, it ensures proper disposal of the database context per invocation and keeps DI concerns isolated from consumer code.
## Notes
- Presence counts are computed per channel; listing many channels may impact response time. If the channel catalog grows large, consider caching or batching presence data to improve responsiveness.
---
@@ -319,7 +346,40 @@ public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
**Returns:** `Task<ChannelMetaDto?>`
Fetches channel-level metadata for a given channel name without returning the messages themselves. It normalizes the input by lowercasing and trimming, resolves the channel via a scoped DI context, and if the channel exists returns a ChannelMetaDto containing the channel's Id, normalized Name, Topic, flags indicating whether a WrappedRoomKey or PasswordHash exists, the total MessageCount, the distinct count of Senders, an estimated storage footprint for the channel (attachments plus text), and the channel's CreatedAt timestamp. If no channel matches the provided name, the method returns null. The operation executes within a scoped DI context to ensure proper disposal of the database context.
GetChannelMetaAsync retrieves the metadata for a channel by its name and returns a [`ChannelMetaDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) (or null if the channel cannot be found). It normalizes the input with `ToLowerInvariant()` and `Trim()`, opens a scoped [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) via `_scopeFactory.CreateScope()`, and looks up the channel in `db.Channels` by `Name`. When found, it computes the total `messageCount` from `db.Messages.CountAsync(...)`, the number of distinct `SenderUserId`s, and the estimated on-disk footprint from attachments and message text, then returns a new [`ChannelMetaDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) containing `c.Id`, `c.Name`, `c.Topic`, booleans for `c.WrappedRoomKey != null` and `c.PasswordHash != null`, the counts, the total footprint, and `c.CreatedAt`.
## Remarks
This method uses a scoped [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) to perform multiple read-only queries and aggregates data from `db.Channels` and `db.Messages`. The returned booleans reflect whether `c.WrappedRoomKey` or `c.PasswordHash` are non-null, indicating encryption and access protection. For encrypted channels, the footprint uses ciphertext sizes to reflect on-disk cost, and sender identities are treated as metadata preserved by the server even when messages are encrypted.
## Notes
- Callers must handle the possibility that the return value is `null` when no channel matches the given `channelName`.
---
### GetChannelTopicAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `Topic` | `string?` | — |
| `Exists` | `bool` | — |
GetChannelTopicAsync retrieves the topic for a channel identified by `channelName`. It normalizes the input by calling `ToLowerInvariant()` and `Trim()`, opens a short-lived DI scope to resolve [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md), queries the `Channels` set for a channel whose `Name` equals the normalized value using `FirstOrDefaultAsync`, and returns the `(Topic, Exists)` tuple; if no channel exists, it returns `(null, false)`.
## Remarks
Encapsulates a small piece of data access behind a scoped context, avoiding long-lived DbContext usage and centralizing the normalization logic for channel lookups. The API communicates existence via the `Exists` flag, while the `Topic` can still be `null` if a channel exists but has no topic set.
## Notes
- The lookup uses `FirstOrDefaultAsync` on `db.Channels`; if more than one channel shares the same normalized `Name`, the returned topic is non-deterministic; enforce unique `Name` values to avoid surprises.
- A per-call DI scope is created to obtain [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md); callers should not rely on an ambient scope for this operation.
---
@@ -342,15 +402,10 @@ public async Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, i
**Returns:** `Task<PaginatedResponse<ChannelDto>>`
Fetches a paginated list of channels visible to the specified user, ensuring a default channel exists and applying system-channel visibility rules. It builds a Page of ChannelDto items by filtering channels based on whether they are system channels (only visible if the caller has the appropriate server role) or non-system channels (visible if public or if the user is a member). The method returns a PaginatedResponse containing the channels and the total count, ordered with system channels first and then by name. Per-channel metadata includes the number of messages, creation time, and security flags such as whether a password is set or a wrapped room key is present.
GetChannelsAsync returns a paginated list of channels visible to the user identified by `userId`. It first ensures a default channel exists, then determines if the caller can view system channels via `_serverLogs.CanView(caller?.Role ?? ServerRole.Member)`, and finally queries `db.Channels` to surface system channels only when permitted or non-system channels that are public or where the user is a member (via `ChannelMemberships`). The results are ordered with system channels first, then by `Name`, and projected into [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) objects containing each channels `Id`, `Name`, `Topic`, `IsPublic`, `Messages.Count`, `CreatedAt`, and flags for `PasswordHash != null` and `WrappedRoomKey != null`, plus `IsSystem`. The method returns a [`PaginatedResponse<ChannelDto>`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) with the current page of channels and the total count.
## Remarks
This method centralizes channel discovery and visibility logic used by API surfaces and the UI. By enforcing system-channel visibility through server-side role checks and by materializing concise per-channel data into ChannelDto, callers receive a consistent, paged view of channels while preserving the default channel guarantee. The use of a scoped DbContext and a two-phase query (total count, then page fetch) encapsulates the data-access concerns behind a single, well-defined operation.
## Notes
- EnsureDefaultChannelAsync(db) may create the default channel if it is missing; this side effect occurs on every call. callers should be aware of potential writes on read-like operations.
- The total and page fetch are executed as separate queries; data may change between these calls, affecting the reported total and the returned page.
- The channel's Messages.Count is computed in the projection, yielding a per-channel count without loading full message collections.
GetChannelsAsync centralizes the channel visibility policy: system channels (the live log room) are exposed only to users whose role allows viewing server logs, while non-system channels are visible if they are public or the user is a member, as determined by `ChannelMemberships`. The results are produced from a scoped [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) and are ordered to surface system channels first, then alphabetically by name, and are projected into lightweight [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) records to drive UI lists without leaking unnecessary data. This encapsulation ensures consistent, permission-aware channel listing across the application.
---
@@ -378,14 +433,11 @@ public async Task<ChannelOperationResult> RekeyChannelAsync(Guid callerUserId, s
**Returns:** `Task<ChannelOperationResult>`
RekeyChannelAsync rotates the passphrase for an end-to-end encrypted channel by swapping the join-gate hash and the wrapped room key, while the actual content key remains unchanged so the history stays readable. The client then re-wraps the content key under the new passphrase-derived key. This operation is restricted to the channel creator; administrators who do not know the current passphrase cannot perform a rekey.
RekeyChannelAsync rekeys an end-to-end encrypted channel by swapping the `join-gate` hash and the `WrappedRoomKey`, re-wrapping the channel's content key under the new passphrase-derived key while leaving the content key itself unchanged so historical messages remain decryptable. The operation is restricted to the channel creator; admins cannot rekey a room unless they know the current passphrase.
## Remarks
This method encapsulates a security-sensitive transition that updates credential material without discarding encrypted content. By validating the new passphrase (via ValidateChannelPassword) and requiring non-empty new salt and wrapped key before touching the database, it preserves both confidentiality and integrity. The operation executes in a scoped data context to ensure the channel state is read and persisted atomically, reflecting the latest creator-approved configuration while keeping history intact.
Passphrase changes are validated, and the operation returns a [`ChannelOperationResult`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) that is either a success containing a [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) or a failure with a [`ChannelError`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) and message (e.g. `NotFound`, `ValidationFailed`, or `Forbidden`). Internally, the method uses a scoped [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) to locate the channel by name, ensure the channel is end-to-end encrypted, verify the caller is the creator, check the old password, and persist updates to `PasswordHash`, `EncryptionSalt`, and `WrappedRoomKey`. Upon success, it computes the current message count and returns a [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) reflecting the updated credentials.
## Notes
- New password, salt, and wrapped key are validated before any changes are persisted; if validation fails, the operation aborts with a ValidationFailed result.
- Rekeying is restricted to the channel creator; the method enforces this by verifying the caller's user ID and the correctness of the current passphrase before applying changes.
This operation centralizes the sensitive rekey workflow and ensures the channel state remains consistent and auditable within a single database transaction.
---
@@ -409,18 +461,15 @@ public async Task<ChannelOperationResult> SetChannelPasswordAsync(Guid callerUse
**Returns:** `Task<ChannelOperationResult>`
Sets, changes, or clears (null) a channel's join password. This operation is allowed only for the channel's creator or an administrator. End-to-end encrypted channels do not accept password changes here; such channels must use RekeyChannelAsync to rotate the passphrase, preserving the room key envelope.
Sets, changes, or clears (null) a channel's join password. Creator or admin only. Not available on end-to-end encrypted channels — those change passphrase via RekeyChannelAsync so the room key envelope stays consistent. The method normalizes the channel name to lowercase and trims, validates the password via `ValidateChannelPassword`, and then uses a scoped [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) to locate the channel and enforce authorization. If the channel doesn't exist, is a system channel, or is end-to-end encrypted, it returns an appropriate [`ChannelOperationResult`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) failure. If the caller is the channel creator or an admin, it updates the channel's `PasswordHash` (hashing a non-null password with `BCrypt.Net.BCrypt.HashPassword` or clearing it when `password` is null), persists the changes, counts the channel's messages, and returns a [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) describing the channel along with flags for password protection and encryption.
## Remarks
By centralizing password management in this method, the system enforces consistent authorization, validation, and persistence rules for channel passphrases. It guards against modifying system channels and avoids altering encryption state for end-to-end encrypted channels at this layer, delegating that concern to RekeyChannelAsync when appropriate. The method returns a ChannelDto describing the updated channel, including whether a password is set and whether the channel remains end-to-end encrypted.
Centralizes channel password management behind a single operation that enforces ownership and role-based access. It interacts with the EF Core context to fetch and persist channel state and to surface up-to-date metadata via [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) (including whether a password is active and whether the channel is end-to-end encrypted). The method explicitly avoids modifying end-to-end encrypted channels here, directing such changes to `RekeyChannelAsync` to preserve the room key envelope.
## Notes
- Clearing the password (passing `null`) removes the join password, which may affect who can join depending on the channel's other visibility settings.
- Only the channel creator or an admin can perform password changes; otherwise the call returns `ChannelError.Forbidden`.
- Passwords are stored as BCrypt hashes; if a null password is provided, the password is cleared (PasswordHash becomes null).
- The channel name is normalized to lowercase and trimmed before lookup to ensure stable, case-insensitive matching.
- If the channel does not exist, is a system channel, or the caller lacks sufficient privileges (not the creator or an admin), the operation fails with NotFound, Protected, or Forbidden respectively.
- After a successful change, the returned ChannelDto includes the current message count and flags indicating HasPassword and HasWrappedKey, reflecting the channel's encryption state.
---
@@ -444,15 +493,16 @@ public async Task<ChannelOperationResult> UpdateTopicAsync(
**Returns:** `Task<ChannelOperationResult>`
Updates the topic of a channel, performing authorization, validation, and persistence in one operation. Given the caller's user ID and the channel name, it normalizes the name, enforces topic length (when provided), ensures only the channel creator can update, persists the topic change, and returns a ChannelOperationResult containing a ChannelDto with the channel's identity, current topic, visibility, message count, creation time, and indicators for password protection and wrapped room key.
Updates the topic of a channel by name, but only if the caller is the channel's creator. It trims and validates a non-null `topic` against `ValidationConstants.MaxChannelTopicLength` (a null `topic` clears the topic), normalizes the channel name to lower-case, persists the change via EF Core, and returns a `ChannelOperationResult.Success` with a [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) containing the updated channel data plus a live `MessageCount`. If the channel is missing, or the caller isn't the creator, or the topic is too long, the method returns a corresponding failure via `ChannelOperationResult.Fail` with an appropriate [`ChannelError`](../../EchoHub.Core/DTOs/CommonDtos.cs.md).
## Remarks
This method centralizes the domain logic for updating a channel topic behind a service boundary. It enforces the business rule that only the channel creator may modify the topic, and it uses a scoped DbContext to apply the change, ensuring consistency with the data-access layer. The returned ChannelDto exposes a compact snapshot of the channel, including whether the channel is password-protected and whether a wrapped room key exists, which informs UI decisions without leaking internal state.
Only the channel creator can update the topic, enforced by comparing `dbChannel.CreatedByUserId` to `callerUserId`. The method uses a short-lived DI scope to fetch [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md), performs a read of the channel by name, applies the update, saves changes, and then counts the channel's `Messages` to populate the `MessageCount` in the returned [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md). The [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) also exposes security-related flags derived from `PasswordHash` and `WrappedRoomKey` to help clients adjust their UI and access logic.
## Notes
- Topic can be null to clear the current topic (the code stores topic?.Trim()).
- Channel name normalization is applied so lookups are case-insensitive and consistent.
- The operation yields concrete failure codes (NotFound, Forbidden, ValidationFailed) to guide callers in handling user feedback.
- The `MessageCount` is retrieved via `db.Messages.CountAsync(m => m.ChannelId == dbChannel.Id)` after applying the update; for very active channels this can add latency.
- Passing a `null` `topic` clears the topic; callers should handle potential null values in the UI.
---
@@ -473,83 +523,15 @@ private static string? ValidateChannelPassword(ref string? password)
**Returns:** `string?`
Normalizes and validates a channel password. If the input password is null or consists only of whitespace, it is treated as no password (the value is effectively normalized to null) and no error is produced. For non-empty input, the method enforces length constraints defined by ValidationConstants and returns an error message when the password is too short or too long; otherwise, it returns null to indicate a valid password. The password is passed by reference, allowing the caller to observe and adopt the normalized value in place.
Normalizes a provided channel password by treating whitespace-only input as the absence of a password (`null`) and then enforces length constraints from [`ValidationConstants`](../../EchoHub.Core/Constants/ValidationConstants.cs.md) (minimum via `MinChannelPasswordLength`, maximum via `MaxPasswordLength`). It returns an error message when the password is too short or too long, or `null` when the value is valid.
## Remarks
Centralizes the channel password policy so all call sites apply the same minimum and maximum length rules and the same interpretation of an empty password. The implementation defers to ValidationConstants for policy values, ensuring changes to password requirements propagate consistently. The use of a ref parameter enables in-place normalization, so the normalized password (or its absence) is visible to the caller without requiring a separate assignment.
By using a `ref` parameter for `password`, the input variable may be mutated to `null` by the callee to reflect the decision that no password is set. This centralizes channel password rules in one place, ensuring consistent behavior across channel creation and update flows.
## Notes
- The caller must pass a mutable variable by ref; passing a constant or read-only expression will not compile.
- The method returns null when the password is valid (or when treated as no password), or a non-null string containing the user-facing validation message when invalid.
---
## GetChannelKeyEnvelopeAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `EncryptionSalt` | `string?` | — |
| `WrappedRoomKey` | `string?` | — |
Gets the encryption envelope for a given channel by name by querying the Channels table via a scoped EchoHubDbContext; it returns the channel's EncryptionSalt and WrappedRoomKey as a tuple, or (null, null) if the channel cannot be found. This method is intended for scenarios where callers need to access per-channel cryptographic parameters to decrypt or initialize channel data, without surfacing the data-access details to higher layers.
## Remarks
Encapsulates a small, cohesive data-access operation and hides EF Core/DI plumbing from callers. By creating a scoped scope and resolving EchoHubDbContext per call, it avoids leaking a long-lived DbContext into consumer code and makes the envelope retrieval occur in a single boundary. It relies on the Channels table's Name field to identify a channel and returns two optional values, allowing callers to decide how to handle missing encryption data. This placement fits ChannelService as a dedicated place to retrieve channel-related metadata used by encryption/decryption flows.
## Notes
- Caller must handle possible nulls in both EncryptionSalt and WrappedRoomKey; if the channel isn't found, both will be null.
- Since the input channelName is lowercased before querying, ensure channel.Name storage is consistent (lowercase) to guarantee matches; otherwise, the lookup could miss existing channels.
---
## GetChannelTopicAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `Topic` | `string?` | — |
| `Exists` | `bool` | — |
Retrieves the topic for a named channel from the EchoHub database. The method normalizes the input (lowercases and trims), opens a scoped DI context to resolve EchoHubDbContext, and queries the Channels set for a channel with the matching name. If no channel is found, it returns (null, false); otherwise it returns the channel's Topic along with true, indicating the channel exists. The operation is asynchronous, allowing callers to await the database query without blocking.
## Remarks
This method encapsulates a small, focused data-access concern: turning a channel name into its topic, while also signaling whether the channel exists. Returning a value tuple (Topic, Exists) makes it straightforward for call sites to branch logic without null checks against the channel entity. The DI-scoped DbContext use ensures clean disposal per call and aligns with standard EF Core usage in a DI-driven application.
## Example
```csharp
var (topic, exists) = await GetChannelTopicAsync("general");
if (exists)
{
Console.WriteLine(topic);
}
else
{
Console.WriteLine("Channel not found.");
}
```
## Notes
- The lookup lowercases the channel name; ensure stored channel names are normalized the same way to guarantee matches.
- Topic can still be null even when Exists is true; callers should handle null topics gracefully.
- If multiple channels share the same name (data integrity issue), FirstOrDefaultAsync returns the first match.
- Because the parameter is `ref`, the caller should re-read the original variable after the call because its value may have been changed to `null`.
- A return value of `null` indicates a valid or absent password; non-null strings are error messages describing the violation.
---
@@ -10,20 +10,20 @@
- [BroadcastChannelUpdatedAsync](#broadcastchannelupdatedasync)
- [BroadcastMessageAsync](#broadcastmessageasync)
- [BroadcastToAllAsync](#broadcasttoallasync)
- [BuildLogBacklog](#buildlogbacklog)
- [BuildReplyRef](#buildreplyref)
- [FileIdFromUrl](#fileidfromurl)
- [GetChannelHistoryAsync](#getchannelhistoryasync)
- [GetChannelHistoryInternalAsync](#getchannelhistoryinternalasync)
- [GetChannelsForUserAsync](#getchannelsforuserasync)
- [GetOnlineUsersAsync](#getonlineusersasync)
- [JoinChannelAsync](#joinchannelasync)
- [LeaveChannelAsync](#leavechannelasync)
- [SanitizeNewlines](#sanitizenewlines)
- [SendMessageAsync](#sendmessageasync)
- [UpdateStatusAsync](#updatestatusasync)
- [UserConnectedAsync](#userconnectedasync)
- [UserDisconnectedAsync](#userdisconnectedasync)
- [BuildLogBacklog](#buildlogbacklog)
- [JoinChannelAsync](#joinchannelasync)
- [SanitizeNewlines](#sanitizenewlines)
---
@@ -36,15 +36,16 @@ public class ChatService : IChatService
```
Coordinates the server-side chat workflow: presence tracking, channel membership and validation, message handling (decryption, sanitization, spam checks, reply validation, optional link embeds), persistence, and broadcasting to configured IChatBroadcaster implementations. Reach for ChatService when you need the complete, policy-enforced chat behavior used by the hub (connect/disconnect, join/leave, send message, history, status and broadcasts) rather than calling lower-level pieces like the channel store, encryption, or broadcasters individually.
Coordinates chat-related operations for the server-side hub: connection lifecycle, channel joins/leaves, message sending, history retrieval and broadcasting. Reach for `ChatService` when you need a single, authoritative orchestrator that applies presence tracking, channel validation, encryption/decryption, spam/mute rules, link-embed enrichment, storage and multi-backend broadcasting rather than implementing those concerns in a hub or duplicating them across callers.
## Remarks
ChatService is an orchestration façade that centralizes chat policies and cross-cutting concerns so the rest of the system sees a single, consistent chat surface. It delegates channel validation and membership (including password checks) to the channel service, uses PresenceTracker for online lists, defers spam decisions to the SpamGuard, asks LinkEmbedService for embeds, and relies on IMessageEncryptionService for decrypt/encrypt logic. It also records runtime telemetry and logs through ServerStatsCollector and ServerLogsService, and persists or reads backlog data via FileStorageService for special channels (for example, the rolling log room). Finally, it shields broadcasters from internal policies by converting and routing messages appropriately (e.g., encrypted payloads for some clients, plaintext for legacy/IRC broadcasters).
`ChatService` centralizes cross-cutting chat logic so transport implementations (for example SignalR or an IRC bridge) can remain thin. It delegates channel membership and validation to [`IChannelService`](../../EchoHub.Core/Contracts/IChannelService.cs.md), relies on [`PresenceTracker`](PresenceTracker.cs.md) for presence state, uses [`IMessageEncryptionService`](../../EchoHub.Core/Contracts/IMessageEncryptionService.cs.md) and [`LinkEmbedService`](LinkEmbedService.cs.md) to handle encrypted payloads and link previews, enforces anti-abuse via [`SpamGuard`](SpamGuard.cs.md), persists attachments via [`FileStorageService`](FileStorageService.cs.md), and emits audit/operational data to [`ServerLogsService`](ServerLogs/ServerLogsService.cs.md) and [`ServerStatsCollector`](Stats/ServerStatsCollector.cs.md). Outgoing delivery is performed by the configured [`IChatBroadcaster`](../../EchoHub.Core/Contracts/IChatBroadcaster.cs.md) implementations so the same message lifecycle (validation, enrichment, storage) can be broadcast to multiple transports consistently.
## Notes
- Join throttle: only first-time joins (where the user is not already a member) count toward the join throttle to avoid falsely flagging reconnect/auto-join bursts.
- Live log room is treated as read-only; attempts to write to it are rejected early and its backlog is sourced from a rolling log file — only the first history page returns a backlog.
- SpamGuard operates on the stored content (ciphertext when end-to-end encryption is used) and never requires or performs decryption; muting and escalation are handled through the service's moderation workflow.
- Join throttling only counts first-time joins (no existing membership) to avoid tripping during normal auto-join bursts on reconnect; clients that re-join known channels should not trigger the throttle.
- The special "log"/live-log room is treated as read-only and its backlog comes from rolling log files rather than DB messages; paging behaves differently for that channel.
- Spam checks operate on the stored content (which may be ciphertext for end-to-end encrypted rooms) — the guard does not require plaintext to function and escalation results in timed mutes issued by the server.
- Message handling includes decryption (clients may send encrypted payloads while other protocols supply plaintext), stripping of any explicit encryption prefix typed by users to prevent spoofing, and plaintext sanitization (for example collapsing excessive newlines) before optional embed fetching and storage.
---
@@ -84,14 +85,7 @@ public ChatService(
| `logger` | `ILogger<ChatService>` | — |
Constructs a ChatService by injecting its required collaborators and wiring them to private fields. This constructor is invoked by the dependency injection container when a ChatService is created, supplying services for scope management, presence tracking, message broadcasting, content embedding, encryption, channel operations, file storage, spam protection, server logging, and statistics collection. The use of `IEnumerable<IChatBroadcaster>` indicates that multiple broadcasters can participate in delivering messages and events, allowing pluggable delivery strategies without changing ChatService code.
## Remarks
ChatService acts as an orchestration hub for chat functionality. By depending on interfaces rather than concrete implementations, it remains highly testable and extensible: you can substitute mocks or fakes for broadcasters, presence tracking, or encryption in tests or different environments. The broadcaster collection enables evolving notification strategies by simply registering new IChatBroadcaster implementations, aligning with the open/closed principle.
## Notes
- No null-checks are performed in the constructor; rely on the DI container to provide non-null dependencies. If ChatService might be created outside the DI pipeline, consider adding guards.
- When using `IEnumerable<IChatBroadcaster>`, all registered broadcasters will be resolved and invoked; behavior depends on the concrete broadcaster implementations.
Initializes a new `ChatService` instance by capturing its required collaborators through dependency injection and storing them in private fields for later use. The constructor takes services for scope management (`IServiceScopeFactory`), presence tracking ([`PresenceTracker`](PresenceTracker.cs.md)), a collection of broadcasters ([`IChatBroadcaster`](../../EchoHub.Core/Contracts/IChatBroadcaster.cs.md)), link embedding ([`LinkEmbedService`](LinkEmbedService.cs.md)), message encryption ([`IMessageEncryptionService`](../../EchoHub.Core/Contracts/IMessageEncryptionService.cs.md)), channel operations ([`IChannelService`](../../EchoHub.Core/Contracts/IChannelService.cs.md)), file storage ([`FileStorageService`](FileStorageService.cs.md)), spam protection ([`SpamGuard`](SpamGuard.cs.md)), server-side logging ([`ServerLogsService`](ServerLogs/ServerLogsService.cs.md)), statistics collection ([`ServerStatsCollector`](Stats/ServerStatsCollector.cs.md)), and a logger (`ILogger<ChatService>`), wiring them to internal fields like `_scopeFactory`, `_presenceTracker`, `_broadcasters`, `_embedService`, `_encryption`, `_channelService`, `_fileStorage`, `_spamGuard`, `_serverLogs`, `_statsCollector`, and `_logger` so the service can perform broadcasting, embedding, encryption, channel management, persistence, spam guarding, logging, and metrics collection.
---
@@ -113,14 +107,10 @@ public Task BroadcastChannelDeletedAsync(string channelName)
**Returns:** `Task`
BroadcastChannelDeletedAsync publishes a channel-deletion event to all connected clients by delegating to the shared broadcasting pipeline. It forwards the channelName to each subscriber through SendChannelDeletedAsync, coordinated by BroadcastToAllAsync to ensure every participant receives the notification.
BroadcastChannelDeletedAsync asynchronously broadcasts a channel-deleted event to all connected clients by invoking `SendChannelDeletedAsync` on each subscriber, via the central `BroadcastToAllAsync` mechanism using the lambda `b => b.SendChannelDeletedAsync(channelName)`. It accepts a `string channelName` and returns a `Task` representing the asynchronous broadcast operation.
## Remarks
Provides a domain-friendly API that hides the broadcasting details behind a simple, expressive method name. By delegating to BroadcastToAllAsync, it centralizes how channel-deletion notifications are distributed, reducing duplication and ensuring consistent behavior across all subscribers.
## Notes
- No input validation is performed on channelName; callers should ensure the value is non-null and meaningful before invocation.
- Exceptions raised during per-subscriber delivery will propagate via the returned Task; callers should decide whether to await and handle failures.
This method is a thin facade over the generic broadcasting path. It delegates to `BroadcastToAllAsync` to deliver the `SendChannelDeletedAsync` call to every connected client, isolating the channel-deletion notification from the underlying broadcast implementation and ensuring consistent semantics across different events.
---
@@ -143,14 +133,10 @@ public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName
**Returns:** `Task`
BroadcastChannelUpdatedAsync is a thin wrapper that notifies all connected clients that the specified channel has been updated. It accepts the ChannelDto describing the channel and an optional new channelName. The method delegates to the shared broadcast mechanism (BroadcastToAllAsync) by applying a function that calls SendChannelUpdatedAsync on each client with the provided payload. Use this when you want real-time client UIs to reflect changes to a channel, such as a rename or updated metadata, without having to push updates individually to each client.
BroadcastChannelUpdatedAsync forwards the given [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) and an optional `string? channelName` to all connected clients by routing through the shared broadcast pipeline: it calls `BroadcastToAllAsync` with a lambda that invokes each client's `SendChannelUpdatedAsync`.
## Remarks
BroadcastChannelUpdatedAsync centralizes the channel-update notification path in ChatService. By encapsulating the broadcast call behind this single method, callers don't need to know about how clients are iterated or how the payload is delivered; tests can mock this entry point, and future changes to the broadcasting strategy stay confined here.
## Notes
- This method only notifies clients; it does not modify the channel data in storage.
- If channelName is non-null, it will be included as part of the payload and may be used by clients to display the new name.
Thin wrapper around the existing broadcast mechanism for the 'channel updated' event. It centralizes the notification path so UI clients stay in sync when a channel changes, and it decouples `ChatService` from the concrete hub method used to push updates. If you add additional update events later, similar wrappers can be introduced to keep the surface area small and consistent.
---
@@ -173,14 +159,11 @@ public Task BroadcastMessageAsync(string channelName, MessageDto message)
**Returns:** `Task`
BroadcastMessageAsync asynchronously broadcasts the provided MessageDto to all clients subscribed to the specified channel by delegating to BroadcastToAllAsync. This method serves as a focused helper for channel-scoped messages, insulating callers from the details of iterating over recipients and invoking SendMessageToChannelAsync on each.
BroadcastMessageAsync is a thin asynchronous wrapper that broadcasts a [`MessageDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) to a named channel by delegating to the shared broadcast pipeline `BroadcastToAllAsync`. It forwards the `channelName` and `message` to every recipient by invoking the lambda `b => b.SendMessageToChannelAsync(channelName, message)`.
## Remarks
This wrapper consolidates the channel-based dispatch pattern into a single, discoverable API on the chat service. It centralizes the broadcasting contract so callers do not need to know how broadcasting is implemented (per-subscriber dispatch vs. transport specifics), and it supports testing and mocking of channel messages by providing a stable entry point.
This method acts as a channel-scoped entry point for the generic broadcast mechanism, decoupling channel-specific semantics from the underlying broadcasting orchestration. By composing with the `BroadcastToAllAsync` pipeline, it ensures consistent delivery behavior across recipients while allowing the underlying strategy to evolve without changing the public API. The wrapper also simplifies testing by isolating the channel-binding logic from the broadcast traversal.
## Notes
- The visible code does not show input validation; consider validating channelName and message upstream to avoid potential ArgumentNullException during broadcasting.
- The method returns a Task; await it to observe completion and to surface any exceptions from the underlying broadcast pipeline (e.g., failures in SendMessageToChannelAsync).
---
@@ -201,15 +184,35 @@ private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
**Returns:** `Task`
BroadcastToAllAsync is a private helper that sequentially applies an asynchronous action to every broadcaster in the _broadcasters collection. By awaiting the provided `Func<IChatBroadcaster, Task>` for each broadcaster, it ensures ordered, per-broadcaster execution. If an individual broadcaster throws, the exception is caught and logged with the broadcasters runtime type name, allowing the remaining broadcasters to continue without interrupting the overall broadcast flow.
BroadcastToAllAsync iterates over the collection of `_broadcasters` and applies the provided `Func<IChatBroadcaster, Task>` to each broadcaster, awaiting the resulting task before moving to the next. If an invocation throws, the exception is caught and logged via `_logger.LogError`, including the broadcaster's type name from `broadcaster.GetType().Name`, and processing continues with the remaining broadcasters. Use this helper when you need to perform a common asynchronous operation across all configured broadcasters while tolerating individual failures.
---
### BuildLogBacklog
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
private List<MessageDto> BuildLogBacklog(string channelName)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `channelName` | `string` | — |
**Returns:** `List<MessageDto>`
Turns the log backlog into transport-encrypted [`MessageDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md)s so past log lines render identically to streamed messages on the client. It reads backlog entries from `_serverLogs.ReadBacklog()`, encrypts each entrys content with `_encryption.Encrypt(entry.Content)`, assigns a fresh `Guid` via `Guid.NewGuid()`, uses `ServerLogsService.SenderName` as the sender, attaches the provided `channelName`, and preserves each backlog entrys `Timestamp`. This method never touches the database.
## Remarks
This helper centralizes the common pattern of broadcasting to multiple chat broadcasters while isolating failures. It provides a small orchestration layer that coordinates across the _broadcasters collection and ensures one faulty broadcaster does not derail the entire operation. Because it is private, this logic remains an implementation detail of the class rather than part of its public API.
Acts as an adapter that repackages backlog entries into the identical [`MessageDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) format used for real-time messages, enabling a seamless, consistent rendering experience for historical logs. It centralizes encryption and ID generation at the point of backlog materialization, reducing divergence between what clients see in history and what they see in live streams.
## Notes
- The method is sequential; broadcasting happens one broadcaster after another, not in parallel. If you need parallel broadcasting, use a different approach.
- Exceptions from action are swallowed per-broadcaster; if you need different error handling, handle it inside the action or upstream.
- Logging uses broadcaster.GetType().Name to identify failures; if multiple broadcasters share a type, the log may not distinguish instances.
- The `Id` of each [`MessageDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) is generated per invocation with `Guid.NewGuid()`, so IDs are not stable across reloads.
- This method is private; it is an internal helper that shapes backlog data specifically for the client-render path and is not directly callable from outside.
---
@@ -230,15 +233,14 @@ private ReplyRefDto BuildReplyRef(Message target)
**Returns:** [`ReplyRefDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md)
BuildReplyRef constructs the wire reference for a reply target by decrypting the targets content to obtain a plaintext snippet, conditionally truncating it, and then re-encrypting the result into a ReplyRefDto that carries the targets ID, sender, and the encrypted snippet. Specifically, it decrypts target.Content; if the decrypted text is not recognized as E2E room ciphertext and exceeds 120 characters, it truncates to 120 characters and appends an ellipsis; finally, it encrypts the possibly shortened plaintext and returns a ReplyRefDto.
Builds a wire reference for a reply target by returning a [`ReplyRefDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) that contains the targets ID, the senders username, and an encrypted surface plaintext. It decrypts the targets `Content` to obtain plaintext; if the decrypted text is not a room ciphertext (i.e. `!RoomCrypto.IsRoomCiphertext(plain)`) and longer than 120 characters, it truncates to 120 characters and appends an ellipsis. The (potentially truncated) plaintext is then encrypted again with `_encryption.Encrypt` before being stored in the [`ReplyRefDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) alongside the targets `Id` and `SenderUsername`.
## Remarks
Encapsulates the logic for producing a secure, compact teaser of the original message for a reply. It ensures that non-E2E content is truncated to a sane length while guaranteeing that E2E content remains intact (and thus decryptable) by avoiding truncation. It delegates the actual encryption to the central _encryption service and uses RoomCrypto.IsRoomCiphertext to decide when truncation is safe.
This method centralizes the policy for constructing reply references: End-to-End room ciphertext is preserved as ciphertext and not truncated in this pass, while non-End-to-End plaintext is surfaced only as a concise preview. It coordinates with `_encryption` and [`RoomCrypto`](../../EchoHub.Core/Security/RoomCrypto.cs.md) to decide truncation and to produce a transport-ready reference that the client can render without exposing raw plaintext.
## Notes
- Truncation only occurs when the decrypted content does not look like room ciphertext; this prevents corrupting E2E data.
- The returned snippet is encrypted before being included in ReplyRefDto.
- The 120-character limit is a fixed server-side threshold governing the preview length.
- Truncation uses an ellipsis character `…` and a hard limit of 120 characters for non-E2E plaintext.
---
@@ -259,14 +261,14 @@ private static string FileIdFromUrl(string url) => url.Split('/')[^1]
**Returns:** `string`
Extracts the storage file id from an attachment URL by taking the last path segment after the final '/'. It is intended for URLs that look like '/api/files/{id}' and is used in scenarios where the code needs to derive the identifier from a URL without performing a full URL parser.
Extracts the storage file id from an attachment URL by taking the last path segment (for example '/api/files/{id}'). This private helper is used when only the id is needed from a known URL rather than maintaining the id separately. It relies on splitting the URL on '/' and selecting the final segment with `[^1]`, without performing further validation.
## Remarks
Because this helper relies on a simple string.Split and the C# index-from-end operator [^1], it assumes the input is a plain path that ends with the id and does not end with a trailing slash. If the URL ends with '/', the result will be an empty string. It also does not guard against null inputs, which would raise an exception at runtime. In practice this method is a tiny, in-class utility that centralizes the id extraction so callers don't duplicate the split logic.
By centralizing the assumption that the file id is the last path segment, this helper reduces duplication and keeps callers focused on higher-level logic. It relies on a simple split-and-select approach and does not validate edge cases such as trailing slashes or query parameters.
## Notes
- Trailing slash in the URL yields an empty id; normalize the URL or trim the trailing slash before calling.
- Null or empty input is not handled; ensure a non-null, non-empty URL is passed.
- Trailing slash or query string edge cases may yield an empty result or a value containing extraneous parts.
- No input validation: passing `null` or clearly malformed URLs will throw at runtime.
---
@@ -289,13 +291,14 @@ public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, i
**Returns:** `Task<List<MessageDto>>`
GetChannelHistoryAsync retrieves a paged history of messages for a given channel. It normalizes the channel name to lowercase and trims it, clamps the requested count to the range [1, ValidationConstants.MaxHistoryCount], and ensures offset is non-negative. If the channel is a logs channel (no DB messages), the backlog is read from the rolling log file: the first page is populated via BuildLogBacklog, while older pages are empty (files are archives). For regular channels, a short-lived DI scope is created to resolve EchoHubDbContext, and the method delegates to GetChannelHistoryInternalAsync to fetch the requested slice from the database. The call is asynchronous and returns a `List<MessageDto>`.
Gets a paginated history of messages for a specified channel. The method normalizes `channelName` to lowercase and trims it, clamps `count` to `ValidationConstants.MaxHistoryCount`, and ensures `offset` is non-negative. If the channel is a log-backed channel (no database messages), it returns the backlog on the first page via `BuildLogBacklog` and an empty list for subsequent pages. Otherwise, it creates a DI scope to obtain an [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) and delegates to `GetChannelHistoryInternalAsync` to fetch messages from the database.
## Remarks
Serves as a unified history retrieval entry point that abstracts away the storage details behind a paging API. It centralizes channel-history concerns so callers don't need to know whether messages come from the rolling log or the database, while preserving the expected paging semantics across both sources.
Log-backed channels are served from the rolling log backlog, while database-backed channels fetch history from the [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md). The method unifies access to channel history by hiding the data source, but preserves the backlog-first paging contract for log channels (as documented in the inline comment).
## Notes
- Be aware that for log channels, only the first page contains backlog data; requesting subsequent pages returns an empty list.
- For log-backed channels, requesting an `offset` > 0 returns an empty list; only the first page can include backlog items.
- Channel name normalization is performed before retrieval; callers may pass mixed-case or whitespace around the channel name.
---
@@ -319,16 +322,8 @@ private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbCon
**Returns:** `Task<List<MessageDto>>`
Retrieves a page of messages for a named channel, enriching each entry with sender metadata, attachment data, and embed information, so the client can render a historical view of the chat. Call this when you need to assemble a channel's message history in a transport-ready form, with tombstoned accounts preserved and content decrypted for display.
Fetches a batch of messages for a named channel and returns them as `List<MessageDto>` after assembling sender metadata, decryption, and attachment/embed preparation. It first resolves the channel by name via `EchoHubDbContext.Channels`; if the channel cannot be found, it returns an empty list. To preserve tombstoned messages (where a sender account has been deleted), it performs a left join with `Users` so messages can still appear with null `NicknameColor` and `DisplayName`, then applies pagination with `offset` and `count` and finally reverses the results to chronological order. The method also gathers reply targets for quotes, groups attachments by message, validates attachments against the current file store via `_fileStorage.GetStoredFileIds()`, decrypts message content using `_encryption.Decrypt`, and decrypts/deserializes embeds from `EmbedJson` using `JsonSerializer`. Attachments are pruned if their underlying files are missing; if a message has no live attachments and no plaintext content, it is pruned from the result. Attachments and previews are re-encrypted for transport, and embedded metadata (when valid JSON) is deserialized into [`EmbedDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) instances. This function coordinates with its dependencies to ensure only live content is delivered and that the client receives an encryption-safe, transport-ready history payload.
## Remarks
Conceptually, this method centralizes history construction: it loads messages for a channel, preserves messages from deleted accounts by left-joining with users, collects reply targets for quote rendering, and hydrates attachments while pruning entries that no longer have valid files. It decrypts message content and any embed metadata, reconstructs attachments for transport (including per-attachment previews that are re-encrypted), and translates raw data into the client-facing DTOs (MessageDto, AttachmentDto, EmbedDto). This batching approach minimizes round-trips by prefetching related data (replies, attachments, embeds) in a single operation.
## Notes
- If a channel is not found, the method returns an empty list rather than throwing. Callers should handle an empty history gracefully.
- Messages associated with deleted users are preserved in history, but their display name and nickname color may be null; UI code should account for missing metadata.
- Attachments are shown only if their underlying files still exist on disk; messages with only vanished attachments and no plaintext content are pruned from the result.
- The method decrypts content and embed JSON, and it re-encrypts payloads for transport; the exact transport-encryption details are handled deeper in the pipeline and may depend on the caller's context.
---
@@ -350,17 +345,10 @@ public Task<List<string>> GetChannelsForUserAsync(string username)
**Returns:** `Task<List<string>>`
Retrieves the list of channel names that a specific user participates in, exposed as an asynchronous method. It delegates to the presence tracker via _presenceTracker.GetChannelsForUser(username) and wraps the result in Task.FromResult, which means the call completes synchronously and simply presents an async surface to callers. Use this when you require an async API surface (e.g., to be consistent with other async members) even though the underlying operation is synchronous.
This method is a thin wrapper around `_presenceTracker.GetChannelsForUser` that returns the channels for a given `username` as a `Task<List<string>>`. It preserves the asynchronous API surface while delegating the actual lookup to the presence tracker.
## Remarks
It provides an asynchronous API surface for retrieving a user's channel list by delegating to the presence tracker. This keeps ChatService methods consistent in an async context and avoids exposing a synchronous API directly to callers that expect Task-returning methods. The actual retrieval is synchronous, so this wrapper does not introduce true asynchrony.
## Notes
- Completes synchronously; no actual I/O is awaited here.
- Any exception from _presenceTracker.GetChannelsForUser will be thrown at call time (not surfaced as a faulted Task).
- If you anticipate long blocking work, prefer an actual asynchronous implementation or an asynchronous presence tracker.
By delegating to `_presenceTracker`, this symbol keeps the `ChatService` decoupled from the concrete presence-tracking implementation. This makes it easier to test `GetChannelsForUserAsync` in isolation and to swap the presence logic without changing callers, while still offering a stable public surface via `GetChannelsForUserAsync`.
---
@@ -381,22 +369,35 @@ public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName)
**Returns:** `Task<List<UserPresenceDto>>`
Retrieves the list of online users for a given chat channel by joining the in-memory presence tracker with the database-stored user records. It normalizes the channel name to lowercase and trims whitespace, fetches the set of online usernames from the tracker, queries EchoHubDbContext.Users for those usernames that are not Invisible, and then maps each user to a UserPresenceDto that includes the in-memory IRC-only flag. This method is typically used when you need to present the current participants of a channel, excluding hidden users, with their display metadata.
GetOnlineUsersAsync returns the current online users for a given channel as a list of [`UserPresenceDto`](../../EchoHub.Core/DTOs/ProfileDtos.cs.md) records. The channel name is normalized with `ToLowerInvariant()` and trimmed, then the in-memory `_presenceTracker` is consulted to obtain the set of online usernames for that channel. A scoped [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) is then used to query `db.Users` for those usernames that are not `UserStatus.Invisible`, materializing the result with `ToListAsync()`. Finally, the code maps each [`User`](../../EchoHub.Core/Models/User.cs.md) to a [`UserPresenceDto`](../../EchoHub.Core/DTOs/ProfileDtos.cs.md), including the `IsIrcOnly` flag via `_presenceTracker.IsIrcOnly(u.Username)`, and returns the list.
## Remarks
Acts as a bridge between transient presence state and the persistent user store, ensuring that the live list respects visibility rules while enriching with profile data. The conversion to UserPresenceDto happens after the EF query to allow the IRC-only flag to be derived from the presence tracker, not stored in the database. The use of a scoped DbContext keeps the data access isolated and safe for concurrent calls.
## Example
```csharp
// Example usage
var online = await chatService.GetOnlineUsersAsync("general");
Console.WriteLine($"Online in #general: {online.Count}");
```
The method bridges in-memory presence information with the persisted user data, encapsulating the lookup so callers need only know a channel name to obtain current participants. It also enforces visibility rules by excluding users with `UserStatus.Invisible` and by deriving the `IsIrcOnly` state from the live tracker rather than from the database. The scoped [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) usage respects dependency injection lifetimes and limits the DbContext to the operation's boundary.
## Notes
- If a username is online according to the tracker but missing from the database, it will be ignored.
- Channel name normalization means calls with different casing or surrounding whitespace map to the same channel.
- The IsIrcOnly flag is determined by the presence tracker and is included in each UserPresenceDto.
- Be aware that the query loads a list of [`User`](../../EchoHub.Core/Models/User.cs.md) records for all online usernames; channels with large online counts could have performance implications, and paging or batching may be warranted in high-traffic scenarios.
---
### JoinChannelAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
public async Task<(List<MessageDto> History, string? Error, bool PasswordRequired)> JoinChannelAsync(
string connectionId, Guid userId, string username, string channelName, string? password = null)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `History` | `List<MessageDto>` | — |
| `Error` | `string?` | — |
| `PasswordRequired` | `bool` | — |
Joins a user to a chat channel by orchestrating normalization, anti-spam checks, membership validation, presence setup, and history retrieval in a single, centralized workflow. When a client requests to join a channel, `JoinChannelAsync` lowercases and trims the channel name, enforces a first-time-join throttle via `_spamGuard`, delegates membership and potential password gating to `_channelService.EnsureChannelMembershipAsync`, registers the user in `_presenceTracker`, broadcasts the join to other clients (excluding invisible users via `UserStatus.Invisible`), and finally returns the channel history via `GetChannelHistoryAsync` along with any error and a `PasswordRequired` flag for future joins.
---
@@ -419,26 +420,42 @@ public async Task LeaveChannelAsync(string connectionId, string username, string
**Returns:** `Task`
LeaveChannelAsync handles the workflow for when a user leaves a chat channel. It normalizes the channel name to lowercase, updates the presence tracker to reflect that the user has left, broadcasts a user-left notification to all connected clients in that channel, and logs the action at the debug level.
Developers call this when a user intentionally exits a channel; the method encapsulates the coordinated state change, notification, and observability so callers don't have to orchestrate these steps separately.
Normalizes the channel name to a canonical form using `ToLowerInvariant()` and `Trim()`, then updates presence via `_presenceTracker.LeaveChannel(username, channelName)`, broadcasts a user-left notification to all connected clients through `BroadcastToAllAsync`, and logs a debug entry with the user and channel via `_logger.LogDebug("{User} left channel '{Channel}'", username, channelName)`.
## Remarks
It centralizes the leave workflow into a single, reusable operation that updates presence, notifies clients, and records the event for debugging. Normalizing the channel name here prevents case-sensitivity inconsistencies when tracking presence or delivering notifications. Notification is performed asynchronously by the broadcasting layer, which preserves responsiveness and allows the caller to await completion.
## Example
```csharp
// Most common usage: user "alice" leaves the "General" channel
await chatService.LeaveChannelAsync("conn-123", "alice", "General");
```
By encapsulating normalization, presence update, and broadcast in a single method, this symbol provides a consistent, reusable leave operation for the chat service. It ensures that all participants are informed of departures and that the server's presence state stays in sync across callers. The normalization step guarantees that channel identity is consistent, preventing duplicate or missed leaves due to casing.
## Notes
- Channel identity is normalized to lowercase; avoid relying on mixed-case channel names.
- This method is asynchronous; callers should `await` it to ensure the left-notification is delivered before proceeding.
- ToLowerInvariant is called on channelName without a null-check; passing null will throw. Ensure channelName is non-null before calling, or upstream validation.
- The connectionId parameter is unused in this implementation; it may be present for correlation or future use.
- Exceptions from _presenceTracker.LeaveChannel or BroadcastToAllAsync propagate to the caller; no internal retry is performed.
---
### SanitizeNewlines
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
private static string SanitizeNewlines(string content)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `content` | `string` | — |
**Returns:** `string`
SanitizeNewlines is a private helper that cleans up a string by normalizing newline endings and trimming excessive blank lines to prevent newline spam in messages. It converts all CRLF/CR endings to LF, collapses runs of whitespace-only lines to at most `HubConstants.MaxConsecutiveNewlines` in a row, and caps the total line count to `HubConstants.MaxMessageNewlines` before returning the result.
## Remarks
SanitizeNewlines encapsulates formatting hygiene, centralizing newline handling behind a single, configurable policy. By relying on [`HubConstants`](../../EchoHub.Core/Constants/HubConstants.cs.md), the behavior can be tuned without changing call sites, and its private scope keeps the classs public surface area focused on higher-level responsibilities for chat content processing.
## Notes
- If `HubConstants.MaxMessageNewlines` is configured to 0 or negative, the method may return an empty string, effectively dropping content.
- The method treats any line consisting only of whitespace as a blank line, so lines that look empty but contain spaces or tabs contribute to the consecutive-blank budget and may be collapsed accordingly.
---
@@ -464,15 +481,7 @@ public async Task<string?> SendMessageAsync(Guid userId, string username, string
**Returns:** `Task<string?>`
SendMessageAsync coordinates the end-to-end process of posting a chat message to a named channel. It normalizes the channel name, validates it against allowed patterns, blocks writes to read-only channels (including the logs room and system channels), decrypts incoming content, strips a literal encryption prefix if present to prevent spoofing, and enforces non-empty content and a maximum length. It then looks up the target channel and the sender from the database, enforces mute state (including automatic unmute when a mute has expired), runs a spam guard that can auto-mute or reject messages, validates an optional reply target, and resolves link embeds before persisting the message. The method returns a user-facing string on error or when action is blocked, and returns null on a successful send; side effects include database updates, saving changes, and a moderation log entry when auto-muting occurs.
## Remarks
The method centralizes chat message submission, ensuring consistent enforcement of security, moderation, and content rules across all channels. It encapsulates cross-cutting concerns (validation, decryption, sanitization, moderation, and embed resolution) behind a single entry point, reducing duplication and potential inconsistencies in callers. By using a scoped DbContext and explicit read-only checks, it mitigates the risk of unintended writes and keeps transactional boundaries clear. The combination of encryption-aware processing, a programmable spam guard, and read-only channel protection reveals a deliberate design to balance user privacy, abuse prevention, and system integrity.
## Notes
- The method mutates and persists mute state (sender.IsMuted/MutedUntil) in response to spam protection or mute expiry.
- Returning strings for error/status means callers must handle UI messaging; on success it returns null.
- Be aware of early returns for read-only channels and non-existent channels; ensure the consumer handles user feedback.
Sends a message from a user to a channel by validating the channel name, enforcing read-only constraints, decrypting and sanitizing the content, and applying user-state checks and anti-spam rules before proceeding with the submission pipeline. If any validation fails, the method returns a descriptive error string (for example, "Invalid channel name." or "Channel '{channelName}' does not exist."). The operation normalizes the channel name with the regex from `ValidationConstants.ChannelNameRegex()` and enforces the maximum length via `HubConstants.MaxMessageLength`. It rejects writes to log/system channels (`_serverLogs.IsLogsChannel(...)`) and decrypts the incoming content with `_encryption.Decrypt`, removing any literal `'$ENC$'` prefix before validation. After sanitizing newlines, it opens a DI scope to obtain an [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md), resolves the target [`Channel`](../../EchoHub.Core/Models/Channel.cs.md) (ensuring it exists and is not system-only), and loads the [`User`](../../EchoHub.Core/Models/User.cs.md) to check mute status (including auto-unmuting if the mute has expired). A spam guard (`_spamGuard.CheckMessage`) may auto-mute or reject the message depending on the verdict (`SpamVerdictKind.AutoMute` or `SpamVerdictKind.Rejected`). If a `replyToMessageId` is provided, the method validates the target message exists within the same channel. It also attempts to fetch URL embeds in a guarded block via `_embedServic` to enrich the message without destabilizing the submission flow. All persistence and side-effects occur within the scoped context, and the method returns a user-facing string on fail or proceeds with the normal submission path on success. The orchestration relies on several collaborators, including [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md), [`EmbedDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md), [`MessageDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md), [`ValidationConstants`](../../EchoHub.Core/Constants/ValidationConstants.cs.md), and [`HubConstants`](../../EchoHub.Core/Constants/HubConstants.cs.md), to enforce channel hygiene, user state, and content enrichment.
---
@@ -496,14 +505,15 @@ public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserS
**Returns:** `Task<string?>`
Updates a user's presence status for the specified userId and username, performing validation, persisting changes to the database, and broadcasting the new presence to connected clients. When the input is invalid or the user cannot be found, it returns a user-facing error string; on success it returns null.
Updates a user\`s `Status` and optional `StatusMessage`, persists the change to the database, and broadcasts the new presence to all subscribed channels. It validates that the `status` is a defined enum value (guarding against undefined bindings via `Enum.IsDefined`), and enforces the maximum length for `statusMessage` using `ValidationConstants.MaxStatusMessageLength`; on failure it returns a string error, otherwise it returns `null` after a successful update.
It uses a scoped DI container to resolve [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md), loads the user by `userId`, updates `Status`, `StatusMessage` (trimmed), and `LastSeenAt` to `DateTimeOffset.UtcNow`, saves changes, builds a [`UserPresenceDto`](../../EchoHub.Core/DTOs/ProfileDtos.cs.md) for broadcasting, determines the channels with `_presenceTracker.GetChannelsForUser(username)`, and notifies clients via `BroadcastToAllAsync` calling `SendUserStatusChangedAsync` with the presence payload.
## Remarks
The method creates a short-lived DI scope to obtain EchoHubDbContext, updates the user entity (Status, StatusMessage trimmed, and LastSeenAt set to UTC now), and saves changes. It then builds a UserPresenceDto and uses the presence tracker to determine the target channels, broadcasting the updated presence to all relevant clients via BroadcastToAllAsync. The return value encodes success (null) or failure (a user-facing string) without throwing exceptions.
By performing the work inside a scoped container, the method keeps the Entity Framework context life cycle local to the operation, avoiding leaks across requests. It constructs a [`UserPresenceDto`](../../EchoHub.Core/DTOs/ProfileDtos.cs.md) containing the user's identity and presence details, which is then broadcast to all relevant channels via `BroadcastToAllAsync` and `SendUserStatusChangedAsync`. It also records whether the user is IRC-only using `_presenceTracker.IsIrcOnly(user.Username)` as part of the presence payload, ensuring clients receive a faithful representation of user state.
## Notes
- If a value outside the defined UserStatus enum is supplied, the method immediately returns "Invalid status. Use online, away, dnd, or invisible." due to the enum validation check.
- The status message is length-validated against ValidationConstants.MaxStatusMessageLength and is trimmed before storage; overly long messages produce a descriptive error.
- The `statusMessage` is trimmed before persistence; a null value yields a null field in storage.
---
@@ -527,17 +537,7 @@ public async Task UserConnectedAsync(string connectionId, Guid userId, string us
**Returns:** `Task`
Upon a client connection, this method coordinates in-memory presence tracking, connection-count telemetry, and optional persistence of the user's online state. It updates the in-memory presence tracker and stats collector with the new connection, then resolves a scoped EchoHubDbContext to locate the user by userId. If the user exists, it updates LastSeenAt to the current UTC time and sets Status to Online, persisting the change via SaveChangesAsync. A debug-level log records the connection event for troubleshooting. The inline comment notes that churn aggregation is performed in the periodic stats report rather than in this hot path.
## Remarks
This method glues together presence, persistence, and telemetry for a user connection. It relies on a scoped DbContext to keep database changes isolated per connection, avoiding long-lived contexts and potential contention. By updating both the in-memory trackers and the persisted user state when a user connects, it helps ensure a consistent view of online users across in-memory data and storage, while gracefully handling the case where a user record may be absent.
## Notes
- If the user record cannot be found in EchoHubDbContext.Users, no database write occurs; the method still updates presence and stats.
- The LastSeenAt timestamp uses DateTimeOffset.UtcNow to avoid timezone inconsistencies across servers.
UserConnectedAsync handles a user establishing a real-time connection by recording the connection with the in-memory presence tracker (`_presenceTracker`), updating the live online user count via the stats collector (`_statsCollector`), and, within a short-lived scope, persisting the user's state in the database ([`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md)). If a [`User`](../../EchoHub.Core/Models/User.cs.md) exists for the provided `userId`, it updates `LastSeenAt` to `DateTimeOffset.UtcNow` and sets `Status` to `UserStatus.Online`, then saves changes with `SaveChangesAsync`. Finally, it emits a debug log with `username` and `connectionId` via `_logger.LogDebug`.
---
@@ -558,117 +558,6 @@ public async Task<string?> UserDisconnectedAsync(string connectionId)
**Returns:** `Task<string?>`
Handles the disconnection lifecycle for a user in the chat service. Given a connectionId, it resolves the associated username, collects the channels the user was in, updates presence statistics, and if the user is no longer online, persists LastSeenAt and marks the user as Invisible in the database. It also constructs a UserPresenceDto and broadcasts a status-change to the user's previously tracked channels. The method returns the username that disconnected (or null if no user could be resolved from the connectionId).
## Remarks
By isolating persistence and presence updates behind a scoped database context, this method coordinates ephemeral connection state with durable user data. It serves as the boundary between connection lifecycle management and user presence broadcasting, ensuring that changes are persisted and that clients are notified consistently. The pattern of resolving the user from the connection, updating LastSeenAt and Visibility, and broadcasting a UserPresenceDto helps keep the client UIs in sync with accurate user status.
## Notes
- If the connectionId cannot be mapped to a username, the method still records the disconnection count via the stats collector, but skips the database update and user-broadcast.
- LastSeenAt is updated to DateTimeOffset.UtcNow and Status is set to Invisible only when a valid username is found and the user is no longer online.
- A scoped EchoHubDbContext is used to persist changes; the DbContext instance is disposed as part of the scope lifecycle. The broadcast is sent to the channels the user was connected to before disconnect; if there were no such channels, there is no targeted broadcast.
---
## BuildLogBacklog
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
private List<MessageDto> BuildLogBacklog(string channelName)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `channelName` | `string` | — |
**Returns:** `List<MessageDto>`
Turns the log-file backlog into transport-encrypted MessageDto objects so clients render past log lines exactly like streamed ones. It reads the backlog via the server logs store, encrypts each backlog entrys content with the encryption service, and wraps it in a new MessageDto using a freshly generated GUID, the SenderName from ServerLogsService, the provided channelName, and the backlog entrys timestamp. This method never touches the database and serves solely as a transform to replay historical log lines in the same MessageDto format as live messages.
## Remarks
Acts as a translator between persisted log backlog and the live message stream. By centralizing encryption and formatting, it ensures backlog replay matches live streams and isolates storage concerns from presentation. The use of a new GUID per backlog item also avoids depending on database identifiers for UI rendering.
## Notes
- The MessageDto payload sent for backlog entries is encrypted; clients must decrypt to display the original content.
- IDs for backlog items are generated per call (Guid.NewGuid) and are not tied to persisted database IDs.
---
## JoinChannelAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
public async Task<(List<MessageDto> History, string? Error, bool PasswordRequired)> JoinChannelAsync(
string connectionId, Guid userId, string username, string channelName, string? [REDACTED:CONNECTION_STRING_PASSWORD]
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `History` | `List<MessageDto>` | — |
| `Error` | `string?` | — |
| `PasswordRequired` | `bool` | — |
Joins a user to a chat channel, performing gating, membership validation, presence tracking, and history retrieval. It first enforces a join throttle via a spam guard, then delegates the channel membership check (including any password gate) to ChannelService. If the gate fails, it returns an empty history with the reason. On a successful gate, it records the join in the presence tracker, fetches a lightweight presence snapshot for broadcasting, broadcasts the join to all connected clients (except when the user is invisible), and finally returns the channel history along with an indication that no error occurred and that no password is required.
## Remarks
This method encapsulates the end-to-end join workflow in a single, reusable operation, ensuring consistent enforcement of anti-spam, permission, and presence semantics across the chat surface. By obtaining presence information in a scoped, guarded manner, it keeps side effects localized to the join flow while enabling clients to incrementally update their views. The implementation gracefully handles failures when fetching presence data (logging at debug level) without interrupting the primary join path, and it respects user visibility by avoiding broadcasts for invisible users.
## Example
```csharp
// Example usage: a user joining a publicly accessible channel without a password
var (history, error, passwordRequired) = await chatService.JoinChannelAsync(
connectionId: "conn-123",
userId: userId,
username: "Alice",
channelName: "general",
password: null);
```
## Notes
- If the spam guard rejects the join, the method returns immediately with an empty history and a non-null error reason; no membership or presence side effects occur.
- Invisible users will not trigger a broadcast of the join to other clients, though their history is still returned to them.
- Presence data is a best-effort fetch; failures are logged at debug level and do not prevent the join from completing or the history from being returned.
---
## SanitizeNewlines
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
private static string SanitizeNewlines(string content)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `content` | `string` | — |
**Returns:** `string`
SanitizeNewlines normalizes line endings to a single newline form, collapses consecutive blank lines to at most HubConstants.MaxConsecutiveNewlines, and truncates the total line count to HubConstants.MaxMessageNewlines. This private helper should be invoked when preparing user-provided content for transmission so that messages stay readable and within size limits, rather than letting users push uncontrolled newline spam through the chat pipeline.
## Remarks
SanitizeNewlines centralizes newline handling to ensure consistent formatting across the chat pipeline. It is driven by HubConstants thresholds, avoiding hard-coded limits and enabling consistent behavior wherever message sanitization occurs. As a pure transformation of the input with no external state, it has no side effects beyond returning a sanitized string.
## Notes
- Collapses consecutive whitespace-only lines, which can alter intended spacing in user messages.
- Truncates lines beyond MaxMessageNewlines, so content beyond the limit is dropped from the end.
- The function is private and intended for internal use within the ChatService; external callers cannot rely on it.
Handles a user disconnection by resolving the provided `connectionId` to a `username` via `_presenceTracker`, capturing the channels the user was in, and then marking the user as disconnected. If a `username` exists and the user is no longer online, it creates a scoped [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md), updates the corresponding [`User`](../../EchoHub.Core/Models/User.cs.md)'s `LastSeenAt` to `DateTimeOffset.UtcNow` and `Status` to `UserStatus.Invisible`, saves changes, constructs a [`UserPresenceDto`](../../EchoHub.Core/DTOs/ProfileDtos.cs.md) with the updated presence, and broadcasts the status change to the previously observed channels via `BroadcastToAllAsync` with `SendUserStatusChangedAsync`. Finally, it logs the disconnect with `_logger` and returns the `username` (which may be null).
---
@@ -18,40 +18,17 @@ public sealed class DirectoryClaimStore
```
Persists the directory claim token (an opaque secret issued on first registration) together with the server's stable ServerId, and exposes a short-lived RegistrationStatus used by operator-facing endpoints. Use this type when you need a simple on-disk, atomic store for the initial claim token and ServerId and also want to surface the most recent registration outcome (success or failure) for diagnostics or UI.
Persists an opaque directory claim token and the row's stable `ServerId` to disk and exposes that data plus an ephemeral `RegistrationStatus` for operator-facing endpoints. Reach for `DirectoryClaimStore` when the process needs to remember the one-time claim token issued at first registration and to report current registration status; it handles atomic on-disk writes and concurrent access within the process so callers can read `ClaimToken`, `ServerId`, and `Status` without taking locks.
## Remarks
This class centralises two responsibilities: durable storage of the claim token + ServerId and an in-memory, ephemeral view of registration status. The file write uses an atomic temporary-write-then-rename strategy (so partial writes are avoided) and callers should treat the stored contents as a secret. Concurrency is handled with a SemaphoreSlim for writes and Volatile reads/writes for the in-memory references: SaveClaimAsync and UpdateServerIdAsync serialize on-disk updates while ClaimToken, ServerId and Status are safe to read without taking the write lock.
## Example
```csharp
// resolve IConfiguration and ILogger<DirectoryClaimStore> from your DI container
var store = new DirectoryClaimStore(configuration, logger);
// Persist the one-time claim token and server id (called once when first claimed)
await store.SaveClaimAsync(claimToken, serverId);
// Read back the persisted values later
var token = store.ClaimToken; // may be null until saved
var id = store.ServerId; // may be null until saved
// Update only the ServerId when re-registering with the same token
await store.UpdateServerIdAsync(newServerId);
// Report ephemeral registration outcomes for operator UI
store.SetSuccess(serverId);
// or on failure:
store.SetFailure("ConflictError", new[] { "host-a", "host-b" });
// Inspect the last registration status
var status = store.Status;
```
`DirectoryClaimStore` separates durable state (the `PersistedClaim` containing `ClaimToken` and `ServerId`) from ephemeral state (`RegistrationStatus`). Durable state is loaded once in the constructor (via configuration-resolved `FilePath`) and updated by `SaveClaimAsync` and `UpdateServerIdAsync` using an atomic write strategy (tmp file + rename). Ephemeral `Status` is updated in-memory by `SetSuccess` and `SetFailure` for operator/UI endpoints and is intentionally not written to disk. Thread-safety is achieved by using `Volatile.Read`/`Volatile.Write` for lock-free readers and a private `SemaphoreSlim` (`_writeLock`) to serialize writers; writers also perform the atomic file swap.
## Notes
- The on-disk file is treated as a secret; protect filesystem permissions and backups accordingly.
- Status is ephemeral and kept only in memory; SetSuccess/SetFailure do not persist to disk.
- SaveClaimAsync is intended to be called once per row's lifetime (first claim). UpdateServerIdAsync is a no-op when the ServerId is unchanged.
- ClaimToken and ServerId properties may be null until a persisted value is loaded or saved.
- The on-disk file is treated as a secret; callers and operators should protect the `FilePath` and its contents (it contains the `ClaimToken`).
- `SaveClaimAsync` is intended to be called only once per row's lifetime (on first claim). `UpdateServerIdAsync` is used when re-registering with an existing token and is a no-op when the `ServerId` is unchanged.
- `SetSuccess` / `SetFailure` mutate only the in-memory `Status` and do not persist anything; process restarts will lose these ephemeral fields (durable `PersistedClaim` is preserved).
- Writes use an atomic tmp+rename strategy to avoid partial files, but this class does not coordinate cross-process access beyond the atomic replace; if multiple processes may write the same file concurrently, external synchronization is required to avoid races.
- I/O errors from loading or writing the backing file (e.g. permissions, disk full) will surface to callers of the write methods or during construction; callers should handle or surface those exceptions as appropriate.
---
@@ -79,12 +56,13 @@ public sealed record RegistrationStatus(
| `ConflictingHosts` | `string[]?` | — |
RegistrationStatus is a small, immutable data container that captures the outcome of attempting to register a directory claim in the EchoHub server. It indicates whether the registration succeeded and optionally conveys the server identity, timestamp, error details, and any conflicting hosts so higher-level logic can react accordingly.
Represents the outcome of attempting to register a server with the directory claim store. This `record` is an immutable value type that carries the essential pieces of registration state: whether the entity is registered (`IsRegistered`), the assigned `ServerId` if one exists, the time of the last registration attempt (`LastRegisteredAt`, a `DateTimeOffset?`), an optional `LastError` describing the failure, and any `ConflictingHosts` that prevented registration. Consumers typically construct or propagate this value from the registration workflow and use it to inform callers, UI logic, or logging code rather than broadcasting multiple primitive values.
## Remarks
RegistrationStatus models a single, transportable result from a registration process. As a record, it benefits from value-based equality, making it easy to compare results across layers or to cache and reuse them. The nullable fields reflect real-world outcomes: a registration attempt may not yield a ServerId or LastRegisteredAt, and LastError plus ConflictingHosts carry additional context when registration fails or is disputed. This abstraction isolates the surface area of registration outcomes from the rest of the directory claim store, enabling consistent handling without sprinkling primitive flags throughout the codebase.
As a `sealed` `record`, `RegistrationStatus` provides value-based equality and immutability, making it a safe, portable summary of a registration outcome across components. The nullable members reflect that some details may be unavailable depending on the failure mode (for example, no `ServerId` if registration hasn't completed). The `ConflictingHosts` array communicates all hosts involved in a conflict, enabling callers to present a remediation path.
## Notes
- Nullable fields indicate optional context; always guard before accessing ServerId, LastRegisteredAt, LastError, and ConflictingHosts to avoid NullReferenceException.
- The `string[]?` `ConflictingHosts` is an array, which is mutable. If you publish this instance or cache its value, clone the array to prevent external mutation from changing the documented status.
- Nullability semantics: `ServerId`, `LastRegisteredAt`, `LastError`, and `ConflictingHosts` being `null` means the data is not available in the current outcome; interpret accordingly and avoid conflating a genuine value with absence.
---
@@ -8,11 +8,12 @@ public sealed class FileCleanupService : BackgroundService
```
FileCleanupService is a hosted background service that periodically deletes files in a configured storage directory that are older than a configured retention period. It reads settings from configuration, chooses the target path, and logs progress while running until cancellation.
FileCleanupService is a hosted background worker that periodically deletes files older than a configured retention window from a storage directory determined by configuration. It reads its interval and retention settings from configuration, selects a path (configured path or a sensible default under the application base directory), and logs its activity while reliably continuing after errors.
## Remarks
The cleanup logic is encapsulated in a dedicated BackgroundService to centralize disk-space hygiene and keep it decoupled from request-driven code. It relies on dependency-injected IConfiguration and ILogger to determine interval, retention, and storage path, and to report status and errors. Cleanup runs in a cancellation-friendly loop and uses UTC timestamps to compare age, making behavior predictable across servers.
FileCleanupService encapsulates cleanup policy behind a dedicated background service, so cleanup logic is not sprinkled across the app. It uses dependency-injected `IConfiguration` and `ILogger<FileCleanupService>` to stay configurable and observable, and it handles exceptions without bringing down the service. The cleanup operation is intentionally conservative: files are deleted only if their creation time UTC is older than the computed cutoff, and per-file errors are logged and do not stop processing of the rest.
## Notes
- It only considers files directly within storagePath; subdirectories are not scanned. If you need recursive cleanup, switch to Directory.GetFiles(storagePath, "*", SearchOption.AllDirectories) and adjust the cutoff logic accordingly.
- File age is determined by GetCreationTimeUtc; if your deployment uses different semantics (e.g., files moved or uploaded), consider using GetLastWriteTimeUtc or metadata-based age checks.
- If the storage path does not exist or is not configured, the cleanup is skipped gracefully.
- Defaults are applied when configuration values are missing or invalid: `Storage:CleanupIntervalHours` defaults to 1, `Storage:RetentionDays` defaults to 30.
- If files are in use or cannot be deleted due to permissions, the service logs a warning and continues with the remaining files.
@@ -8,14 +8,12 @@ public class FileStorageService
```
FileStorageService persists uploaded data to a local disk storage location, creating the directory if it does not exist and selecting the path from configuration (Storage:Path) or defaulting to an uploads folder beside the application. Each saved file is assigned a GUID-based fileId and stored with its original extension; the API supports SaveFileAsync, GetFilePath, GetStoredFileIds, and DeleteFile for common lifecycle operations.
FileStorageService is a lightweight on-disk storage helper for uploaded files. It derives its storage location from configuration under `Storage:Path` (defaulting to an `uploads` directory next to the application's base directory), ensures the directory exists, and provides basic operations to save, locate, enumerate, and delete files.
## Remarks
By centralizing disk interactions, this service hides filesystem details from callers and provides a single, testable abstraction for storing attachments. It guarantees the storage directory exists and maps between a stable fileId and the corresponding on-disk file (preserving the extension). The GetStoredFileIds method performs a single directory scan to facilitate bulk checks across many files without per-file I/O.
FileStorageService centers on a GUID-based identity for each stored file and writes files to a single storage directory as `"{fileId}{extension}"`. Retrieval by id uses a wildcard extension, so callers do not need to know the original file name or extension at lookup time. The `GetStoredFileIds` method performs one directory scan to produce the set of ids (filenames without extensions), enabling bulk checks of attachments without issuing a separate filesystem glob per id. The design hides actual file names from callers while preserving the original extension on disk to help downstream consumers infer content type. The constructor's path resolution and directory creation ensure a usable store is available up front, reducing boilerplate for calling code.
## Notes
- The storage path is captured at construction time; changes to configuration after construction won't affect this instance.
- No validation of the incoming streams content type or size is performed here; enforce validation at call sites if needed.
- DeleteFile uses GetFilePath to locate the file before deleting and becomes a no-op if the file does not exist.
- Initialization may throw if the configured storage path is invalid or cannot be created due to permissions.
- `DeleteFile` is safe to call for non-existent files; it simply becomes a no-op.
- `GetFilePath` relies on a pattern `"{fileId}.*"`; if multiple matches exist (e.g., due to external tampering), the first match is returned, which should be rare given the GUID-based ids.
@@ -8,86 +8,13 @@
```mermaid
%%{init: {'theme':'base','themeVariables':{'background':'#faf7ef','primaryColor':'#f0e2c2','primaryTextColor':'#1f2840','primaryBorderColor':'#8a7548','secondaryColor':'#d9efec','secondaryBorderColor':'#1d8a80','secondaryTextColor':'#1f2840','tertiaryColor':'#f2ebd8','tertiaryBorderColor':'#8a7548','tertiaryTextColor':'#1f2840','lineColor':'#1d8a80','titleColor':'#1f2840','fontSize':'14px','edgeLabelBackground':'#faf7ef','clusterBkg':'#f2ebd8','clusterBorder':'#8a7548','actorBkg':'#f0e2c2','actorBorder':'#8a7548','actorTextColor':'#1f2840','actorLineColor':'#8a7548','signalColor':'#1d8a80','signalTextColor':'#1f2840','activationBkgColor':'#d9efec','activationBorderColor':'#1d8a80','noteBkgColor':'#f2ebd8','noteBorderColor':'#8a7548','noteTextColor':'#1f2840','labelBoxBkgColor':'#f0e2c2','labelBoxBorderColor':'#8a7548','labelTextColor':'#1f2840','transitionColor':'#1d8a80','transitionLabelColor':'#1f2840','stateLabelColor':'#1f2840','altBackground':'#f2ebd8'}}}%%
flowchart TB
Start["Start"]
Extract["ExtractUrls(content) -> urls"]
CheckUrls["urls.Count == 0?"]
ReturnNullNoUrls["Return null (no URLs found)"]
InitEmbeds["Create empty List#60;EmbedDto#62; embeds"]
ForEach["For each url in urls"]
CallFetch["Call FetchEmbedForUrlAsync(url)"]
ReturnNullFromFetch["Returned null -> continue"]
AddEmbed["Add EmbedDto to embeds"]
CatchLog["Catch Exception -> LogDebug and continue"]
AfterLoop["All URLs processed"]
ReturnDecision["embeds.Count > 0?"]
ReturnEmbeds["Return embeds"]
ReturnNullAll["Return null (no successful embeds)"]
LinkEmbedService["TryGetEmbedsAsync: ExtractUrls content; if no URLs -> return null. For each URL: call FetchEmbedForUrlAsync -> validate absolute URI, allow http or https, skip private hosts; create CancellationTokenSource using HubConstants, send GET with HttpClient 'OgFetch' and HttpCompletionOption.ResponseHeadersRead; if non-success status -> skip; ensure Content-Type starts with text/html; read limited HTML; parse OG tags; determine title with og:title fallback to <title>; if no title -> skip; else build EmbedDto and add to results. Catch exceptions and LogDebug. Return embeds list or null"]
HubConstants["HubConstants: EmbedFetchTimeoutSeconds, EmbedMaxHtmlBytes, EmbedMaxDescription"]
EmbedDto["EmbedDto: represents successful OG embed data"]
subgraph FetchEmbedForUrlAsync
F1["Try Uri.TryCreate(url, Absolute)"]
F1_no["Return null (invalid uri)"]
F2["Check scheme is http or https"]
F2_no["Return null (unsupported scheme)"]
F3["IsPrivateHost(uri)?"]
F3_no["Return null (private host)"]
F4["Create CTS with HubConstants.EmbedFetchTimeoutSeconds"]
F5["Create HTTP client 'OgFetch'"]
F6["Send GET request, get response"]
F7["response.IsSuccessStatusCode?"]
F7_no["Return null (unsuccessful status)"]
F8["Content-Type starts with #quot;text/html#quot;?"]
F8_no["Return null (non-html content)"]
F9["Read limited HTML (HubConstants.EmbedMaxHtmlBytes)"]
F9_empty["Return null (empty or whitespace html)"]
F10["Parse OG tags, get title or fall back to #60;title#62;"]
F10_no["Return null (no title)"]
F11["Build EmbedDto and return"]
end
Start --> Extract
Extract --> CheckUrls
CheckUrls -->|"yes"| ReturnNullNoUrls
CheckUrls -->|"no"| InitEmbeds
InitEmbeds --> ForEach
ForEach --> CallFetch
CallFetch -->|"throws"| CatchLog
CallFetch -->|"null"| ReturnNullFromFetch
CallFetch -->|"EmbedDto"| AddEmbed
ReturnNullFromFetch --> ForEach
AddEmbed --> ForEach
CatchLog --> ForEach
ForEach -->|"done"| AfterLoop
AfterLoop --> ReturnDecision
ReturnDecision -->|"yes"| ReturnEmbeds
ReturnDecision -->|"no"| ReturnNullAll
CallFetch --> F1
F1 -->|"no"| F1_no
F1 -->|"yes"| F2
F2 -->|"no"| F2_no
F2 -->|"yes"| F3
F3 -->|"true"| F3_no
F3 -->|"false"| F4
F4 --> F5
F5 --> F6
F6 --> F7
F7 -->|"no"| F7_no
F7 -->|"yes"| F8
F8 -->|"no"| F8_no
F8 -->|"yes"| F9
F9 -->|"empty"| F9_empty
F9 -->|"has html"| F10
F10 -->|"no"| F10_no
F10 -->|"yes"| F11
F1_no --> ReturnNullFromFetch
F2_no --> ReturnNullFromFetch
F3_no --> ReturnNullFromFetch
F7_no --> ReturnNullFromFetch
F8_no --> ReturnNullFromFetch
F9_empty --> ReturnNullFromFetch
F10_no --> ReturnNullFromFetch
F11 --> AddEmbed
LinkEmbedService -->|"reads timeouts and limits"| HubConstants
LinkEmbedService -->|"creates and adds successful EmbedDto"| EmbedDto
LinkEmbedService -->|"foreach URL (loop)"| LinkEmbedService
```
```csharp
@@ -95,30 +22,13 @@ public partial class LinkEmbedService
```
Scans a piece of message text for URLs and attempts to produce lightweight link preview data (EmbedDto) by fetching and parsing Open Graph and common HTML metadata. Use TryGetEmbedsAsync when you need server-side link previews for chat messages and want a defensive, timeout- and size-limited fetch that never throws (it logs failures and returns null when no usable embeds are found).
Detects and fetches Open Graph-style embed metadata for any URLs found in a piece of message `content`. Use `LinkEmbedService` (via its `TryGetEmbedsAsync` method) when you want a best-effort, non-throwing attempt to produce [`EmbedDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) objects for links inside user messages — for example, to show link previews — and you want network, size and privacy protections applied automatically.
## Remarks
LinkEmbedService centralizes the logic for discovering URLs in a message and converting remote HTML metadata into EmbedDto instances suitable for display. It is intentionally defensive: only absolute http/https URLs are considered, private hosts are skipped, fetches are limited by a cancellation timeout and a maximum HTML byte count (HubConstants), and only text/html responses are parsed. Errors during individual fetches are caught and logged at debug level so the caller observes either a list of successful embeds or null (no useful embeds).
## Example
```csharp
// Given an instance of LinkEmbedService (typically from DI):
var embeds = await linkEmbedService.TryGetEmbedsAsync(messageContent);
if (embeds is null)
{
// No embeds found or all fetch attempts failed.
}
else
{
Console.WriteLine($"Found {embeds.Count} embeds");
foreach (var embed in embeds)
{
// render embed in UI or pass to presentation layer
}
}
```
`LinkEmbedService` centralizes link-preview logic so callers do not have to implement URL extraction, host-safety checks, HTTP fetching, HTML-size limits, or Open Graph parsing themselves. The public `TryGetEmbedsAsync` method returns `null` when no useful embed data is available (either because no URLs were found or all fetch attempts failed) and never throws; individual fetch failures are caught and logged at debug level. Internally it calls the private `FetchEmbedForUrlAsync` for each URL which enforces absolute `http`/`https` URIs, rejects private hosts via `IsPrivateHost`, uses an `IHttpClientFactory`-created client named `"OgFetch"`, applies a `CancellationTokenSource` timeout (`HubConstants.EmbedFetchTimeoutSeconds`), requires a `text/html` response, bounds the HTML read size (`HubConstants.EmbedMaxHtmlBytes`), extracts Open Graph tags (falling back to the `<title>` tag), decodes HTML entities with `WebUtility.HtmlDecode`, and truncates long descriptions to `HubConstants.EmbedMaxDescriptionLength`.
## Notes
- TryGetEmbedsAsync returns null when no URLs are present or when all fetches fail; it does not return an empty list in those cases—check for null before iterating.
- The service expects an IHttpClientFactory and creates a client with the name "OgFetch"; ensure your HttpClient configuration (handlers, DNS/timeout policies) is appropriate for remote HTML fetches.
- HTML metadata extraction is heuristic: it uses Open Graph tags, falls back to a <title> regex, reads only the first N bytes of HTML, and truncates long descriptions per HubConstants. Consumers should treat returned fields as untrusted display content and apply any necessary sanitization in the UI layer.
- The service expects an `IHttpClientFactory` client named `"OgFetch"` to be configured; network policy (proxies, handlers) should be applied on that named client rather than relying on this class to set HTTP options.
- Fetching is constrained by time and size: a cancellation timeout (`HubConstants.EmbedFetchTimeoutSeconds`) and a maximum number of HTML bytes (`HubConstants.EmbedMaxHtmlBytes`) are enforced; pages that exceed these limits may yield no embed.
- Only absolute `http`/`https` URLs are considered and private/internal hosts are explicitly ignored by `IsPrivateHost`; the method will return `null` instead of an [`EmbedDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) for such URLs.
- Failures during individual URL fetches are swallowed (logged at debug) so `TryGetEmbedsAsync` remains non-throwing for callers — check logs when embeds are unexpectedly missing.
@@ -8,12 +8,12 @@ public class MessageEncryptionService : IMessageEncryptionService
```
MessageEncryptionService provides AES-GCM-based encryption and decryption for strings using a 256-bit key loaded from configuration, returning ciphertexts in a standardized prefixed, base64-encoded format. Callers use it when they need authenticated encryption with a consistent storage format and null-safety helpers.
MessageEncryptionService is a server-side component that encrypts and decrypts text using AES-GCM with a 256-bit key sourced from configuration. It implements [`IMessageEncryptionService`](../../EchoHub.Core/Contracts/IMessageEncryptionService.cs.md) and exposes `Encrypt`, `Decrypt`, `EncryptNullable`, and `DecryptNullable`. Use it when you need to store or transmit sensitive strings (for example, in a database) without exposing plaintext. Each encrypted value is prefixed with the configured `CiphertextPrefix` and serialized as a base64-encoded nonce followed by a base64-encoded payload containing the ciphertext and authentication tag, enabling safe storage and later decryption with the same key. If a value supplied to `Decrypt` does not begin with the encryption prefix, the service treats it as legacy plaintext and returns it unchanged. When decryption fails for any reason, the service logs the issue and returns the placeholder string `[encrypted message — decryption failed]` to avoid leaking cryptographic details.
## Remarks
By centralizing the encryption logic, this class ensures all encrypted messages share the same nonce handling, tag size, and output format, which simplifies storage and auditing across clients and servers. It also enforces key validation upfront and uses dependency-injected logging to surface decryption problems and protect the caller from exceptions. The EncryptNullable/DecryptNullable helpers make it convenient to encode optional values without duplicating boilerplate.
MessageEncryptionService centralizes cryptographic logic to isolate security concerns from business code. It provides a single, testable path for encryption and decryption and ensures consistent storage format for encrypted data, which simplifies auditing and data integrity checks. The class reads a 256-bit key at startup from `Encryption:Key` (as Base64) and validates its length, making key management explicit and failure-revealing at boot time; the `EncryptDatabaseEnabled` flag controls whether database encryption should be active, enabling or disabling encryption behavior without code changes.
## Notes
- Key retrieval and validation: the constructor reads Encryption:Key from configuration as a Base64 string and requires exactly 32 bytes; otherwise it throws InvalidOperationException.
- Decryption safety and error handling: if content doesn't start with the CiphertextPrefix, it is treated as legacy plaintext; malformed payloads log a warning and yield "[encrypted message — decryption failed]"; any exception results in a logged error and the same sentinel output.
- Null handling convenience: EncryptNullable and DecryptNullable gracefully handle null inputs without throwing.
- Do not rotate the encryption key at runtime; the key is loaded once during construction and would render previously encrypted data unreadable.
- The class is thread-safe for concurrent use since it creates a new `AesGcm` instance per operation and does not share mutable state.
- Non-prefixed content is treated as legacy plaintext, ensuring backward compatibility with data that predates server-side encryption.
@@ -8,14 +8,11 @@ public sealed class MuteExpirationService : BackgroundService
```
Automatically unmutes users when their timed mute period has expired.
This is a background hosted service that periodically scans for users who are currently muted and whose MutedUntil timestamp has passed, then clears the mute state and logs the action. It uses a scoped DbContext instance per iteration (via IServiceScopeFactory) to perform a safe, isolated database update, and it runs on a fixed cadence (15 seconds) until the host is stopped. The service catches non-cancellation exceptions to avoid leaking the loop and continues monitoring uninterrupted.
Periodic background task that checks for users with an active timed mute and lifts the mute once the expiration time has passed. Implemented as a `BackgroundService`, it creates a short-lived scope to obtain an [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md), queries `Users` for those where `IsMuted` is true and `MutedUntil` has a value that is in the past, clears `IsMuted` and `MutedUntil`, and saves the changes. It logs each auto-unmute and continues running until the host is canceled; the check runs every 15 seconds to balance timely unmute with database load.
## Remarks
The MuteExpirationService centralizes the expiry-based state transition for user mutes, decoupling this concern from user actions or other services. By resolving EchoHubDbContext within a scope for each cycle, it ensures proper disposal of the context and its resources while keeping the background loop lightweight. This pattern keeps mute state consistent across the system and reduces the chance of missed expirations if a users timed mute expires while the application is running.
This symbol centralizes the timed-mute expiration lifecycle, decoupling unmute logic from controllers or scheduled jobs. It ensures mutes expire even if no user action occurs and uses a scoped [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) to avoid long-lived contexts and to work with fresh data on each cycle. Updates are batched per cycle, with a per-user log entry (e.g., "Auto-unmuted user ... (timed mute expired)") to aid observability and troubleshooting.
## Notes
- The query materializes expired mutes with ToListAsync before processing; for environments with a very large backlog of expirations, consider batching to reduce memory usage.
- The cadence (CheckInterval) is 15 seconds; adjust if you need tighter or looser alignment with mute expiration semantics.
- Time comparisons use UTC (DateTimeOffset.UtcNow) to avoid timezone-related drift; ensure MutedUntil is stored as a UTC timestamp to preserve correctness.
- The polling interval is fixed by `CheckInterval` (15 seconds); lowering or raising this value trades immediacy against database load. Adjust with awareness of your projects performance characteristics.
- Only mutes with a non-null `MutedUntil` are expired by this service. If a users `MutedUntil` is null, that mute will not be auto-expanded by this path and will require manual intervention or a different expiration rule.
@@ -8,32 +8,33 @@ public class PresenceTracker
```
Tracks active connections and per-user channel membership for a server-side hub/service. Use this when you need to know which usernames are currently connected (counting a user with multiple connections only once), map individual connection IDs to their user, and query which users are in which chat channels.
Maintains an in-memory registry of active connections, per-user connection sets, and per-user channel memberships. Use `PresenceTracker` when a hub or real-time service needs a centralized, process-local view of who is online (distinct users are counted once even if they have multiple connections), which channels each user has joined, and to obtain connection IDs for broadcasting to members of channels.
## Remarks
PresenceTracker centralizes presence state in three concurrent dictionaries: a connectionId → (userId, username) map, a username → set-of-connectionIds map, and a username → set-of-channelNames map. It uses a single private lock to make operations on the HashSet values atomic because ConcurrentDictionary only protects access to individual slots, not the mutable collections stored as values. The UserCountChanged event is raised only when the distinct online user count changes (for example, when a user's first connection is added or their last connection is removed); the implementation invokes the event outside the lock to avoid holding the lock while user code runs.
`PresenceTracker` centralises presence state so hubs or services can avoid scattering connection and channel bookkeeping across call sites. It stores a mapping of connection id → `(userId, username)` in `_connections`, a mapping of `username` → connection id set in `_userConnections`, and a mapping of `username` → channel name set in `_userChannels`. The class deduplicates users with multiple connections (a user with N connections is one online user) and raises `UserCountChanged` only when the distinct online user count changes. Internally it uses a private lock (`_lock`) around operations that mutate the `HashSet` values because `ConcurrentDictionary` protects its buckets but not the mutability of objects stored inside them; this ensures consistency for the `TryGetValue` → modify sequences and for multi-step cleanup when the last connection for a user is removed.
## Example
```csharp
var tracker = new PresenceTracker();
tracker.UserCountChanged += count => Console.WriteLine($"Online users: {count}");
// user connects from two clients (only the first connection should trigger the count change)
tracker.UserConnected("conn-1", Guid.NewGuid(), "alice"); // triggers UserCountChanged -> 1
tracker.UserConnected("conn-2", Guid.NewGuid(), "alice"); // no count change
// A client connects with two transports (two connection IDs) for the same logical user
var aliceId = Guid.NewGuid();
tracker.UserConnected("conn-1", aliceId, "alice");
tracker.UserConnected("conn-2", aliceId, "alice");
// join a channel and query who is in it
tracker.JoinChannel("alice", "general");
var usersInGeneral = tracker.GetOnlineUsersInChannel("general"); // contains "alice"
// Join a channel; returns true only if this `username` was not already in the channel
var firstJoin = tracker.JoinChannel("alice", "general");
// disconnect one connection; user still online because another connection remains
tracker.UserDisconnected("conn-1"); // returns "alice"; no UserCountChanged
// Read who is in a channel (snapshot list)
var usersInGeneral = tracker.GetOnlineUsersInChannel("general");
// final disconnect removes the user and triggers UserCountChanged
tracker.UserDisconnected("conn-2"); // returns "alice"; triggers UserCountChanged -> 0
// When a connection goes away
tracker.UserDisconnected("conn-1");
// When the last connection is removed, `UserCountChanged` will fire and channel membership for that user is cleaned up
```
## Notes
- The class treats usernames as dictionary keys using the string's default equality (case-sensitive by default). Normalize or use a consistent casing strategy before calling if your application expects case-insensitive behavior.
- The lock protects the HashSet instances stored in the dictionaries; callers do not need to synchronize when calling the public methods, but should avoid long-running work inside UserCountChanged handlers because the event is invoked from the presence-tracking flow (the implementation intentionally invokes the event outside the lock, but handlers that re-enter tracker methods could still affect ordering).
- The provided source appears truncated / contains a small syntax issue near GetChannelsForUser and GetConnectionsInChannels; verify the final implementation of those methods before relying on their exact return behavior.
- `UserCountChanged` is invoked synchronously on the calling thread after the internal lock is released; subscribers are called inline and should avoid long-running work to prevent blocking the caller.
- `JoinChannel` creates or updates the per-`username` channel set in `_userChannels` even if that `username` currently has no active connections; channel membership is tracked separately from `_connections`.
- The implementation uses a single private lock (`_lock`) to protect mutations of the `HashSet` values stored in the `ConcurrentDictionary` instances; this simplifies correctness but can be a contention point at very large scale. Consider sharding presence state if you expect thousands of concurrent mutations per second.
@@ -6,8 +6,8 @@
- [ServerDirectoryService](#serverdirectoryservice)
- [InfiniteRetryPolicy](#infiniteretrypolicy)
- [ServerDirectoryService (constructor)](#serverdirectoryservice-constructor)
- [BuildConnection](#buildconnection)
- [ConnectWithRetryAsync](#connectwithretryasync)
- [DisposeConnectionAsync](#disposeconnectionasync)
- [ExecuteAsync](#executeasync)
- [ExtractConflictingHosts](#extractconflictinghosts)
@@ -21,16 +21,16 @@
- [RunConnectionLoopAsync](#runconnectionloopasync)
- [StopAsync](#stopasync)
- [DirectoryHubUrl](#directoryhuburl)
- [ReconnectBaseDelay](#reconnectbasedelay)
- [ReconnectMaxDelay](#reconnectmaxdelay)
- [UserCountMinInterval](#usercountmininterval)
- [DirectoryProtocol](#directoryprotocol)
- [DirectoryRegistrationErrors](#directoryregistrationerrors)
- [ErrorDetail](#errordetail)
- [RegisterServerDto](#registerserverdto)
- [RegisterServerResult](#registerserverresult)
- [Response](#response)
- [ServerDirectoryService (constructor)](#serverdirectoryservice-constructor)
- [UserCountMinInterval](#usercountmininterval)
- [ConnectWithRetryAsync](#connectwithretryasync)
- [ReconnectBaseDelay](#reconnectbasedelay)
---
@@ -43,16 +43,18 @@ public sealed class ServerDirectoryService : BackgroundService
```
Maintains a durable, resilient registration of this process in the central server directory and continuously reports presence (user counts) to that directory. Run as a hosted BackgroundService, it opens and manages a SignalR HubConnection to the directory, performs server registration/claiming, publishes metadata (name, description, hosts, version, tags) and incremental presence updates, and automatically reconnects with backoff when the connection drops.
Maintains a long-lived SignalR connection to the central server directory and keeps this process advertised and up-to-date. `ServerDirectoryService` runs as a hosted background worker that connects to the directory hub at `DirectoryHubUrl`, attempts to register/claim the server identity (persisting a claim token via [`DirectoryClaimStore`](DirectoryClaimStore.cs.md)), and pushes aggregated presence (user count) updates derived from [`PresenceTracker`](PresenceTracker.cs.md) to the directory. Use this service when the application should automatically announce itself and maintain presence information in the shared directory rather than performing manual/one-off registration calls.
## Remarks
This service sits between the local PresenceTracker, a persistent DirectoryClaimStore (which holds claim tokens and server IDs), and the remote directory hub. It coalesces frequent presence changes into a single "latest wins" update using a single-slot bounded channel to avoid flooding the directory, and applies an exponential backoff on reconnect attempts to avoid tight retry loops. Certain registration failures (for example: host already claimed, invalid token, or host conflict) are treated as permanent for the lifetime of the process — once that permanent-failure state is observed the service stops attempting to register on that connection and any subsequent reconnects, leaving operator intervention required to correct configuration and restart.
`ServerDirectoryService` is the glue between local presence tracking and the remote directory. It encapsulates the connection lifecycle (built by `BuildConnection` and managed by `ConnectWithRetryAsync` and `RunConnectionLoopAsync`), registration/claim semantics (`RegisterAsync` and `HandleRegistrationResponseAsync`), and presence propagation (`OnUserCountChanged` and `ProcessUserCountUpdatesAsync`). To avoid noisy updates the service coalesces bursts of presence changes using a single-slot [`Channel<int>`](../../EchoHub.Core/Models/Channel.cs.md) (`_userCountUpdates`) so that the most recent count wins, and it enforces a minimum send interval controlled by `UserCountMinInterval`. The service also implements an increasing reconnect backoff bounded by `ReconnectBaseDelay` and `ReconnectMaxDelay` via `GetBackoffDelay`. If the registration receives a fatal error (examples noted in comments: `HostAlreadyClaimed`, `InvalidToken`, `HostConflict`) the service sets `_registrationPermanentlyFailed` and stops attempting further register attempts for this connection — the operator must fix configuration and restart the process.
## Notes
- Permanent registration failures stop further register attempts even across reconnections; the operator must fix configuration and restart the service to recover.
- Presence updates are coalesced and throttled: the single-slot channel drops intermediate values (latest wins) and sends no more often than the configured UserCountMinInterval, so short-lived fluctuations may be suppressed.
- Reconnect attempts use a backoff between ReconnectBaseDelay and ReconnectMaxDelay; expect progressively longer wait times on repeated failures.
- The service relies on configuration and on DirectoryClaimStore to persist claim tokens; ensure those dependencies are available and correctly configured or registration will fail.
- `OnUserCountChanged` feeds a single-slot channel so intermediate counts can be dropped; the directory will see only the latest value sent after throttling, not every intermediate change. This is by design to reduce churn.
- Presence updates are throttled by `UserCountMinInterval`; rapid updates will be coalesced and delayed to respect that interval.
- If `_registrationPermanentlyFailed` becomes true (due to registration error codes like `HostAlreadyClaimed`/`InvalidToken`/`HostConflict`), the service stops retrying registration on the current connection and on subsequent reconnects — fixing the configuration and restarting the service is required to recover.
- The implementation persists a freshly-issued claim token via [`DirectoryClaimStore`](DirectoryClaimStore.cs.md) early in the registration flow to provide a durability guarantee for first-time claims; this ordering is intentional to avoid losing a claim token on process crash.
- The startup logic yields briefly before attempting its initial connect so the host can finish starting; this affects the timing of the first registration attempt.
---
@@ -65,18 +67,46 @@ private sealed class InfiniteRetryPolicy : IRetryPolicy
```
Retrieves indefinitely with exponential backoff capped at a maximum delay for reconnect attempts. This private sealed class implements IRetryPolicy and provides a retry strategy that increases the wait time between attempts rather than failing fast, enabling resilient reconnection to the server directory service.
NextRetryDelay yields delays based on an exponential progression: the first retry occurs after 1 second, followed by 2 seconds, 4 seconds, 8 seconds, and 16 seconds. After that, the delay is capped by ReconnectMaxDelay (30 seconds), so all subsequent retries use that maximum delay. This approach balances persistence in the face of transient failures with a bound on retry timing to avoid excessive load.
`InfiniteRetryPolicy` is a private sealed class that implements `IRetryPolicy` to provide a retry strategy. Its `NextRetryDelay` computes the next wait as 2^min(`retryContext.PreviousRetryCount`, 10) seconds and returns it, capped by `ReconnectMaxDelay`, enabling indefinite retries while bounding the maximum wait.
## Remarks
This policy encapsulates a specific retry strategy behind the IRetryPolicy interface, isolating timing logic from the rest of the reconnection code. Marked private and sealed, it signals an internal, non-extendable implementation used solely by the server directory service's retry mechanism. The cap on delay helps prevent runaway retry intervals while still ensuring the system makes progress toward recovery.
This abstraction centralizes the exponential backoff so the rest of the server's reconnection logic shares a consistent, testable delay policy. By being private and sealed, it remains an internal implementation detail, reducing surface area for change and misuse outside its containing class.
## Notes
- The first retry delay is 1 second, not immediate.
- Delays progress as 1s, 2s, 4s, 8s, 16s, and then 30s for all subsequent retries due to the cap.
- If many clients share the same cap, consider introducing jitter at the call site to avoid synchronized retries (this policy does not include jitter by default).
- The backoff growth saturates after the 10th retry; `NextRetryDelay` uses `Math.Min(retryContext.PreviousRetryCount, 10)` to compute the exponent, so delays cannot grow beyond `ReconnectMaxDelay`.
---
### ServerDirectoryService (constructor)
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** constructor
```csharp
public ServerDirectoryService(
IConfiguration configuration,
PresenceTracker presenceTracker,
DirectoryClaimStore claimStore,
ILogger<ServerDirectoryService> logger)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `configuration` | `IConfiguration` | — |
| `presenceTracker` | [`PresenceTracker`](PresenceTracker.cs.md) | — |
| `claimStore` | [`DirectoryClaimStore`](DirectoryClaimStore.cs.md) | — |
| `logger` | `ILogger<ServerDirectoryService>` | — |
Constructs a `ServerDirectoryService` by binding its essential collaborators: `IConfiguration`, [`PresenceTracker`](PresenceTracker.cs.md), [`DirectoryClaimStore`](DirectoryClaimStore.cs.md), and `ILogger<ServerDirectoryService>`. Typically invoked by the dependency injection container, it assigns these dependencies to the private fields `_configuration`, `_presenceTracker`, `_claimStore`, and `_logger` so the service can access configuration, track presence, manage directory claims, and emit logs.
## Remarks
By design, this constructor is a straightforward DI-only initializer with no business logic. It simply wires the four collaborators into private fields so the rest of the service can coordinate configuration data, presence state, claim storage, and logging.
## Notes
- This constructor does not perform argument null checks; rely on the DI container to provide valid instances. If you instantiate `ServerDirectoryService` manually, consider adding guards.
- Ensure the DI container is configured to register [`PresenceTracker`](PresenceTracker.cs.md), [`DirectoryClaimStore`](DirectoryClaimStore.cs.md), and `ILogger<ServerDirectoryService>` so resolution succeeds at startup.
---
@@ -91,43 +121,14 @@ private HubConnection BuildConnection()
**Returns:** `HubConnection`
BuildConnection constructs and returns a HubConnection configured to connect to the directory hub. It encapsulates the boilerplate of wiring the hub URL and an infinite automatic-reconnect policy, so callers can obtain a ready-to-configure connection without duplicating setup code.
BuildConnection creates and returns a new `HubConnection` configured to communicate with the directory hub. It wires the hub URL from `DirectoryHubUrl`, enables automatic reconnection using an `InfiniteRetryPolicy`, and returns the built instance for the caller to start and use.
## Remarks
BuildConnection centralizes the creation of the SignalR client used by the directory service, ensuring a consistent URL and reconnect policy across all call sites. By wrapping the builder steps, it reduces boilerplate and makes it easy to adjust the underlying connection strategy in one place. Note that the returned HubConnection is configured but not started; callers should invoke StartAsync (and manage its lifecycle) when ready. The attached InfiniteRetryPolicy governs how the client attempts to reconnect after a disconnect, providing resilience against transient network issues.
Encapsulating this setup here ensures consistent behavior across call sites that need a connection to the directory hub. The `InfiniteRetryPolicy` drives unbounded reconnect attempts, with the delay determined by `NextRetryDelay` on the `RetryContext`; callers should consider lifecycle management and potential long-running retries.
## Notes
- The connection is not started by BuildConnection; you must call StartAsync and later dispose of the connection to avoid leaks. Ensure DirectoryHubUrl is properly configured before using this method.
---
### ConnectWithRetryAsync
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
private async Task<bool> ConnectWithRetryAsync(HubConnection connection, CancellationToken ct)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `connection` | `HubConnection` | — |
| `ct` | `CancellationToken` | — |
**Returns:** `Task<bool>`
ConnectWithRetryAsync establishes a SignalR hub connection by repeatedly invoking StartAsync on the supplied HubConnection until the operation succeeds or the provided CancellationToken is triggered. When StartAsync completes successfully, the method returns true. If an exception occurs, the method increments its retry counter, computes a backoff delay via GetBackoffDelay(attempt), logs a warning including the delay, and awaits Task.Delay(delay, ct) before retrying. If the CancellationToken is canceled before a successful connection, the loop exits and the method returns false. This encapsulates transient-connection retry logic so callers do not have to implement their own retry loop.
## Remarks
Centralizes retry/backoff semantics for establishing the directory connection, so callers don't implement their own loop. It respects the cancellation token to avoid hanging and uses logging to surface transient failures for operators.
## Notes
- All exceptions from StartAsync are treated as retryable; there is no distinction between transient and permanent errors.
- CancellationToken is observed during both StartAsync and the subsequent Task.Delay, so cancellation is respected promptly.
- The backoff duration is determined by GetBackoffDelay(attempt); ensure this aligns with the desired backoff strategy to avoid excessively long waits or too-aggressive retries.
- The returned `HubConnection` is not started automatically; you must call `StartAsync()` before use.
- Each invocation yields a new `HubConnection`; reuse the instance if a single long-lived connection is required.
---
@@ -148,20 +149,15 @@ private static async Task DisposeConnectionAsync(HubConnection connection)
**Returns:** `Task`
Disposes the given HubConnection asynchronously with a hard 3-second timeout and suppresses any errors, ensuring shutdown proceeds without being blocked by a slow disposal. Use this during teardown when you want to promptly release the connection without surfacing disposal failures.
Disposes a `HubConnection` asynchronously with a bounded timeout by awaiting `DisposeAsync()` converted to a `Task` via `AsTask()` for up to 3 seconds. If the operation exceeds the timeout or throws, the exception is caught and ignored to prevent shutdown from blocking. This private helper ensures resources are released promptly during server shutdown without risking a hang.
## Remarks
This pattern encapsulates a best-effort disposal strategy: it waits up to three seconds for disposal to complete, then continues regardless of the outcome. By catching all exceptions, callers cannot rely on successful disposal being reported; if you need visibility into disposal failures, handle the disposal outside this helper. It is intended for shutdown scenarios where the connection must be released promptly and further operations on the connection are no longer needed.
## Example
```csharp
// Example usage within the same class
await DisposeConnectionAsync(connection);
```
This method isolates the disposal of a `HubConnection` from the rest of shutdown logic, providing a deterministic, non-blocking path when terminating the server. By swallowing disposal failures, it avoids a slow or faulty dispose from delaying process termination, though it hides potential cleanup issues that may warrant later diagnostics. As a private static helper, it signals that disposing a given `HubConnection` is a concern tied to the server's lifecycle rather than a general-purpose cleanup utility.
## Notes
- Empty catch hides disposal failures; only use this when shutdown must not be delayed by disposal issues.
- The 3-second timeout is hard-coded; adjust if your application's shutdown window requires a different bound.
- This method swallows all exceptions from `DisposeAsync` and the timeout; consider adding logging if you need visibility into disposal problems.
- The 3-second timeout is hard-coded and may not suit every environment; make it configurable if needed.
- Caller must ensure the `connection` parameter is non-null; passing null will throw before entering the try block.
---
@@ -182,7 +178,13 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
**Returns:** `async Task`
Executes the hosts startup lifecycle for registering the server with the EchoHubSpace directory when configured to be public. It yields to the host to finish starting, validates the configuration, collects hosts and metadata from configuration, resolves the server version, and then delegates to the connection loop that maintains the directory registration. It also subscribes to user-count changes so presence updates can be propagated while the registration is active, and guarantees cleanup of the event subscription when the operation completes or fails.
Coordinates the public-directory registration lifecycle for the server. It first yields to the host to finish initialization, then decides whether to register by reading `Server:PublicServer` from configuration; when enabled, it gathers metadata from `Server:PublicHosts`, `Server:Name`, `Server:Description`, `Server:Tags`, and the computed `version`, logs its intent, subscribes to user-count changes via `_presenceTracker.UserCountChanged`, and starts the connection loop with `RunConnectionLoopAsync` using `stoppingToken`. If registration is disabled or required config is missing, it logs and exits gracefully.
## Remarks
This symbol serves to encapsulate the startup flow for a publicly visible server: it centralizes the decision, metadata collection, and lifecycle management needed to register with the directory. The initial `await Task.Yield()` gives the host a chance to continue its startup sequence before any logging or network activity. The subscription to `_presenceTracker.UserCountChanged` is paired with a `finally` to guarantee cleanup and avoid leaks, even if the connection loop fails or is cancelled.
## Notes
- The code unsubscribes from `_presenceTracker.UserCountChanged` in `finally` to avoid memory leaks and stray callbacks after the connection loop ends.
---
@@ -203,17 +205,23 @@ private static string[]? ExtractConflictingHosts(ErrorDetail? error)
**Returns:** `string[]?`
ExtractConflictingHosts reads a JSON payload from an ErrorDetail's Data field and returns the list of host names described under the ConflictingHosts property. It tolerates both PascalCase (ConflictingHosts) and camelCase (conflictingHosts) keys because SignalR's wire casing depends on the hub's serializer configuration, and the field is typed object?.
It returns null when the payload is missing or not a JSON object, when neither property is present, when the property isn't an array, or when the array contains no string entries. Otherwise, it returns a string[] of the host names extracted from the array.
ExtractConflictingHosts pulls the `ConflictingHosts` from an error's loosely-typed `Data` payload and returns it as a `string[]` when present. It tolerates both PascalCase and camelCase keys to accommodate different serializer configurations. If the payload is missing, not a JSON object, not an array, or contains no string values, the method returns null.
## Remarks
The helper centralizes resilient parsing of optional error metadata, shielding callers from variations in payload shape and casing. By returning null for absent or empty data, it lets higher-level error handling distinguish between "no information" and an explicit list of conflicting hosts.
By centralizing the JSON-payload parsing in a small helper, callers do not need to know the wiring quirks of the error data or the particular casing produced by the hub's serializer. It provides a stable, strongly-typed extraction point for host names when conflicts are reported.
## Example
```csharp
string[]? hosts = ExtractConflictingHosts(error);
```
## Notes
- Returns null rather than an empty array when no hosts are present or the data is malformed.
- Non-string elements inside the host list are ignored.
- The method is private and static, indicating it is an internal helper for extracting just this piece of information from a larger error payload.
- Returns null if the input error is null, the `Data` payload is not a JSON object, the relevant property is missing, or the array contains no string values.
- Non-string items within the `ConflictingHosts` array are ignored; only string values are collected.
- The source snippet in the method contains a likely compile-time issue: `List<string> hosts = []` is invalid C#. It should be initialized as `new List<string>()` (or `var hosts = new List<string>();`). This is a potential trap to address during review.
---
@@ -234,14 +242,13 @@ private static TimeSpan GetBackoffDelay(int attempt)
**Returns:** `TimeSpan`
Calculates the exponential backoff delay for a reconnect attempt. Given the retry attempt index, it computes 2^min(attempt, 10) seconds, then clamps the result to ReconnectMaxDelay. The returned TimeSpan is used by the reconnect logic to wait before the next attempt, ensuring that retries are spaced out but never exceed a configured maximum delay.
`GetBackoffDelay` computes the wait duration before the next reconnect attempt. Given an `attempt`, it derives the delay as `TimeSpan.FromSeconds(Math.Pow(2, Math.Min(attempt, 10)))` and returns the value capped at `ReconnectMaxDelay` as a `TimeSpan`.
## Remarks
Isolates the backoff policy in a small helper, keeping the retry loop simple and readable. The exponential ramp-up helps avoid overwhelming the remote endpoint while still providing progressively longer waits as failures persist; the cap guarantees a bound on wait times. This method is private to the class and intended for internal use by the directory service's reconnect flow.
This symbol encapsulates the reconnect retry policy within the server directory service to ensure consistent timing across retries. It employs exponential growth with a hard cap to prevent unbounded delays while avoiding overly-aggressive backoff in early attempts.
## Notes
- Ensure 'attempt' is non-negative; negative values yield sub-second delays due to 2^negative, which may be surprising.
- The delay is clamped to ReconnectMaxDelay; even very large attempts cannot produce longer waits.
- The exponent is capped by `Math.Min(attempt, 10)`, so delays stop growing exponentially after the 10th attempt; beyond that, the final delay is determined by `ReconnectMaxDelay`.
---
@@ -262,17 +269,10 @@ private Task HandleRegistrationErrorAsync(ErrorDetail[]? errors)
**Returns:** `Task`
HandleRegistrationErrorAsync interprets the errors returned by the directory registration attempt, logs a code-specific message, and marks the registration as permanently failed to stop automatic retries until a restart.
HandleRegistrationErrorAsync centralizes the processing of errors reported by directory registration. It marks the registration as permanently failed, derives an error code (falling back to `UnknownError`) and the set of conflicting hosts from the first error, and then logs a targeted, code-specific message before recording the failure in `_claimStore`.
## Remarks
This method centralizes the directory registration failure handling, consolidating how various error codes are surfaced to operators and how the failure state is recorded. It relies on ExtractConflictingHosts to surface any conflicting hosts and on the claim store to persist the failure details so that diagnostics and recovery decisions can reason about what happened.
## Notes
- It forces the server into a permanently failed state for registration, with log messages indicating that the server will not retry until restarted. This ensures long-running processes do not silently retry under inconsistent directory state.
- It returns a completed Task and performs its side effects synchronously (logging and state mutation) without throwing, so callers can await the result safely without handling exceptions.
By encapsulating this logic in one place, the method decouples error interpretation from the main registration flow. It coordinates with `_claimStore` to persist a failure snapshot and with `_logger` to surface actionable diagnostics for operators, aiding remediation. The design relies on the first error and the extracted conflicts to provide a deterministic failure narrative while supporting specific guidance for each known error code from `DirectoryRegistrationErrors` (e.g. `HostAlreadyClaimed`, `InvalidToken`, `HostConflict`, `InvalidInput`).
---
@@ -296,7 +296,14 @@ private async Task HandleRegistrationResponseAsync(Response<RegisterServerResult
**Returns:** `Task`
Handles the directory registration response by validating the envelope, enforcing protocol compatibility, and performing the appropriate follow-up depending on success or failure. It ensures a durability guarantee by saving a fresh claim token before acknowledging success, otherwise updating the ServerId to keep internal state in sync, and it logs the outcome for observability.
Handles the asynchronous response from the directory registration workflow. It validates the envelope is non-null, asserts the protocol version against `DirectoryProtocol.Version`, and then branches on success or failure, performing durability-oriented state updates via internal stores and logging the outcome.
## Remarks
This method centralizes response handling for directory registration: it immediately treats null envelopes, protocol mismatches, or malformed success payloads as permanent failures to avoid operating against an incompatible or corrupted directory. On success, it ensures a fresh `ClaimToken` is persisted before acknowledging the new `ServerId`, guaranteeing durability for the initial credential and consistent recovery behavior after restarts. The approach also accommodates re-registration by syncing the persisted `ServerId` when no new token is provided, keeping local state aligned with the directory.
## Notes
- If a new `ClaimToken` is supplied, it is saved prior to completing the success path; if not, the method only updates the persisted `ServerId` to reflect the directory.
---
@@ -317,14 +324,13 @@ private void OnUserCountChanged(int newCount)
**Returns:** `void`
OnUserCountChanged is an internal callback invoked whenever the server detects a change in the user count. It publishes the new value by writing to a single-slot channel via _userCountUpdates.Writer.TryWrite(newCount). The single-slot channel semantics coalesce bursts of updates so that only the most recent count is propagated downstream, reducing churn and avoiding repeated handling for rapid presence changes.
Internal event handler that forwards the new user count into the single-slot update channel. It uses `_userCountUpdates.Writer.TryWrite(newCount)` to coalesce bursts of presence changes, ensuring only the latest value is observed downstream.
## Remarks
By delegating to a dedicated Writer, this method decouples the act of detecting changes from the consumers that react to them. The single-slot channel pattern ensures downstream work is debounced; observers observe the latest value after a change cycle, which is ideal for presence-aware UI updates or telemetry.
This internal abstraction decouples the producer of presence changes from the consumer by routing updates through the `_userCountUpdates.Writer` channel on a single-slot channel. The non-blocking `TryWrite` call ensures bursts of updates don't overwhelm downstream processing, preserving only the most recent value.
## Notes
- The call does not inspect the result of TryWrite; if the channel is full or readers slow, an update may be dropped, so downstream consumers should be able to tolerate occasional missed updates.
- This method is private to its containing type; external code should not rely on direct invocation.
- The non-blocking nature of the `TryWrite` call means bursts can be coalesced and intermediate counts may be dropped; only the latest value is observed.
---
@@ -347,15 +353,8 @@ private async Task ProcessUserCountUpdatesAsync(HubConnection connection, Task c
**Returns:** `Task`
ProcessUserCountUpdatesAsync continuously consumes user-count updates from a buffered channel and, when appropriate, reports the latest count to the directory via a HubConnection. It throttles emissions to respect a minimum interval, draining newer values during the wait so the hub receives the most up-to-date count rather than a stale snapshot, and it exits gracefully on cancellation or connection closure; updates are only sent when the hub is connected and the local registration is valid.
Runs an asynchronous background loop that propagates the latest observed user count to the directory service over a `HubConnection`. It listens for updates from `_userCountUpdates.Reader` and terminates when the `connectionClosed` task completes or cancellation is requested via the `ct` token. On each update, it reads the current `count`, then throttles sends to respect `UserCountMinInterval` (draining newer values during the wait so the eventual call carries the latest count). If the count hasnt changed since the last report, or the hub is not connected, or registration has permanently failed or is not currently registered, it skips sending. When sending is appropriate, it invokes the hub method `UpdateUserCount` with the latest `count`, updates `_lastReportedUserCount` and `lastSentAt`, and logs the outcome. This pattern ensures updates are delivered efficiently, tolerate bursts, and never crash due to transient failures.
## Remarks
This method decouples producers of user-count data from the actual update path by reading from a channel and emitting to the directory hub only when ownership conditions are met. The drain-on-wait policy ensures the directory reflects the latest state under bursty updates without flooding the hub, and the preconditions (connected hub and valid registration) guard presence reporting within the lifecycle of the system.
## Notes
- Updates may be dropped under bursty traffic due to throttling and the drain loop; callers should not rely on every intermediate update being observed by the hub.
- If the hub is temporarily unavailable or registration is not established, updates are skipped until conditions are met; there is no automatic backoff beyond the local catch logging.
- Cancellation via the provided CancellationToken causes an immediate exit from the loop; ensure callers signal cancellation during shutdown to avoid lingering tasks.
---
@@ -380,15 +379,7 @@ private async Task RegisterAsync(string name, string? description, string[] host
**Returns:** `Task`
Registers the current server with the directory service over a live hub connection. If connected and not permanently failed, it collects the online user count, builds a RegisterServerDto containing the server's name, description, hosts, user count, version, tags, and the current claim token, and sends it to the directory via the hub's RegisterServer method. The response is processed by HandleRegistrationResponseAsync to apply the result locally. Any exceptions are caught and logged as warnings with no propagation to the caller.
## Remarks
Encapsulates the server-registration handshake behind a private method, so callers don't have to manage DTO construction or hub invocation directly. It coordinates with _presenceTracker for live user counts and with _claimStore to carry the persisted claim token across registrations. The guard checks against the hub's connection state and a permanent-failure flag reflect the intended lifecycle: registration only runs when viable, preventing noisy or duplicate attempts.
## Notes
- Exceptions are swallowed; the method logs a warning and does not rethrow, so callers cannot rely on exceptions for control flow and may need separate retry logic.
- On the first-ever registration, ClaimToken is null; after the first successful claim it is persisted and reused on subsequent registrations.
- If not connected or if a permanent failure has been recorded, the method returns immediately without attempting registration.
RegisterAsync asynchronously registers the current server with the directory service when the hub connection is active. It exits early if the hub is not connected or if a permanent registration failure has been recorded, avoiding unnecessary work. When proceeding, it reads the current online user count from `_presenceTracker.GetOnlineUserCount()`, builds a `RegisterServerDto` with the server's `name`, `description`, `hosts`, `userCount`, `version`, `tags`, and the persisted claim token `_claimStore.ClaimToken`, and then calls the directory via `_connection.InvokeAsync<Response<RegisterServerResult>>("RegisterServer", dto)`. The envelope is then passed to `HandleRegistrationResponseAsync` to finalize the registration flow.
---
@@ -403,15 +394,14 @@ private static string ResolveVersion()
**Returns:** `string`
Resolves the version string used to identify the running ServerDirectoryService assembly. It prefers AssemblyInformationalVersionAttribute.InformationalVersion, but strips any SourceLink git SHA suffix (for example '0.2.10+abc123') before returning; if that attribute isn't present, it falls back to the assembly version, and finally to '0.0.0' if neither is available.
Returns a human-friendly version string for the server assembly. It is a private static helper that reads the `AssemblyInformationalVersionAttribute.InformationalVersion` from the containing assembly (via `typeof(ServerDirectoryService).Assembly`) and, if present, strips any `+` suffix (git SHA) added by SourceLink before returning the value; if not present, it falls back to the assembly's `Version` as a string, and finally to the literal `0.0.0` if neither is available.
## Remarks
This centralizes version resolution for the ServerDirectoryService, ensuring a consistent, human-friendly version string for diagnostics, logging, and server identity without leaking VCS details. It prefers source-controlled metadata when possible, but gracefully degrades to a stable default when it's not.
This tiny helper centralizes version resolution for the server, ensuring consistent display and logging of version regardless of build configuration. By extracting the informational version when available and normalizing away VCS metadata, it prevents leaking internal identifiers while still reflecting the actual package version. The implementation relies on reflection to read the version data from the containing assembly, so the produced value depends on the built assembly's metadata at runtime.
## Notes
- Strips the SourceLink git SHA suffix by locating the '+' and returning the prefix portion only.
- If neither informational version nor assembly version is available, the method returns '0.0.0'.
- Uses a private static helper scope; ensure tests align with the private context and the assembly hosting the symbol remains ServerDirectoryService's assembly.
- If the `InformationalVersion` contains a `+` (the SourceLink suffix), only the portion before `+` is returned, keeping the string human-friendly.
- If neither the informational version nor the standard assembly version is available, the method returns the literal `0.0.0` as a safe fallback.
---
@@ -443,37 +433,10 @@ private async Task RunConnectionLoopAsync(
**Returns:** `Task`
## Source Code
Runs a resilient, long-running loop that manages the lifecycle of a connection to a directory service. It repeatedly builds a connection, wires in heartbeat and re-registration logic, and, when the connection is permanently closed or cancellation is requested, cleanly tears down and rebuilds the connection to maintain availability.
Runs a resilient, long-running loop that maintains a connection to the directory service by repeatedly building a connection via `BuildConnection()`, wiring up `Ping`/`Heartbeat`, `Reconnected`, and `Closed` handlers, and connecting with retry through `ConnectWithRetryAsync`. On a successful connect, it registers the server with `RegisterAsync` and streams user-count updates by calling `ProcessUserCountUpdatesAsync` until cancellation or a permanent disconnection is signaled via a `TaskCompletionSource`. When a permanent close occurs or cancellation is requested, the method disposes the connection and rebuilds after a short delay.
## Remarks
This method centralizes the connection lifecycle management, coordinating connection establishment, heartbeat handling, re-registration on reconnect, and clean disposal. It relies on a cancellation token and a TaskCompletionSource to synchronize asynchronous events across the loop, enabling robust recovery paths while preserving a consistent registration state.
## Notes
- If ConnectWithRetryAsync(connection, stoppingToken) returns false, the method exits, causing the outer loop to terminate and the service to stop attempting a reconnect.
- The On("Ping") handler sends a heartbeat back to the directory and safely logs any heartbeat failures without crashing the loop.
- When the connection is permanently closed, the Closed event signals completion via the TaskCompletionSource and the outer loop proceeds to rebuild after a short delay, unless cancellation has been requested.
## Dependencies
- TaskCompletionSource
- TaskCreationOptions
- Task
## Dependency APIs
- TaskCompletionSource (non-generic)
- Constructor: TaskCompletionSource(TaskCreationOptions options)
- Property: Task Task { get; }
- TaskCreationOptions (enum)
- Member used: RunContinuationsAsynchronously
- Task (System.Threading.Tasks.Task)
- Represents an asynchronous operation; used for awaiting and coordinating async work
## Symbol To Document
- Name: RunConnectionLoopAsync
- Kind: method
- File: src/EchoHub.Server/Services/ServerDirectoryService.cs
- Language: csharp
- ID: a28a6f2b-23c7-4de0-bb5e-3da24401feb3
The method centralizes all aspects of directory connectivity—heartbeat, re-registration, and back-to-back disconnections—into a single loop, minimizing risk of desynchronization between the server and directory state. It uses a `TaskCompletionSource` to coordinate the 'permanent close' signal so the outer loop can rebuild cleanly after a failure, and respects `_registrationPermanentlyFailed` to avoid blind re-registration after a known permanent fault.
---
@@ -494,13 +457,15 @@ public override async Task StopAsync(CancellationToken cancellationToken)
**Returns:** `async Task`
Override of StopAsync performs the base shutdown logic and then clears the internal _connection to release the resource and reflect that the service is disconnected.
This `StopAsync` override extends the base stop behavior by clearing the service's internal `_connection` after the base stop completes, ensuring resources are released and the connection cannot be reused. It first awaits `base.StopAsync(cancellationToken)` to perform the standard shutdown, then sets `_connection` to `null`.
## Remarks
Ensures the derived service participates in the lifecycle by letting the base stop routine complete before releasing its own resources. Clearing _connection after the base stop prevents reuse of an active connection during shutdown and marks the service as disconnected for the rest of the system.
Clearing `_connection` after the base stop ensures there are no lingering references to an active connection once shutdown has begun. It communicates a clear lifecycle boundary for the service's connection state to its collaborators and helps GC reclaim resources.
## Notes
- If base.StopAsync throws, _connection will not be cleared; consider wrapping the cleanup in a finally block to guarantee cleanup.
- Be aware that `_connection` becomes `null` after `StopAsync` completes; code that accesses `_connection` during shutdown should guard against null references or only run after shutdown is finished.
---
@@ -513,33 +478,7 @@ private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/serv
```
This private constant string DirectoryHubUrl holds the base URL for the servers directory hub used by ServerDirectoryService. It is initialized to https://echohub.voidcube.cloud/hubs/servers and should be referenced wherever the directory hub endpoint is needed, ensuring a single source of truth and avoiding string duplication.
## Remarks
By centralizing the hub URL in a single private constant, the class avoids scattering the endpoint string across multiple methods. This reduces the risk of inconsistent paths and simplifies maintenance if the hub address changes; it also makes the code more testable by isolating the configuration-like value in one place.
## Notes
- The value is baked into the assembly as a private const; it cannot be overridden at runtime. For environment-specific endpoints, consider configuration-driven access and testing hooks to swap or mock the value.
---
### ReconnectBaseDelay
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** field
```csharp
private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2)
```
ReconnectBaseDelay is a private static readonly TimeSpan that defines the base wait time used by ServerDirectoryService when retrying a failed connection. By centralizing this 2-second base delay, the code avoids magic numbers and provides a single point to tune the retry cadence across all reconnection attempts.
## Remarks
Centralizing the base delay enforces a uniform retry cadence and simplifies tuning during incidents or tests. Being static and readonly ensures the value is shared across all instances and cannot be changed at runtime, which preserves predictable timing in concurrent reconnection scenarios. If you later introduce a more sophisticated backoff strategy (for example, exponential backoff with jitter), this base delay would typically feed that mechanism rather than replace it.
## Notes
- Changing this value affects all reconnection retries across the service; it's a global constant for the directory service.
- Because it is private, external code or tests cannot override it directly; consider configuration or making it injectable if runtime tunability is required.
Defines the immutable base URL for the directory hub used by the `ServerDirectoryService` to reach server endpoints: `https://echohub.voidcube.cloud/hubs/servers`. As a private `const`, the value is baked into the assembly, ensuring a single source of truth for hub interactions within this service.
---
@@ -552,14 +491,29 @@ private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30)
```
Defines the upper bound for the delay between reconnection attempts. The field is private, static, and readonly, initialized as TimeSpan.FromSeconds(30). It is used by the servers internal reconnection logic to cap backoff durations, ensuring retry intervals remain bounded even under transient network issues.
ReconnectMaxDelay defines the upper bound for the delay between reconnection attempts performed by the service. Declared as a private static readonly `TimeSpan` and initialized with `TimeSpan.FromSeconds(30)`, it provides a single, immutable cap that applies to all reconnect logic within the `ServerDirectoryService`.
## Remarks
Centralizes reconnection policy within ServerDirectoryService to ensure consistent retry timing across all attempts. Being private and readonly, this value is not exposed to external components and cannot be modified at runtime, promoting predictable behavior and easier maintenance. The 30-second cap prevents excessively long delays during outages while avoiding overly aggressive retry loops.
Static readonly guarantees a shared, immutable cap across all instances, ensuring the reconnect cadence remains consistent even under concurrent reconnect operations. Because the field is private, the policy cannot be adjusted from outside the class; tuning requires a code change rather than a runtime configuration.
---
### UserCountMinInterval
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** field
```csharp
private static readonly TimeSpan UserCountMinInterval = TimeSpan.FromSeconds(1)
```
This private static readonly `TimeSpan` defines the minimum interval between user-count operations inside the class, enforcing throttling to avoid rapid updates. It is initialized as `TimeSpan.FromSeconds(1)` and should be used wherever the class would otherwise perform frequent user-count recomputations to maintain consistent timing.
## Remarks
This field centralizes the throttling policy for user-count computations within the class, ensuring consistent timing across internal update paths. Making it `static` and `readonly` prevents accidental drift at runtime and communicates that the value is a fixed policy rather than dynamic state. It also makes tuning straightforward: adjust this single value to influence all user-count throttling behavior without changing multiple call sites.
## Notes
- The maximum delay is fixed after class initialization; changing it requires code edits and a recompilation.
- Private scope ensures external code cannot depend on or bypass this policy.
- The value is baked into the assembly; changing it requires recompilation unless the code is refactored to read from a configuration source.
---
@@ -572,14 +526,14 @@ internal static class DirectoryProtocol
```
DirectoryProtocol is a small, internal helper that exposes the current envelope protocol version used by the server's directory communications. The Version constant holds the protocol version as a string ("1.0"), and serves as a single source of truth for compatibility checks. Bumps to this version are coordinated across both repositories to keep envelope formats aligned during client/server exchanges.
Pinned envelope protocol version is centralized in a single constant. The value is exposed as `DirectoryProtocol.Version`, so code references a single source of truth rather than duplicating version strings, ensuring coordinated upgrades across both repositories when the envelope protocol evolves.
## Remarks
By centralizing the protocol version, this type makes explicit when envelope formats may evolve and prevents drift between the two sides. It also clarifies where to pull the version for any envelope construction or validation, reducing the risk of duplicating literals across the codebase.
It acts as a minimal contract boundary by providing a stable, centralized version that downstream code can validate against. By routing all version bumps through `DirectoryProtocol.Version`, the codebase gains a predictable upgrade path and reduces drift between repositories.
## Notes
- Do not hard-code '1.0' in multiple places; reference DirectoryProtocol.Version instead.
- This class is internal; its Version member is only accessible to code within the same assembly, so cross-repo coordination relies on the shared build/packaging process.
- Because `Version` is a `const`, its value is baked into compiled assemblies; updating it requires recompiling all dependents and coordinating updates across both repositories.
- Changes to the version must be performed in sync across both repositories to prevent a mismatch in protocol expectations.
---
@@ -592,13 +546,15 @@ internal static class DirectoryRegistrationErrors
```
This internal static class DirectoryRegistrationErrors serves as a centralized collection of string constants that represent the standard error codes used during the EchoHub server's directory registration workflow. It helps avoid hard-coded literals spread across the codebase and provides a single source of truth for the messages the hub uses to classify and surface registration failures. The constants cover common causes like invalid input, invalid token, host state conflicts, as well as client-side synthetic codes used for status reporting (ProtocolVersionMismatch, MalformedResponse) which are not emitted by the hub.
DirectoryRegistrationErrors is an internal static class that defines a concise set of error-code constants used during directory registration in the EchoHub server. It provides named codes such as `InvalidInput`, `InvalidToken`, `HostAlreadyClaimed`, and `HostConflict` to represent specific failure reasons returned by the server, eliminating scattered string literals and reducing typos. It also includes client-side synthetic codes `ProtocolVersionMismatch` and `MalformedResponse`, which are generated locally for status reporting and are not emitted by the hub.
## Remarks
The class is internal to the server assembly and centralizes canonical error codes for the directory registration subsystem to promote consistent error handling across components. Keeping these strings in one place reduces typos and mismatches in error reporting and mapping. Note that ProtocolVersionMismatch and MalformedResponse are client-side synthetic codes documented here for parity; they are never emitted by the hub.
By centralizing these values, the codebase gains a single source of truth for directory-registration errors, simplifying error handling, testing, and mapping to user-visible messages. It distinguishes between server-disclosed error codes (the first four) and client-side diagnostics (the two synthetic codes) that help with local status reporting without being emitted by the hub.
## Notes
- Not accessible from outside the server assembly; if you need client-visible error codes, expose a separate contract instead.
- Changing any constant's value is a breaking change; external or internal code that relies on the exact string value may fail after the change.
- The constants are compile-time constants; ensure all referencing code is recompiled together to avoid mismatches.
- The two client-side codes (`ProtocolVersionMismatch`, `MalformedResponse`) are for client-only diagnostics and are not emitted by the hub; avoid handling them as server-facing error payloads.
---
@@ -619,28 +575,14 @@ internal record ErrorDetail(string Code, string? Message, JsonElement? Data)
| `Data` | `JsonElement?` | — |
ErrorDetail represents a single error entry that can appear inside a `Response<T>` as part of an API error payload. It carries an error code (Code), an optional human-readable message (Message), and an optional Data payload for error-specific details. The Data field is typed as JsonElement to keep the payload shape flexible, accommodating different errors with varying detail structures (for example, a host-related error might include a ConflictingHosts array). As a record, ErrorDetail benefits from value-based equality and immutability, which makes it a stable, serializable unit for error reporting across API boundaries.
An internal record that represents a single error entry inside a `Response<T>`. The `Code` identifies the error kind, [`Message`](../../EchoHub.Core/Models/Message.cs.md) provides an optional human-readable description, and `Data` carries an optional, loosely-typed payload as a `JsonElement` to accommodate varying error shapes (e.g. host-related errors might carry a list of conflicting hosts).
## Remarks
ErrorDetail's design separates the error signaling (via Code) from the optional payload (Message and Data), enabling clients to react to known codes while optionally surfacing human-readable context or structured details. It works alongside the surrounding `Response<T>` wrapper to assemble a consistent error surface while preserving flexibility in the Data payload. The JsonElement Data keeps the detail shape decoupled from the type system, at the cost of requiring clients to inspect Code before interpreting Data.
## Example
```csharp
using System.Text.Json;
var json = "{\"ConflictingHosts\":[\"host1\",\"host2\"]}";
JsonElement data = JsonSerializer.Deserialize<JsonElement>(json);
var error = new ErrorDetail(
Code: "ConflictingHosts",
Message: "One or more hosts conflict with existing entries.",
Data: data
);
```
This abstraction decouples error signaling from concrete payload schemas by wrapping code, message, and data within a single value. It fits the `Response<T>` pattern by enabling diverse error details to accompany a common envelope, while allowing clients to switch on `Code` to interpret the `Data` payload.
## Notes
- Data is loosely-typed by design; clients should first inspect Code to determine how to interpret Data.
- If Data is null, the consumer should rely on Code and optional Message for context.
- JsonElement is a view into the underlying JsonDocument; if the document is disposed, the Data value becomes invalid. Ensure the originating `JsonDocument` remains alive as long as `ErrorDetail.Data` is accessed.
- If you need a durable payload, consider storing `Data.GetRawText()` or a deserialized DTO instead of keeping the `JsonElement` itself.
---
@@ -672,26 +614,29 @@ internal record RegisterServerDto(
| `ClaimToken` | `string?` | — |
RegisterServerDto is a compact, immutable data transfer object (record) that carries all the information required to register a server in the EchoHub server directory. It groups identity data (Name, Version), optional metadata (Description), network endpoints (Hosts), current user load (UserCount), and classification tags (Tags) into a single value object so callers supply a single payload to the directory service rather than wiring multiple fields through separate calls. The optional ClaimToken supports claim-based authorization when needed.
RegisterServerDto is an internal C# positional record that serves as the single, strongly-typed payload for registering a server with the directory service. It captures the server's identity (``Name``), optional description (``Description``), the collection of host endpoints (``Hosts``), the current user count (``UserCount``), the software version (``Version``), a set of metadata tags (``Tags``), and an optional authentication token (``ClaimToken``). Because it is immutable and passed as a single object, it keeps registration logic clean and reduces parameter clutter across layers.
## Remarks
RegisterServerDto exists to encapsulate the registration data in a single cohesive unit, decoupling the producer from the directory service and enabling consistent validation and persistence. As a record, it provides value-based equality, which helps determine duplicates or idempotent operations across registration attempts.
RegisterServerDto is internal and immutable, which helps ensure a consistent snapshot of registration data as it moves through the directory service. By bundling related fields together, it reduces coupling between components and makes validation, logging, and auditing easier. The nullable fields ``Description`` and ``ClaimToken`` reflect optional aspects of registration; consumers should handle possible nulls and token absence accordingly.
## Example
```csharp
// Example of constructing the payload for registration
var dto = new RegisterServerDto(
Name: "EchoServer-01",
Description: "Primary gateway for region A",
Hosts: new[] { "https://host1.example.com", "https://host2.example.com" },
UserCount: 128,
Version: "2.3.1",
Tags: new[] { "production", "gateway" },
ClaimToken: null
"EchoServer-01",
"Primary gateway",
new string[] { "tcp://host1:1234", "tcp://host2:1234" },
42,
"2.3.1",
new string[] { "gateway", "primary" },
null
);
```
## Notes
- The Hosts and Tags properties are string[] arrays; their contents can be mutated after construction since arrays are mutable. If you need true immutability, consider defensive copies or using a read-only collection type in a different design.
- The DTO does not enforce invariants (e.g., you should ensure `Hosts` is non-empty and `UserCount` is non-negative before registration).
- The type is marked `internal`; outside of its containing assembly, code cannot construct or consume it unless test-friendly tooling like `InternalsVisibleTo` is configured.
---
@@ -711,23 +656,15 @@ internal record RegisterServerResult(Guid ServerId, string? ClaimToken)
| `ClaimToken` | `string?` | — |
An internal, immutable data carrier that represents the outcome of registering a server in EchoHub's directory service. It holds the assigned ServerId and an optional ClaimToken returned alongside the registration result. Callers construct this type at the end of a registration flow to pass both pieces of information together, rather than returning them separately.
RegisterServerResult is an immutable value object that represents the outcome of registering a server. It contains the server's identity (`ServerId`), a `Guid`, and an optional `ClaimToken` (`string?`) that callers may use for subsequent authenticated operations.
## Remarks
Designed to decouple the registration outcome from the service logic and to expose a stable, immutable snapshot of the operation. By using a record, it gains value-based equality and built-in deconstruction, which makes testing and wiring across layers straightforward. The optional ClaimToken acknowledges scenarios where a token is not issued; consumers should handle its absence gracefully.
## Example
```csharp
var serverId = Guid.NewGuid();
var result = new RegisterServerResult(serverId, "token-abc");
// result.ServerId == serverId
// result.ClaimToken == "token-abc"
```
This symbol acts as a focused data carrier between the registration flow and its consumers. By leveraging the `record` construct, it gains value-based equality and built-in immutability, ensuring the result is stable once created. Its `internal` visibility confines the contract to the assembly, underscoring that server registration details are an internal concern of the `ServerDirectoryService`.
## Notes
- ClaimToken is nullable; callers should guard against null before use.
- The type is internal, so it is not part of the public API surface outside its assembly.
- Being a record, it supports deconstruction (e.g., `var (id, token) = result;`) and value-based equality, which aids comparisons and pattern-based usage.
- The `ClaimToken` property can be `null` if no token is issued during registration.
- Treat the `ClaimToken` as sensitive data; avoid logging or persisting it in plain text and only keep it in memory for as long as needed.
- `RegisterServerResult` is immutable; do not mutate its properties after construction. Rely on the record's value semantics when comparing results.
---
@@ -749,72 +686,63 @@ internal record Response<T>(bool IsSuccess, T? Data, ErrorDetail[]? Errors, stri
| `Version` | `string?` | — |
Generic envelope wrapper for directory hub responses. It mirrors the EchoHubSpace contract by wrapping a success indicator, an optional payload, optional errors, and an optional version string into a single, transport-safe object.
Generic, immutable envelope that wraps every directory hub response. It exposes a boolean `IsSuccess`, an optional data payload `Data`, an optional array of `ErrorDetail` in `Errors`, and an optional `Version`. Use `Response<T>` whenever you need a consistent, hub-wide response shape instead of ad-hoc return types: place the operations payload in `Data`, set `IsSuccess`, attach any `Errors` if something went wrong, and optionally include `Version` for compatibility.
## Remarks
This envelope isolates transport concerns from business logic by providing a uniform surface for responses. Callers should always check IsSuccess before using Data, and rely on Errors for details when it is false. Data and Version are nullable, so consumers must guard for nulls and treat Version as optional metadata rather than a payload. The generic T makes this wrapper reusable for any payload.
## Example
```csharp
var response = new Response<string>(true, "directory listing", null, "1.2");
```
By mirroring the `EchoHubSpace` contract, this envelope centralizes response structure and simplifies client and server handling of hub results. The generic parameter `T` lets you wrap any payload while preserving a single, predictable transport form. It also separates business data from transport metadata: callers typically check `IsSuccess` first, then read `Data` or `Errors` accordingly.
## Notes
- When IsSuccess is false, Data may be null; always inspect the Errors collection for failure details.
- The symbol is internal to its assembly; to share the envelope across boundaries, you may need a public abstraction or converter on your side.
- `Data` is nullable; always guard against null when consuming `Data`.
- If `IsSuccess` is false, prefer inspecting `Errors` for failure details rather than using `Data`.
- `Version` is optional and may be omitted; treat it as informational metadata rather than a contract guarantee.
---
## ServerDirectoryService (constructor)
## ConnectWithRetryAsync
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** constructor
> **Kind:** method
```csharp
public ServerDirectoryService(
IConfiguration configuration,
PresenceTracker presenceTracker,
DirectoryClaimStore claimStore,
ILogger<ServerDirectoryService> logger)
private async Task<bool> ConnectWithRetryAsync(HubConnection connection, CancellationToken ct)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `configuration` | `IConfiguration` | — |
| `presenceTracker` | [`PresenceTracker`](PresenceTracker.cs.md) | — |
| `claimStore` | [`DirectoryClaimStore`](DirectoryClaimStore.cs.md) | — |
| `logger` | `ILogger<ServerDirectoryService>` | — |
| `connection` | `HubConnection` | — |
| `ct` | `CancellationToken` | — |
**Returns:** `Task<bool>`
This constructor wires ServerDirectoryService by receiving four dependencies through dependency injection and storing them in private fields. It prepares the service for directory-related operations by providing access to configuration, presence tracking, claim storage, and logging.
Tries to start the provided `HubConnection` and, on failure, retries with a backoff until the `CancellationToken` is cancelled. It returns `true` if `StartAsync` completes successfully; if the operation is cancelled before a successful start, it returns `false`.
## Remarks
The constructor enforces that ServerDirectoryService cannot operate without configuration, presence tracking, claim storage, and a logger, making its dependencies explicit and testable. It sits at the boundary between configuration and domain logic, coordinating the infrastructure pieces that support directory management.
By isolating this retry logic in `ConnectWithRetryAsync`, the surrounding code can rely on a single, consistent startup strategy for the directory hub. It coordinates the backoff via `GetBackoffDelay`, logs each failure with the upcoming delay, and respects cancellation through the provided `CancellationToken`.
## Notes
- Ensure all dependencies are registered in the application's DI container; missing registrations will cause the service resolution to fail at runtime.
- If any dependency requires specific lifetimes (e.g., scoped vs singleton), align them with the composition root to avoid disposal issues or lifetime mismatches.
- The delay cancellation caveat: if the `CancellationToken` is signaled while `Task.Delay` is awaiting, an `OperationCanceledException` propagates, which means the method would surface cancellation rather than returning `false`.
- Logging: on every failed attempt, a warning is logged with the exception and the upcoming delay.
- Dependency: the retry timing depends on `GetBackoffDelay(attempt)`; callers should ensure this method yields a sensible backoff to avoid long startup times.
---
## UserCountMinInterval
## ReconnectBaseDelay
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** field
```csharp
private static readonly TimeSpan UserCountMinInterval = TimeSpan.FromSeconds(1)
private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2)
```
Defines a shared, immutable throttling interval used by ServerDirectoryService to regulate how often user-count related work runs. The field is private, static, and readonly, initialized to TimeSpan.FromSeconds(1). This ensures a consistent cadence and avoids magic numbers scattered through the class; it's consulted wherever the service needs to debounce or rate-limit user-count updates.
The `ReconnectBaseDelay` field defines the starting interval used by the service's reconnection logic. As a private static readonly `TimeSpan` initialized with `TimeSpan.FromSeconds(2)`, it provides a single, immutable baseline for calculating backoff delays during reconnect attempts, without exposing the value publicly. Developers thinking about the backoff strategy should consider this constant as the canonical baseline rather than sprinkling literals throughout the codebase.
## Remarks
By centralizing the timing policy in this single member, the class achieves consistent behavior across all usages and simplifies future adjustments. The static readonly combination guarantees that the interval is computed once at type initialization and remains the same for the lifetime of the application, minimizing race-condition risk when read from multiple threads. Because it is private, external callers cannot bypass or alter the cadence; any changes must go through the class logic and a rebuild.
Public exposure is avoided by keeping this value private, but the field still has architectural significance: it centralizes the base delay for the reconnect workflow within `ServerDirectoryService`, ensuring consistent timing across all retry scenarios and simplifying future tuning.
## Notes
- This is not a compile-time constant; it is evaluated at type initialization and cannot be reassigned afterwards.
- External configuration at runtime is not possible unless the class provides a mechanism to override it.
- If cadence needs to vary by environment or load, consider externalizing to configuration or making the interval configurable instead of editing code.
- Changing the private static readonly `TimeSpan` will change the base backoff used by all reconnection attempts in `ServerDirectoryService`; there is no per-call override for this baseline. If configurability is required, expose a parameter or configuration option rather than modifying this field.
---
@@ -7,18 +7,20 @@
```mermaid
%%{init: {'theme':'base','themeVariables':{'background':'#faf7ef','primaryColor':'#f0e2c2','primaryTextColor':'#1f2840','primaryBorderColor':'#8a7548','secondaryColor':'#d9efec','secondaryBorderColor':'#1d8a80','secondaryTextColor':'#1f2840','tertiaryColor':'#f2ebd8','tertiaryBorderColor':'#8a7548','tertiaryTextColor':'#1f2840','lineColor':'#1d8a80','titleColor':'#1f2840','fontSize':'14px','edgeLabelBackground':'#faf7ef','clusterBkg':'#f2ebd8','clusterBorder':'#8a7548','actorBkg':'#f0e2c2','actorBorder':'#8a7548','actorTextColor':'#1f2840','actorLineColor':'#8a7548','signalColor':'#1d8a80','signalTextColor':'#1f2840','activationBkgColor':'#d9efec','activationBorderColor':'#1d8a80','noteBkgColor':'#f2ebd8','noteBorderColor':'#8a7548','noteTextColor':'#1f2840','labelBoxBkgColor':'#f0e2c2','labelBoxBorderColor':'#8a7548','labelTextColor':'#1f2840','transitionColor':'#1d8a80','transitionLabelColor':'#1f2840','stateLabelColor':'#1f2840','altBackground':'#f2ebd8'}}}%%
flowchart TB
A[ServerLogsService ReadBacklog]
A --> B[Resolve ServerLogsOptions LogDirectory]
B --> Dir{Directory exists}
Dir -->|Yes| C[Find newest file matching LogFilePattern]
Dir -->|No| E[Return empty list]
C --> F{Newest file found}
F -->|No| E
F -->|Yes| G[Open newest file with FileShare ReadWrite Delete then seek to tail when stream longer than TailReadBytes]
G --> H[Read to end and split by newline into lines]
H --> I[Group lines into LogBacklogEntry with skipLeadingContinuations if seeked and limit from ServerLogsOptions BacklogLines]
I --> J[Return list of LogBacklogEntry]
A -.-> E
ServerLogsService["Start ReadBacklog()"]
ServerLogsOptions["Load ServerLogsOptions (LogDirectory, LogFilePattern, BacklogLines)"]
LogBacklogEntry["Return IReadOnlyList of LogBacklogEntry (backlog or empty)"]
ServerLogsService -->|"Resolve full path of LogDirectory"| ServerLogsOptions
ServerLogsOptions -->|"If directory does not exist -> return empty list"| LogBacklogEntry
ServerLogsOptions -->|"Find newest file matching LogFilePattern (order by LastWriteTimeUtc)"| ServerLogsService
ServerLogsService -->|"If no newest file -> return empty list"| LogBacklogEntry
ServerLogsService -->|"Open FileStream(newest.FullName, FileMode.Open, FileAccess.Read, FileShare ReadWrite and Delete)"| ServerLogsOptions
ServerLogsOptions -->|"Determine if stream.Length > TailReadBytes (seeked)"| ServerLogsService
ServerLogsService -->|"If seeked -> stream.Seek(-TailReadBytes, SeekOrigin.End)"| ServerLogsOptions
ServerLogsOptions -->|"Read remainder with StreamReader and split into lines"| ServerLogsService
ServerLogsService -->|"Call GroupIntoEntries(lines, skipLeadingContinuations: seeked, BacklogLines)"| LogBacklogEntry
ServerLogsService -->|"On any exception -> return empty list"| LogBacklogEntry
```
## Contents
@@ -37,37 +39,16 @@ public sealed class ServerLogsService
```
Provides the logic needed to present a read-only "live logs" room: it exposes the room identity, the role-based gate for who may join, and a best-effort reader that returns the most recent log entries from the active rolling Serilog file. Use this service when you need to show a live, read-only backlog of server log entries (rather than storing log lines as chat messages).
Provides utilities for exposing a read-only, live server log room: it knows the room identity and access gate and can read the tail of the current rolling log file into `LogBacklogEntry` items for display. Use `ServerLogsService` when you need to determine whether a channel is the configured logs room, check whether a role may view logs, or retrieve a best-effort backlog snapshot from the most recent log file (rather than relying on persisted messages).
## Remarks
This class centralizes the concerns required for a live log room: determining the configured room name and sender, enforcing the minimum role required to view logs, and extracting a focused backlog from the current log file on disk. It treats the file sink as the single source of truth (Serilog keeps the file open and rolls it), reads only the tail of the newest file up to a bounded byte size, and groups raw lines into logical entries by detecting timestamp-prefixed lines. ReadBacklog is resilient: any I/O or parsing problem yields an empty backlog rather than propagating an error.
## Example
```csharp
// 'options' is an existing ServerLogsOptions instance configured for the server.
var service = new ServerLogsService(options);
// Check whether a user role may view/join the live logs room
if (service.CanView(userRole))
{
// Read the most recent backlog entries (best-effort; may be empty on error)
var backlog = service.ReadBacklog();
foreach (var entry in backlog)
{
// LogBacklogEntry exposes a timestamp and the concatenated content
Console.WriteLine($"{entry.Timestamp:O} {entry.Content}");
}
}
// Room identity helpers
var isLogs = service.IsLogsChannel("logs");
var sender = ServerLogsService.SenderName; // "server"
```
`ServerLogsService` centralizes the concerns around presenting live server logs without persisting log lines as messages. It uses the configured [`ServerLogsOptions`](../../Config/ServerLogsOptions.cs.md) to decide whether logging is enabled, to match a channel name (`NormalizedRoomName`) in `IsLogsChannel`, and to gate access with `CanView` based on `MinRole`. For backlog retrieval, `ReadBacklog` opens the newest file matching `LogFilePattern` in `LogDirectory` with `FileShare.ReadWrite | FileShare.Delete` (to cooperate with a rolling sink like Serilog), reads up to `TailReadBytes` from the file end, and converts raw lines into `LogBacklogEntry` instances via `GroupIntoEntries`. The `GroupIntoEntries` method is public to allow unit testing of the timestamp-based grouping logic.
## Notes
- ReadBacklog swallows all exceptions and returns an empty list on any I/O problem; callers must tolerate an empty backlog as a sign of transient failure or missing files.
- To avoid reading an arbitrarily large file, the reader seeks to the last TailReadBytes bytes; that can start the scan mid-entry, so the grouping logic optionally drops leading continuation lines when the tail was seeked.
- The FileStream is opened with FileShare.ReadWrite | FileShare.Delete because the Serilog file sink typically keeps the file open for writing and may roll it; the service reads concurrently without taking exclusive locks.
- `IsLogsChannel` calls `Trim()` on the provided `channelName`; passing `null` will throw a `NullReferenceException` — callers should ensure they pass a non-null string or guard accordingly.
- `ReadBacklog` is intentionally best-effort: it catches all exceptions and returns an empty list on any I/O or parsing failure. This prevents join failures but can hide filesystem problems; monitor logs or surface errors elsewhere if you need diagnostics.
- The grouping logic depends on lines that start with the timestamp format defined by `TimestampFormat`. If your log sink uses a different timestamp template, `GroupIntoEntries` will treat those timestamped lines as continuations and entries will be merged incorrectly.
- When the newest file is larger than `TailReadBytes`, `ReadBacklog` seeks into the file and sets `skipLeadingContinuations` so a partial entry at the seek boundary is dropped. This is deliberate to avoid presenting truncated entries but means very long single entries near the file end can be partially excluded.
---
@@ -87,13 +68,12 @@ public record LogBacklogEntry(DateTimeOffset Timestamp, string Content)
| `Content` | `string` | — |
LogBacklogEntry is a tiny, immutable data container that models a single backlog item read from the server log file. It captures the timestamp of the original log line via Timestamp and the associated log text in Content, which may include the initial line plus any continuation lines (such as exception stack traces) that followed it. Use this type when you need to treat a complete backlog segment as a unit, instead of handling raw lines individually; its especially helpful for grouping, displaying, or analyzing backlog entries after parsing.
LogBacklogEntry is an immutable value object that captures a backlog entry read from the log file. It consists of a timestamp (`Timestamp`) and the associated content (`Content`), representing the first line of the backlog entry plus any continuation lines (such as exception stack traces) that followed it.
## Remarks
Because LogBacklogEntry is a record, it benefits from value-based equality and concise deconstruction, making it easy to compare backlog entries or to extract the fields in pattern-matching. The Content field holds a multi-line string that includes the initial line and any continuation text that followed it; consumers should be aware that the entry may span multiple lines. This type is commonly produced by the server log reader (e.g., ServerLogsService) when assembling backlog entries from the log file, serving as a stable data carrier between parsing and presentation layers.
Because this is a `record`, it provides value-based equality and deconstruction, which simplifies comparing backlog entries and passing them through the processing pipeline without mutation. It acts as a lightweight data carrier that decouples raw log parsing from higher-level log aggregation or display concerns, allowing the server logs service to operate on coherent chunks of log data.
## Notes
- When collecting backlog lines, ensure that each entry groups the initial timestamped line with its subsequent continuation lines exactly once; splitting or merging entries incorrectly can corrupt the log's temporal grouping.
- The `Content` may be large and contain newline characters representing multi-line stack traces; treat it as an opaque blob when storing or transmitting.
---
@@ -8,12 +8,12 @@ public sealed class ServerLogsSink : ILogEventSink
```
ServerLogsSink is a Serilog sink that feeds the live log room by buffering log events in a bounded channel and exposing them to the streaming pipeline without persisting them. It enforces a minimum log level, filters out internal sources to avoid feedback loops, and writes accepted events into a single-reader queue consumed by the live broadcast path. This sink thus serves as a lightweight, non-persistent conduit for real-time visibility of logging activity.
ServerLogsSink is a Serilog sink that buffers recent `LogEvent`s into a bounded, single-reader [`Channel<LogEvent>`](../../../EchoHub.Core/Models/Channel.cs.md) and exposes a `Reader` for the live log streaming path. It enforces a minimum level via the `ServerLogsOptions.MinLevel` and filters out internal streaming sources using `ExcludedSourcePrefixes` to prevent a feedback loop where a log would broadcast and re-log itself.
## Remarks
The sink decouples log emission from the live broadcast pathway, providing backpressure via a bounded channel (capacity 512) with drop-oldest semantics to prevent unbounded memory growth. Internal pipeline events are culled by inspecting the SourceContext and excluding known internal prefixes, which prevents the log → broadcast → log feedback loop. By not writing to a database, the component prioritizes timely visibility for operators and clients over long-term auditing.
Serving as a bridge between Serilog and the live log room, `ServerLogsSink` deliberately does not write to a database; events are queued for streaming consumption by [`ServerLogsStreamService`](ServerLogsStreamService.cs.md). The channel is sized with a capacity of 512 and uses `BoundedChannelFullMode.DropOldest` with `SingleReader = true`, which preserves the most recent events while avoiding unbounded memory growth. The internal filtering — checking `Constants.SourceContextPropertyName` and skipping any source that starts with entries in `ExcludedSourcePrefixes` — protects against recursive logging from the streaming infrastructure.
## Notes
- When the channel is full, TryWrite may return false and the log event will be dropped, ensuring the application does not stall due to logging backpressure.
- The channel is configured with SingleReader = true, so there is a single consumer in the streaming path; additional readers would not receive the full event sequence.
- Only events that pass the MinLevel filter and do not originate from excluded internal sources are enqueued for broadcast.
- The bound buffer capacity is 512 and uses `BoundedChannelFullMode.DropOldest`; when full, the oldest buffered events are dropped to make room for newer ones.
- `Emit` uses `TryWrite` and ignores the return value; under load, logs may be dropped if the consumer lags behind.
- Internal sources are excluded by prefix; adding new internal namespaces requires updating `ExcludedSourcePrefixes` to avoid self-logging.
@@ -8,41 +8,71 @@ public sealed class ServerLogsStreamService : BackgroundService
```
Streams queued log events to the live log room as ephemeral SignalR messages, never persisting them to the IRC gateway or a database, and with a guard against introducing new logging from the streaming path itself. It runs as a background service, ensuring the destination room exists before sending each event and recreating it if needed, so live viewers can always join the stream without manual intervention.
Description:
Streams queued log events to the live log room as ephemeral SignalR messages, ensuring the room exists before each publish and recreating it on demand if it was removed. The streaming path is intentionally non-logging to avoid recursive logging and potential message sprawl. Use this service when you want real-time, in-memory broadcasts of server log events to connected clients without persisting those lines to a database.
## Remarks
This symbol acts as a thin, resilient bridge between the server-side log sink and the real-time chat hub. It separates the streaming path from log persistence, enforcing a no-log-from-stream policy to avoid feedback loops where streaming would itself generate more log lines. By lazily resolving the hub context, it avoids tight coupling during service construction and ensures the ChatHub context is available when streaming begins. The class also enforces room existence in a lightweight, interval-bounded way to tolerate transient room removal without blocking the live stream.
This symbol acts as the dedicated conduit between the server-side log sink and the live chat hub. It coordinates with a channel service to guarantee the existence of the log room (and to recreate it if it disappears), throttling such housekeeping to at most once every 15 seconds to avoid excessive churn. Messages are encrypted before transmission and delivered to the room group via a lazily-resolved `HubContext`, which is intentionally retrieved only after the host has fully configured its DI graph. The static `Format` helper is public for tests, enabling validation of the exact, client-rendered payload without instantiating the streaming pipeline. This separation keeps streaming concerns isolated from the rest of the logging infrastructure and prevents per-event logging from leaking into the stream itself.
## Notes
- The streaming path must never emit logs of its own activity; per-event logging is explicitly suppressed to prevent cascading streams.
- TryEnsureRoomAsync re-checks the room at most once per EnsureInterval (15 seconds) to balance responsiveness with avoiding repeated recreation attempts.
- Messages are formatted and then encrypted before sending; the client receives an encrypted payload and is responsible for decrypting it, mirroring the design that prioritizes privacy and transport safety. The public Format method is exposed for tests, reflecting a desire to validate formatting behavior in isolation.
- If room recreation fails, events are streamed to a group with no members until the next interval, ensuring that the streaming pipeline remains non-blocking and resilient to transient failures.
- The service reads from `ServerLogsSink.Reader` and, for each event, ensures the destination room exists, formats the log event, encrypts the payload, and sends it to the [`ChatHub`](../../Hubs/ChatHub.cs.md) group corresponding to the room name.
- Room creation/verification is throttled by `EnsureInterval` (15 seconds) to avoid excessive calls during high-frequency log bursts; failed attempts are silently retried on the next interval.
- The streaming path is guarded to swallow non-cancellation exceptions to prevent re-entrancy into the logging pipeline.
- The `Format` method is intentionally public for testability, and truncates messages to `HubConstants.MaxMessageLength` with an ellipsis when necessary.
## Example
- Not included: non-obvious usage from the signature; the behavior is exercised through the background streaming loop and the TryEnsureRoomAsync room-recovery logic. See the source for exact flow and state transitions.
```csharp
// The example demonstrates formatting a log event for client rendering and ensuring the message is wrapped for transport.
var logEvent = new LogEvent(/* parameters omitted for brevity */);
var payload = ServerLogsStreamService.Format(logEvent);
// payload is then embedded in a [`MessageDto`](../../../EchoHub.Core/DTOs/ChatDtos.cs.md), encrypted, and sent to the SignalR hub.
```
## Dependencies
- SignalR, BackgroundService, MessageDto, StringBuilder, TimeSpan, DateTimeOffset, Reader, Guid
## Dependency APIs (verified signatures)
The REAL, parser-verified API surface of this symbol's collaborators:
- MessageDto (src/EchoHub.Core/DTOs/ChatDtos.cs)
- Reader (src/EchoHub.Server/Services/ServerLogs/ServerLogsSink.cs)
- ServerLogsService (src/EchoHub.Server/Services/ServerLogs/ServerLogsService.cs)
- SenderName, RoomTopic, TimestampFormat, TailReadBytes
- ServerLogsService(ServerLogsOptions options)
- Options, IsLogsChannel, CanView(ServerRole)
- ReadBacklog(), GroupIntoEntries(`IReadOnlyList<string>`, bool, int)
- TryParseTimestamp(string, out DateTimeOffset, out string)
- HubContext (src/EchoHub.Server/Services/ServerLogs/ServerLogsStreamService.cs)
- HubConstants (src/EchoHub.Core/Constants/HubConstants.cs)
- ChatHubPath, DefaultChannel, IrcConnectionIdPrefix, DefaultHistoryCount, MaxMessageLength
- MaxImageSizeBytes, MaxAudioFileSizeBytes, MaxFileSizeBytes, MaxAvatarSizeBytes
- MaxMessageNewlines, MaxAttachmentsPerMessage, MaxConsecutiveNewlines
- record [`MessageDto`](../../../EchoHub.Core/DTOs/ChatDtos.cs.md) (`src/EchoHub.Core/DTOs/ChatDtos.cs`)
- property `Reader` (`src/EchoHub.Server/Services/ServerLogs/ServerLogsSink.cs`)
- class [`ServerLogsService`](ServerLogsService.cs.md) (`src/EchoHub.Server/Services/ServerLogs/ServerLogsService.cs`)
- field `string SenderName`
- field `string RoomTopic`
- field `string TimestampFormat`
- field `int TailReadBytes`
- `ServerLogsService(ServerLogsOptions options)`
- property `ServerLogsOptions Options`
- `bool IsLogsChannel(string channelName)`
- `bool CanView(ServerRole role)`
- `IReadOnlyList<LogBacklogEntry> ReadBacklog()`
- `IReadOnlyList<LogBacklogEntry> GroupIntoEntries(IReadOnlyList<string> lines, bool skipLeadingContinuations, int maxEntries)`
- `bool TryParseTimestamp(string line, out DateTimeOffset timestamp, out string rest)`
- property `HubContext` (`src/EchoHub.Server/Services/ServerLogs/ServerLogsStreamService.cs`)
- class [`HubConstants`](../../../EchoHub.Core/Constants/HubConstants.cs.md) (`src/EchoHub.Core/Constants/HubConstants.cs`)
- field `string ChatHubPath`
- field `string DefaultChannel`
- field `string IrcConnectionIdPrefix`
- field `int DefaultHistoryCount`
- field `int MaxMessageLength`
- field `int MaxImageSizeBytes`
- field `int MaxAudioFileSizeBytes`
- field `int MaxFileSizeBytes`
- field `int MaxAvatarSizeBytes`
- field `int MaxMessageNewlines`
- field `int MaxAttachmentsPerMessage`
- field `int MaxConsecutiveNewlines`
- …and 7 more member(s) not shown
## Symbol To Document
- Name: ServerLogsStreamService
- Name: `ServerLogsStreamService`
- Kind: class
- File: src/EchoHub.Server/Services/ServerLogs/ServerLogsStreamService.cs
- Language: csharp
- ID: fe54ac96-642e-4dbe-af25-3d2559e01299
- File: `src/EchoHub.Server/Services/ServerLogs/ServerLogsStreamService.cs`
- Language: `csharp`
- ID: 24389698-e5ce-4385-b392-f34e08edf31f
@@ -8,13 +8,13 @@ public class SignalRBroadcaster : IChatBroadcaster
```
Implements IChatBroadcaster to deliver chat events to SignalR-connected clients (via an `IHubContext<ChatHub, IEchoHubClient>`). Use this implementation when the application should push messages, presence updates, channel changes and moderation events to SignalR clients; it routes events to groups, specific clients, or all connected SignalR clients as appropriate.
Broadcasts chat events to connected SignalR clients and adapts the generic [`IChatBroadcaster`](../../EchoHub.Core/Contracts/IChatBroadcaster.cs.md) contract to an `IHubContext<ChatHub, IEchoHubClient>`-backed implementation. Use `SignalRBroadcaster` when you need server-side broadcasting of messages, presence updates, channel lifecycle events and administrative actions to SignalR clients; the class centralizes SignalR-specific delivery details so callers can work with the [`IChatBroadcaster`](../../EchoHub.Core/Contracts/IChatBroadcaster.cs.md) abstraction.
## Remarks
This class adapts the generic chat-broadcasting contract to SignalR: it resolves an IHubContext lazily from an IServiceProvider and uses the ChatHub/IEchoHubClient surface to send notifications. It cooperates with a PresenceTracker to map channel lists to active SignalR connection ids and intentionally filters out connections belonging to the IRC gateway. The implementation keeps broadcasting logic simple (group vs. all vs. specific clients) and relies on SignalR's client invocation Tasks for async behavior.
`SignalRBroadcaster` resolves and caches an `IHubContext<ChatHub, IEchoHubClient>` lazily from the provided `IServiceProvider`, and uses a [`PresenceTracker`](PresenceTracker.cs.md) to map channels to live connection IDs. It implements the [`IChatBroadcaster`](../../EchoHub.Core/Contracts/IChatBroadcaster.cs.md) surface by translating high-level events (message send, user joined/left, status changes, channel updates, kicks/bans, deletes, nukes, errors, and forced disconnects) into SignalR calls on `Clients.Group`, `Clients.All`, `Clients.Clients` and `Clients.Client`. The implementation intentionally treats connection IDs that start with the `irc-` prefix as non-SignalR (they are handled by a separate IRC gateway), so several methods either filter those IDs out or no-op for them.
## Notes
- The implementation treats connection IDs prefixed with "irc-" as non-SignalR (IRC gateway) and excludes or ignores those ids in several methods; callers must follow that convention if mixing IRC and SignalR connections.
- SendMessageToChannelAsync intentionally ignores the excludeConnectionId parameter (comment: SignalR clients render their own message echo). For selective exclusion of a SignalR connection use SendUserJoinedAsync (which excludes non-IRC ids) or other targeted methods that call GroupExcept/Clients.
- SendUserStatusChangedAsync will return Task.CompletedTask when no SignalR connections are found for the provided channels — callers should expect no-op behavior in that case.
- HubContext is cached in a private field after first resolution from IServiceProvider; the lazy resolution avoids constructor-time resolution (useful to prevent dependency cycles) and subsequent accesses reuse the same IHubContext instance.
- The `excludeConnectionId` parameter is ignored by `SendMessageToChannelAsync` (the comment in-source explains the IRC exclusion only applies to the IRC gateway because SignalR clients render their own broadcast echo). Callers expecting the exclude behavior for SignalR clients should not rely on it for this method.
- Several methods filter out connection IDs that start with `irc-` (for example `SendUserStatusChangedAsync` and `ForceDisconnectUserAsync`); this convention must be followed by any component that produces or stores mixed connection IDs, otherwise intended recipients may be missed or IRC gateways may receive inappropriate signals.
- `HubContext` is resolved once via `IServiceProvider.GetRequiredService<IHubContext<ChatHub, IEchoHubClient>>()` and cached in a private field. If the application's DI configuration does not provide that service the call will throw at first use; caching avoids repeated resolution but means any change in the resolved instance after first access will not be observed.
- Methods return the `Task` returned by SignalR calls directly; any exceptions thrown by SignalR delivery will propagate to the caller of the [`IChatBroadcaster`](../../EchoHub.Core/Contracts/IChatBroadcaster.cs.md) method.
@@ -19,17 +19,15 @@ public sealed class SpamGuard
```
An in-memory, per-user spam protection component used by the server to enforce rate limits and duplicate-content rules across all ingress protocols (SignalR, IRC, etc.). SpamGuard centralizes checks for message sends, channel joins and channel creations so the same limits apply regardless of how a user interacts with the system. Its state is process-local (not persisted) and it delegates configuration to the supplied SpamOptions; the Enabled property exposes whether checks are active.
In-memory, per-user spam protection consulted by the server-side ingress points (for example [`ChatService`](ChatService.cs.md) for messages and joins, and [`ChannelService`](ChannelService.cs.md) for channel creation). Reach for `SpamGuard` when you need a lightweight, process-local policy that enforces rate limits, duplicate-message checks, and simple escalation (auto-mute) without persisting state or inspecting decrypted content.
## Remarks
SpamGuard exists to provide a single place for applying and counting spam-related events so different services (for example ChatService for messages/joins and ChannelService for channel creation) share the same view of a user's recent activity and violations. It keeps compact per-user state (queues of timestamps and a small duplicate-detection cache) and prunes stale entries lazily to avoid unbounded memory growth on busy servers. The class performs all checks under a private lock, so callers do not need to synchronize access; moderators (role >= ServerRole.Mod) are exempted from checks.
`SpamGuard` centralizes cross-protocol ingress throttling so SignalR, IRC, and other entry points share the same limits and violation tracking. State is stored only in-process (the private `_users` dictionary) and is pruned lazily (see `PruneThreshold` and `StaleAfter`) to avoid unbounded growth on busy servers. It operates on content the server already has (so for end-to-end encrypted rooms this is ciphertext) and does not perform decryption. Staff users bypass the guard (`role >= ServerRole.Mod`), rejections are recorded as violations, and repeated rejected messages inside the configured violation window can escalate to `SpamVerdictKind.AutoMute` (the escalation is evaluated only when a message is rejected).
## Notes
- State is process-local and not persisted: restarts or multi-process deployments will reset per-user counters; auto-mutes are recorded in the normal mute store (outside this class).
- Time sources default to DateTimeOffset.UtcNow but can be overridden via the optional nowOverride parameter (useful for deterministic testing).
- Flood detection counts every attempt (including retries), and uses a sliding window configured via SpamOptions (Prune before enqueueing and compare against MaxMessagesPerWindow).
- Duplicate detection compares trimmed, case-insensitive content to the immediately previous message only (back-to-back duplicates); RepeatCount is incremented for consecutive repeats and compared to MaxDuplicateMessages.
- Only rejected actions are recorded as violations for escalation; a non-rejected (clean) message cannot by itself trigger an auto-mute. The escalation logic (violations -> SpamVerdictKind.AutoMute) is evaluated only on rejected messages.
- State is process-local and not persisted: `SpamGuard` does not provide global or cross-instance enforcement. On a multi-server deployment, limits and violation histories are not shared between processes.
- Duplicate detection uses a simple normalization (`Trim()` + `ToLowerInvariant()`): whitespace differences and casing are ignored when comparing `content` to `UserState.LastContent`; `RepeatCount` is reset when normalized content changes.
- All checks run under the internal `Lock` (`lock (_lock)`), so `SpamGuard` is thread-safe but its callers may observe brief blocking under contention; where tests or deterministic timing are needed, use the `nowOverride` parameter to supply a fixed time.
---
@@ -53,21 +51,13 @@ public readonly record struct SpamVerdict(SpamVerdictKind Kind, string? Reason =
| `MuteDuration` | `TimeSpan` | `default` |
SpamVerdict is an immutable value-type that conveys the outcome of a spam check. It carries a Kind from SpamVerdictKind to describe the verdict, an optional Reason for explaining the result, and a MuteDuration indicating how long to suppress further messages when applicable. Defined as a readonly record struct, it benefits from value-based equality and guarantees immutability across boundaries. A convenient static instance, SpamVerdict.Allowed, represents the common case where a message passes spam checks without penalty.
SpamVerdict is an immutable value-type that conveys the outcome of a spam check. It aggregates the verdict kind (`SpamVerdictKind`), an optional `Reason` for extra context, and a `MuteDuration` that can specify how long to mute the sender when appropriate. A single, shared instance `SpamVerdict.Allowed` is provided for the common case where no action is needed, enabling callers to express acceptance without allocating a new structure.
## Remarks
Because the verdict is packaged as a single object, this abstraction lets the rest of the system reason about spam results without ad-hoc boolean flags scattered through the code. It fits into the SpamGuard workflow by serving as a single, transportable payload that downstream components can inspect via Kind and optionally read the Reason or respect the MuteDuration. The immutability of the type helps prevent accidental mutations once a verdict has been created.
## Example
```csharp
// Common usage: treat messages as allowed by the spam guard
var verdict = SpamVerdict.Allowed;
```
SpamVerdict is a `readonly record struct`, which gives it value-based equality, structural deconstruction, and immutability. This design keeps spam-check results small and cheap to pass across boundaries, while centralizing how verdicts are represented and interpreted by the rest of the system.
## Notes
- Reason is nullable; when present, it should be used for diagnostics or logs rather than for control flow. If you need extra context, supply Reason; otherwise leave it null.
- MuteDuration defaults to TimeSpan.Zero. To mute a user or channel for a period, provide a non-zero duration.
- SpamVerdict.Allowed is a convenient singleton for the common allowed case, but it does not encode a Reason or a non-zero MuteDuration. If you need those metadata, construct a new SpamVerdict explicitly.
- The `Reason` is optional; code must account for `null` when presenting or logging context.
---
@@ -86,14 +76,12 @@ public enum SpamVerdictKind
```
SpamVerdictKind enumerates the possible outcomes of a spam evaluation. It is used by enforcement logic to decide whether to allow, reject, or mute a user based on the spam check; AutoMute indicates the caller should apply a timed mute.
The `SpamVerdictKind` enum encapsulates the outcome of a spam policy evaluation performed by the `SpamGuard`. It is used to drive downstream behavior without embedding policy logic in callers: `Allowed` means the action may proceed, `Rejected` means the action is blocked, and `AutoMute` signals that the user has crossed the violation threshold — the caller should apply a timed mute.
## Remarks
By centralizing these verdicts, the codebase can route enforcement consistently without scattering string literals or magic numbers. The AutoMute value communicates a specific consequence (a timed mute) that callers should implement, decoupling the decision from the actual enforcement mechanism. This abstraction helps evolve spam-policy over time while keeping evaluation and enforcement loosely coupled.
This enum separates policy evaluation from enforcement, allowing a single, centralized decision point at the boundary of spam checks. Downstream code can switch on the verdict to implement appropriate behavior; the exact duration and rules of a timed mute are defined elsewhere and are not baked into this type.
## Notes
- AutoMute carries no duration or scope; the enforcement layer must supply the mute length and target(s).
- There is no payload attached to the verdict; if more context is needed (e.g., risk score, user ID), pass it separately alongside the verdict.
- Be mindful when updating this enum; adding values requires updating all readers to handle new cases.
- The duration of an auto mute is not encoded in the enum; callers must resolve duration from configuration or a separate policy engine.
---
@@ -18,16 +18,10 @@ public sealed class ServerStatsCollector
```
ServerStatsCollector is a thread-safe, in-memory accumulator for server activity counters that have no natural database timestamp (connections, disconnections, kicks, bans, and peak concurrency). It updates counters via lock-free increments and uses a periodic SnapshotAndReset to emit a windowed StatsCounters and prepare the next window, including resetting the peak to the current online count.
The ServerStatsCollector is a thread-safe, in-memory accumulator for server-activity counters that have no natural database timestamp to query after the fact—such as session connects/disconnections, moderation actions, and peak concurrent users. It exposes methods to record connections, disconnections, kicks, and bans, and maintains a running, lock-free estimate of the current peak online. A periodic stats-reporting job calls SnapshotAndReset to atomically capture and reset the windows counters, seeding the next windows peak with the provided online count. It is registered as a singleton and is designed to be updated from hot paths like connect/disconnect.
## Remarks
Because it is registered as a singleton, multiple threads can record events without blocking. The class uses Interlocked and Volatile to implement a lock-free maximum-tracking algorithm for PeakOnline; SnapshotAndReset atomically drains all counters and resets PeakOnline to the provided onlineNow, which defines the starting point for the next window. This design favors low-latency updates in hot paths while deferring aggregation to the reporting window.
## Notes
- The next window's PeakOnline baseline is reset to the supplied onlineNow; if that baseline is lower than the actual concurrency at snapshot time, the subsequent peak may undercount.
- SnapshotAndReset resets the per-window counters to zero (except PeakOnline, which is reset to onlineNow); ensure you call it on the cadence that matches your reporting window to align with dashboards.
Architecturally, it provides a low-latency in-memory sink that decouples event counting from persistence, enabling a single, atomic snapshot per reporting window for the servers activity data. The snapshot resets all counters and optically seeds the next windows peak with the current online count, maintaining continuity of peak tracking across windows.
---
@@ -55,21 +49,14 @@ public readonly record struct StatsCounters(
| `PeakOnline` | `int` | — |
StatsCounters is an immutable snapshot of the counters held by ServerStatsCollector. It captures the total connections, disconnections, kicks, bans, and the peak online count at a single moment, enabling safe sharing and logging without mutating the underlying counters.
StatsCounters is an immutable snapshot of the counters held by `ServerStatsCollector`. It records the current values of the counters `Connections`, `Disconnections`, `Kicks`, `Bans`, and the peak online figure `PeakOnline` at the moment of creation. Use this type when you need a read-only view of these statistics or to pass them between components without exposing mutable state.
## Remarks
StatCounters uses a readonly record struct to provide value semantics, meaning two instances with the same values compare equal and it can be passed by value without side effects. It is intended to be produced by the ServerStatsCollector and consumed by telemetry, dashboards, or loggers that need a stable view of current activity. Because it is immutable, readers can snapshot and transport it across threads without additional synchronization concerns.
By design, `StatsCounters` decouples consumers from the mutable internal state of `ServerStatsCollector`, offering a stable, shareable view of statistics. As a `readonly record struct`, it provides value-based equality and cheap copies, ensuring a snapshot can be produced and transported without synchronization concerns.
## Example
```csharp
// Create a snapshot of current counters
var snapshot = new StatsCounters(Connections: 1024, Disconnections: 64, Kicks: 3, Bans: 0, PeakOnline: 128);
// Deconstruct to access individual values
var (connections, disconnections, kicks, bans, peakOnline) = snapshot;
```
## Dependencies
- ServerStatsCollector
## Notes
- This type is immutable; you cannot modify its fields after construction. If you need an updated view, obtain a new `StatsCounters` from the collector.
- Copying a `StatsCounters` instance is cheap because it is a value type, making it safe to pass across threads or components without locking.
- A snapshot reflects the state at the moment it was created; subsequent updates to the collector will not affect already-captured instances.
---
@@ -8,12 +8,11 @@ public sealed class ServerStatsReportService : BackgroundService
```
ServerStatsReportService is a background task that periodically snapshots server activity over the current reporting window, logs the snapshot as pretty-printed JSON (visible in the live server-logs room), and persists a ServerStatsReport to the database for historical trend analysis. The cadence and retention are controlled by StatsOptions; if IntervalHours is non-positive the service uses a 6-hour default.
Background service that periodically snapshots server activity over a rolling window, logs a pretty-printed JSON snapshot (which surfaces in the live server-logs room), and persists the results to the database for historical trends. It reads cadence and retention from [`StatsOptions`](../../Config/StatsOptions.cs.md) and coordinates with [`PresenceTracker`](../PresenceTracker.cs.md), [`ServerStatsCollector`](ServerStatsCollector.cs.md), and [`EchoHubDbContext`](../../Data/EchoHubDbContext.cs.md) to compute windowed metrics such as messages sent, active members, attachments uploaded, and user counts.
## Remarks
To achieve this, the service reads the online user count from PresenceTracker, captures in-memory statistics from ServerStatsCollector, and then opens a scoped EchoHubDbContext to compute metrics such as messages sent, active members, files uploaded, and new users within the reporting window. A fresh DI scope is created per report to ensure proper EF Core lifetimes and isolation between reports. The reporting window is defined by _periodStart and periodEnd to align live counters with database-derived metrics, ensuring the report reflects the same time span across in-memory and persisted data. The pretty-printed JSON log enhances operational visibility by surfacing structured data in the logs.
This symbol acts as an orchestration point between live state, in-memory counters, and durable storage to provide a stable, windowed view of server activity. It builds a [`ServerStatsReport`](../../../EchoHub.Core/Models/ServerStatsReport.cs.md) for each interval and uses a dedicated scope to query [`EchoHubDbContext`](../../Data/EchoHubDbContext.cs.md), ensuring isolation from other requests. By anchoring the window to `_periodStart` and `periodEnd`, it aligns in-memory counters with database-derived counts to avoid drift.
## Notes
- The background loop honors cancellation by awaiting Task.Delay with the provided CancellationToken and catching OperationCanceledException to exit promptly.
- IntervalHours is validated: non-positive values fall back to 6 hours, and the interval is floored at 1 second to avoid a spinning loop.
- Each report uses its own DbContext scope (via _scopeFactory.CreateScope()) to query the database and persist the resulting ServerStatsReport, ensuring clean lifetimes and minimal cross-report contention.
- The interval is computed from `StatsOptions.IntervalHours`; non-positive values default to 6 hours and the interval is clamped to at least 1 second to prevent a runaway loop.
- Restarting the service resets the reporting window; data prior to the restart belongs to the previous period and will not be included in the new interval unless recalculated by the next run.
@@ -8,68 +8,31 @@
```mermaid
%%{init: {'theme':'base','themeVariables':{'background':'#faf7ef','primaryColor':'#f0e2c2','primaryTextColor':'#1f2840','primaryBorderColor':'#8a7548','secondaryColor':'#d9efec','secondaryBorderColor':'#1d8a80','secondaryTextColor':'#1f2840','tertiaryColor':'#f2ebd8','tertiaryBorderColor':'#8a7548','tertiaryTextColor':'#1f2840','lineColor':'#1d8a80','titleColor':'#1f2840','fontSize':'14px','edgeLabelBackground':'#faf7ef','clusterBkg':'#f2ebd8','clusterBorder':'#8a7548','actorBkg':'#f0e2c2','actorBorder':'#8a7548','actorTextColor':'#1f2840','actorLineColor':'#8a7548','signalColor':'#1d8a80','signalTextColor':'#1f2840','activationBkgColor':'#d9efec','activationBorderColor':'#1d8a80','noteBkgColor':'#f2ebd8','noteBorderColor':'#8a7548','noteTextColor':'#1f2840','labelBoxBkgColor':'#f0e2c2','labelBoxBorderColor':'#8a7548','labelTextColor':'#1f2840','transitionColor':'#1d8a80','transitionLabelColor':'#1f2840','stateLabelColor':'#1f2840','altBackground':'#f2ebd8'}}}%%
flowchart TB
Start["Start RegisterUserAsync"]
Start --> CheckEmpty
CheckEmpty["Check username and password not empty"]
CheckEmpty -->|"missing"| FailMissing
CheckEmpty -->|"present"| CheckUsernameRegex
FailMissing["Return UserOperationResult.Fail(UserError.ValidationFailed, #quot;Username and password are required.#quot;)"]
CheckUsernameRegex["Validate username with ValidationConstants.UsernameRegex()"]
CheckUsernameRegex -->|"invalid"| FailUsernameRegex
CheckUsernameRegex -->|"valid"| CheckPwdMin
FailUsernameRegex["Return UserOperationResult.Fail(UserError.ValidationFailed, #quot;Username must be 3-50 characters and contain only letters, digits, underscores, or hyphens.#quot;)"]
CheckPwdMin["Check password length >= 6"]
CheckPwdMin -->|"too short"| FailPwdShort
CheckPwdMin -->|"ok"| CheckPwdMax
FailPwdShort["Return UserOperationResult.Fail(UserError.ValidationFailed, #quot;Password must be at least 6 characters.#quot;)"]
CheckPwdMax["Check password length <= ValidationConstants.MaxPasswordLength"]
CheckPwdMax -->|"too long"| FailPwdLong
CheckPwdMax -->|"ok"| Normalize
FailPwdLong["Return UserOperationResult.Fail(UserError.ValidationFailed, #quot;Password must not exceed ValidationConstants.MaxPasswordLength characters.#quot;)"]
Normalize["Normalize username (ToLowerInvariant and Trim)"]
Normalize --> CheckReserved
CheckReserved["Compare normalized username to UsersController.DeletedUserName"]
CheckReserved -->|"reserved"| FailReserved
CheckReserved -->|"not reserved"| CreateScope
FailReserved["Return UserOperationResult.Fail(UserError.ValidationFailed, #quot;This username is reserved.#quot;)"]
CreateScope["Create scope and get EchoHubDbContext from _scopeFactory"]
CreateScope --> CheckExists
CheckExists["Check if db.Users.AnyAsync(u => u.Username == normalizedUsername)"]
CheckExists -->|"exists"| FailAlreadyExists
CheckExists -->|"not exists"| CheckIsFirstUser
FailAlreadyExists["Return UserOperationResult.Fail(UserError.AlreadyExists, #quot;Username is already taken.#quot;)"]
CheckIsFirstUser["Determine isFirstUser = !await db.Users.AnyAsync()"]
CheckIsFirstUser -->|"first user"| CreateUser
CheckIsFirstUser -->|"not first"| RegistrationGate
RegistrationGate["Check RegistrationMode (open / invite / closed)"]
RegistrationGate -->|"closed"| FailRegistrationClosed
RegistrationGate -->|"invite"| TryInvite
RegistrationGate -->|"open"| CreateUser
FailRegistrationClosed["Return UserOperationResult.Fail(UserError.ValidationFailed, #quot;Registration is closed on this server.#quot;)"]
TryInvite["Call TryConsumeInviteAsync(db, inviteCode)"]
TryInvite -->|"invite error"| FailInviteError
TryInvite -->|"ok"| CreateUser
FailInviteError["Return UserOperationResult.Fail(UserError.ValidationFailed, inviteError)"]
CreateUser["Create new User instance (Id = Guid.NewGuid(), set fields)"]
start["Start RegisterUserAsync"]
start --> checkEmpty["Check username and password not empty"]
checkEmpty -->|"invalid"| emptyFail["Return UserOperationResult.Fail(UserError.ValidationFailed): Username and password required"]
checkEmpty -->|"valid"| regexCheck["Validate username with ValidationConstants.UsernameRegex()"]
regexCheck -->|"invalid"| regexFail["Return UserOperationResult.Fail(UserError.ValidationFailed): Username format invalid"]
regexCheck -->|"valid"| pwMinCheck["Check password length >= 6"]
pwMinCheck -->|"no"| pwMinFail["Return UserOperationResult.Fail(UserError.ValidationFailed): Password must be at least 6 characters"]
pwMinCheck -->|"yes"| pwMaxCheck["Check password length <= ValidationConstants.MaxPasswordLength"]
pwMaxCheck -->|"no"| pwMaxFail["Return UserOperationResult.Fail(UserError.ValidationFailed): Password exceeds max length"]
pwMaxCheck -->|"yes"| normalize["Normalize username (ToLowerInvariant().Trim())"]
normalize --> reservedCheck["If normalized == UsersController.DeletedUserName"]
reservedCheck -->|"yes"| reservedFail["Return UserOperationResult.Fail(UserError.ValidationFailed): This username is reserved"]
reservedCheck -->|"no"| dbScope["Create scope and get EchoHubDbContext"]
dbScope --> existsCheck["If EchoHubDbContext.Users.Any(u => u.Username == normalized)"]
existsCheck -->|"yes"| existsFail["Return UserOperationResult.Fail(UserError.AlreadyExists): Username is already taken"]
existsCheck -->|"no"| isFirstCheck["Determine isFirstUser = !EchoHubDbContext.Users.Any()"]
isFirstCheck -->|"true"| createUser["Create new User entity (new User { ... })"]
isFirstCheck -->|"false"| regMode["Check UserService.RegistrationMode (open, invite, closed)"]
regMode -->|"closed"| closedFail["Return UserOperationResult.Fail(UserError.ValidationFailed): Registration is closed on this server"]
regMode -->|"invite"| inviteTry["Call TryConsumeInviteAsync(EchoHubDbContext, inviteCode)"]
inviteTry -->|"error"| inviteFail["Return UserOperationResult.Fail(UserError.ValidationFailed): invite error returned"]
inviteTry -->|"ok"| createUser
regMode -->|"open"| createUser
createUser --> save["Save new User to EchoHubDbContext and assign roles (ServerRole may apply)"]
save --> success["Return UserOperationResult.Success(UserProfileDto)"]
```
```csharp
@@ -77,28 +40,27 @@ public class UserService : IUserService
```
Implements user-account operations for the server, most notably account registration. Use this concrete IUserService implementation when you need the server-backed behavior: configuration-driven registration modes (open / invite / closed), automatic owner bootstrap for the very first account, username/password validation, reserved-name checks, and password hashing before persisting users.
Handles user registration and related user-management concerns for the server. Use `UserService` when you need a high-level operation that validates credentials, enforces server-wide registration policy, creates the initial server owner account, hashes passwords, and returns canonical [`UserOperationResult`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) responses instead of interacting with [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) directly.
## Remarks
UserService is the server-side implementation of IUserService and is responsible for safe, policy-driven user creation. It reads the registration policy from IConfiguration (Server:Registration), uses an IServiceScopeFactory to create a scoped EchoHubDbContext per operation (so the service can be used from different DI lifetimes), and enforces validation rules from ValidationConstants. The very first account created on a fresh database is always promoted to ServerRole.Owner to allow bootstrapping an administration account. Invite consumption (when registration is in "invite" mode) is performed via an atomic/guarded update to avoid races when two registrations attempt to use the last invite simultaneously.
`UserService` encapsulates the rules and side effects required to create a new [`User`](../../EchoHub.Core/Models/User.cs.md) in the application: it validates the `username` with `ValidationConstants.UsernameRegex()`, enforces password length (minimum 6 characters and a maximum of `ValidationConstants.MaxPasswordLength`), normalizes the username to lowercase and trimmed form, prevents use of the reserved `Controllers.UsersController.DeletedUserName`, and checks uniqueness using [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md). The `RegistrationMode` property reads `Server:Registration` from `IConfiguration` and controls whether new sign-ups are allowed (`"open"`), require a valid invite (`"invite"`), or are disallowed (`"closed"`). The very first account created on an empty database is automatically assigned `ServerRole.Owner` to allow server bootstrap. For invite-based registration, `UserService` defers to the private `TryConsumeInviteAsync` routine which (per its comment) performs a guarded update so concurrent registrations cannot both consume the same invite.
## Example
```csharp
// Typical usage from an async context where `userService` is resolved from DI
var result = await userService.RegisterUserAsync("alice", "s3cretP@ss", displayName: "Alice");
// Given an IUserService instance (e.g. resolved from DI):
var result = await userService.RegisterUserAsync("alice", "s3cret!", displayName: "Alice");
if (result.IsSuccess)
{
var profile = result; // UserOperationResult.Success wraps the created UserProfileDto
// proceed with signed-in flow
var profile = result; // result carries the created [`UserProfileDto`](../../EchoHub.Core/DTOs/ProfileDtos.cs.md) via `UserOperationResult.Success`
// proceed with login or return profile to caller
}
else
{
// registration failed; map user-visible error to response
// handle failure: message and [`UserError`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) are available from the [`UserOperationResult`](../../EchoHub.Core/DTOs/CommonDtos.cs.md) returned
}
```
## Notes
- Username handling: the service normalizes usernames by trimming and lower-casing; a specific reserved name (UsersController.DeletedUserName) is rejected.
- The first user bypasses the registration gate and becomes ServerRole.Owner this is intentional so a fresh server can be bootstrapped.
- Passwords are hashed using BCrypt.Net.BCrypt.HashPassword before being stored; there is no exposed mechanism here to change the hash algorithm.
- Invite consumption uses a guarded database update to prevent two concurrent registrations from both consuming the last available use of a code; if invite validation fails, RegisterUserAsync returns a validation failure with the invite error message.
- The `RegistrationMode` is computed on each access from `IConfiguration["Server:Registration"]`; changing that configuration at runtime affects subsequent calls to `RegisterUserAsync` immediately.
- The first created user bypasses invite/closed checks and is assigned `ServerRole.Owner`; this is intentional to allow initial server bootstrap and means the first successful registration must be protected in deployment scenarios.
- `RegisterUserAsync` normalizes usernames to lowercase and trims them before uniqueness checks, so the system enforces case-insensitive username uniqueness. Passwords are hashed with `BCrypt.Net.BCrypt.HashPassword` before being stored.
@@ -8,49 +8,40 @@
```mermaid
%%{init: {'theme':'base','themeVariables':{'background':'#faf7ef','primaryColor':'#f0e2c2','primaryTextColor':'#1f2840','primaryBorderColor':'#8a7548','secondaryColor':'#d9efec','secondaryBorderColor':'#1d8a80','secondaryTextColor':'#1f2840','tertiaryColor':'#f2ebd8','tertiaryBorderColor':'#8a7548','tertiaryTextColor':'#1f2840','lineColor':'#1d8a80','titleColor':'#1f2840','fontSize':'14px','edgeLabelBackground':'#faf7ef','clusterBkg':'#f2ebd8','clusterBorder':'#8a7548','actorBkg':'#f0e2c2','actorBorder':'#8a7548','actorTextColor':'#1f2840','actorLineColor':'#8a7548','signalColor':'#1d8a80','signalTextColor':'#1f2840','activationBkgColor':'#d9efec','activationBorderColor':'#1d8a80','noteBkgColor':'#f2ebd8','noteBorderColor':'#8a7548','noteTextColor':'#1f2840','labelBoxBkgColor':'#f0e2c2','labelBoxBorderColor':'#8a7548','labelTextColor':'#1f2840','transitionColor':'#1d8a80','transitionLabelColor':'#1f2840','stateLabelColor':'#1f2840','altBackground':'#f2ebd8'}}}%%
flowchart TB
Start["Start"]
Scope["Create scope and resolve EchoHubDbContext"]
EnsureDefault["Call EnsureDefaultChannelsPublicAsync(EchoHubDbContext)"]
QueryGeneral["Query Channel where Name == HubConstants.DefaultChannel"]
CheckGeneral{"Channel found and Channel.IsPublic == false?"}
MarkPublic["Set Channel.IsPublic = true and save EchoHubDbContext"]
SkipMark["No change"]
MigrateAnsi["Call MigrateAnsiMessagesAsync(EchoHubDbContext)"]
QueryImages["Load messages where Type == MessageType.Image"]
FilterAnsi["Filter messages where Content contains ESC byte"]
CheckCount{"toMigrate.Count == 0?"}
LogFound["Log found messages and prepare migration"]
ConvertLoop["For each message: convert ANSI to color tags and update Content if changed"]
CheckModified{"modified > 0?"}
SaveModified["Save changes to EchoHubDbContext and log migrated count"]
SkipSave["No modifications to save"]
CallEmbed["Call MigrateEmbedJsonToArrayAsync to migrate EmbedDto JSON to array"]
CallAttach["Call MigrateLegacyAttachmentsAsync to migrate Attachment/AttachmentKind"]
CallAdmins["Call EnsureConfiguredAdminsAsync to ensure ServerRole admins configured"]
End["End"]
Start["DataMigrationService.RunAsync(IServiceProvider)"]
Scope["Create scope and resolve services"]
GetServices["Get EchoHubDbContext, IConfiguration and ILogger"]
Start --> Scope
Scope --> EnsureDefault
EnsureDefault --> QueryGeneral
QueryGeneral --> CheckGeneral
CheckGeneral -->|"Yes"| MarkPublic
CheckGeneral -->|"No"| SkipMark
MarkPublic --> MigrateAnsi
SkipMark --> MigrateAnsi
MigrateAnsi --> QueryImages
QueryImages --> FilterAnsi
FilterAnsi --> CheckCount
CheckCount -->|"Yes"| CallEmbed
CheckCount -->|"No"| LogFound
LogFound --> ConvertLoop
ConvertLoop --> CheckModified
CheckModified -->|"Yes"| SaveModified
CheckModified -->|"No"| SkipSave
SaveModified --> CallEmbed
SkipSave --> CallEmbed
CallEmbed --> CallAttach
CallAttach --> CallAdmins
CallAdmins --> End
EnsureDefault["Call EnsureDefaultChannelsPublicAsync(db, logger)"]
CheckDefault{"Channel named HubConstants.DefaultChannel exists and IsPublic == false?"}
UpdateDefault["Set Channel.IsPublic = true; await db.SaveChangesAsync(); logger.LogInformation"]
SkipDefault["No change"]
AfterDefault["Continue to next migration"]
MigrateAnsi["Call MigrateAnsiMessagesAsync(db, logger)"]
LoadImages["Load EchoHubDbContext.Messages where Type == MessageType.Image"]
FilterAnsi["Filter messages where Content contains ESC (0x1B) -> toMigrate list"]
AnsiEmpty{"toMigrate.Count == 0?"}
AnsiProcess["For each message: converted = AnsiToColorTags(Content); if changed set Content and increment modified"]
AnsiSave{"modified > 0?"}
AnsiSaved["await db.SaveChangesAsync(); logger.LogInformation of migrated count"]
MigrateEmbed["Call MigrateEmbedJsonToArrayAsync(db, logger) - convert legacy embed JSON to EmbedDto array where needed"]
MigrateAttachments["Call MigrateLegacyAttachmentsAsync(db, logger) - migrate Attachment entities to new AttachmentKind/format"]
EnsureAdmins["Call EnsureConfiguredAdminsAsync(db, config, logger) - ensure ServerRole admin users per config"]
End["RunAsync complete"]
Start --> Scope --> GetServices --> EnsureDefault --> CheckDefault
CheckDefault -- "yes" --> UpdateDefault --> AfterDefault
CheckDefault -- "no" --> SkipDefault --> AfterDefault
AfterDefault --> MigrateAnsi --> LoadImages --> FilterAnsi --> AnsiEmpty
AnsiEmpty -- "yes" --> MigrateEmbed
AnsiEmpty -- "no" --> AnsiProcess --> AnsiSave
AnsiSave -- "yes" --> AnsiSaved --> MigrateEmbed
AnsiSave -- "no" --> MigrateEmbed
MigrateEmbed --> MigrateAttachments --> EnsureAdmins --> End
```
```csharp
@@ -58,12 +49,14 @@ public static partial class DataMigrationService
```
Runs a set of application-level data migrations that update content and small structural pieces of the database when the server starts. Call this during application startup (once) to apply idempotent, content-format migrations such as making the default channel public, converting legacy ANSI art to printable color tags, folding legacy attachment columns into the Attachments table, and other one-off data fixes.
Performs application data migrations that should run at startup. Call `RunAsync(IServiceProvider)` once (for example during application startup) to perform a series of idempotent migrations against the [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md): make the default channel public, convert legacy ANSI color escape sequences to printable color tags, migrate embed JSON to the newer array form, fold legacy single-row attachments into the `Attachments` table, and ensure configured admin users exist.
## Remarks
This class centralizes lightweight, code-driven migrations that operate on row data and content formats rather than schema changes (which belong in EF migrations). Each migration method scopes a DbContext from the provided IServiceProvider and performs targeted, idempotent updates where possible (for example, legacy single-attachment rows are only migrated when no Attachment rows exist). Conversions that change message content (like ANSI→color tags) are deterministic and saved back with SaveChangesAsync.
`DataMigrationService` centralizes small, targeted transformations that evolve persisted chat data between versions. Each migration method (for example, `EnsureDefaultChannelsPublicAsync`, `MigrateAnsiMessagesAsync`, `MigrateEmbedJsonToArrayAsync`, `MigrateLegacyAttachmentsAsync`, and `EnsureConfiguredAdminsAsync`) is written to be safe to run repeatedly: migrated rows are detected and skipped if already-upgraded so the service can be invoked on every startup without duplicating work. The service resolves a scoped [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) (and `IConfiguration`/`ILoggerFactory`) from the provided `IServiceProvider`, performs database changes, and logs what changed.
The ANSI conversion helper `AnsiToColorTags` is exposed for reuse and relies on a generated regex (`AnsiColorRegex`) to efficiently match 24-bit foreground (`38;2;R;G;B`) and background (`48;2;R;G;B`) color sequences and the reset code (`0`). Matches are transformed to `{F:RRGGBB}`, `{B:RRGGBB}`, and `{X}` respectively.
## Notes
- AnsiToColorTags only recognizes/reset sequences produced as "\x1b[38;2;R;G;Bm", "\x1b[48;2;R;G;Bm" and the reset "\x1b[0m"; other ANSI sequences are left unchanged.
- MigrateAnsiMessagesAsync only examines messages of type Image and checks for the ESC (0x1B) character before attempting conversion, reducing unnecessary work.
- Each migration method calls SaveChangesAsync only when there are actual modifications; however RunAsync itself is asynchronous and can be long-running depending on DB size—callers should await it during startup and avoid calling concurrently.
- `RunAsync` resolves [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md), `IConfiguration`, and `ILoggerFactory` from the provided `IServiceProvider`; ensure those services are registered in DI before calling `RunAsync`.
- `MigrateAnsiMessagesAsync` assumes `Message.Content` is populated (the code calls `m.Content.Contains('\x1b')`). If `Message.Content` can be null in your schema, the migration may throw a `NullReferenceException` — validate non-null constraints or add a null-check before running this migration.
- `AnsiToColorTags` only converts the specific 24-bit RGB sequences (`38;2` and `48;2`) and the reset code (`0`). Other ANSI sequences are left unchanged by design; if older clients used different ANSI sequences they will not be translated by this helper.
@@ -8,11 +8,11 @@ public static class DatabaseSetup
```
DatabaseSetup is a startup bootstrapper that ensures the EchoHub database is ready by applying migrations, seeding a default channel, and running data migrations. It creates a scoped DbContext and logger, migrates the database, seeds a default channel when missing, and then triggers data migrations; if a legacy SQLite database is detected (no migrations history but legacy tables exist), it backs up the current file and recreates the database to enable the modern migration path.
DatabaseSetup is a startup-time orchestration helper that ensures the database is ready for use by applying migrations, seeding initial data, and performing post-migration data transformations. When you call `InitializeAsync` with an `IServiceProvider`, it creates a scoped container, resolves the required [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) and a logger from `ILoggerFactory`, and then runs three phases: migrate the database (including legacy-handling) via `MigrateAsync`, seed the default channel via `SeedDefaultChannelAsync`, and finally invoke `DataMigrationService.RunAsync` to apply data migrations such as ANSI-to-color-tag conversions.
## Remarks
DatabaseSetup centralizes the one-time bootstrap concerns for the database, isolating migration, seeding, and legacy-handling logic from the rest of the startup flow. It relies on EF Cores migration pipeline and coordinates with DataMigrationService to perform data transformations (e.g., ANSI-to-color-tag conversions) and to ensure essential defaults (like the General channel) exist, aligning the persisted state with the application's current expectations.
DatabaseSetup centralizes the startup bootstrap workflow for the database, encapsulating migrations, schema upgrades, seeding, and legacy handling behind a single entry point. It coordinates collaborators like [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) and [`DataMigrationService`](DataMigrationService.cs.md), and uses a scoped `IServiceProvider` so bootstrapping code does not leak scoped lifetimes to the caller. The legacy handling path ensures a clean migration story for older SQLite databases by backing up the file when a legacy schema is detected and then recreating the database with migrations support.
## Notes
- Legacy-path destructive behavior: when a legacy database is detected, the code creates a timestamped backup and then deletes the database so migrations can proceed against a fresh schema. This trade-off is intentional to enable a safe migration path from older schemas.
- Startup-time invocation: the initialization runs at application startup and establishes its own service scope; avoid multiple concurrent invocations to prevent duplicate work or conflicting migrations during a single process lifecycle.
- Legacy backup: If a legacy SQLite database is detected, the code may back up the original file to a path like `{dbPath}.legacy_{timestamp}` before deletion. This preserves a recoverable snapshot when possible.
- Startup failure: If `MigrateAsync` fails, the exception is logged and rethrown, which can cause startup to fail so the issue is addressed before the app runs.
@@ -8,18 +8,13 @@ public static class FirstRunSetup
```
FirstRunSetup is a small bootstrap utility that guarantees a usable appsettings.json and seeds it with cryptographic secrets on first run. On startup, it copies appsettings.example.json to appsettings.json if the destination is missing, then ensures a valid JWT secret and an encryption key exist by generating them when needed.
FirstRunSetup is a small bootstrap utility that ensures essential configuration exists on the first run of the application. Calling `EnsureAppSettings` will create `appsettings.json` from `appsettings.example.json` if the former is missing, and then guarantee that security-related values are present by generating them when necessary. Specifically, it will ensure the JWT secret at `Jwt.Secret` is non-empty and not a placeholder, and it will ensure an AES encryption key at `Encryption.Key` is present. Generated secrets are cryptographically strong base64 values written back into `appsettings.json`, and progress is reported to the console.
## Remarks
Centralizing this bootstrap logic keeps startup concerns cohesive and makes the secrets generation deterministic and auditable. It relies on cryptographically secure RNG and writes back to the configuration file with indentation for human readability, while tolerating JSON comments during read.
## Example
```csharp
// Typical usage during application startup
FirstRunSetup.EnsureAppSettings();
```
The class centralizes the bootstrapping of critical security configuration, enabling a smooth first-run startup without manual edits. It is designed to be invoked during startup or a dedicated setup routine, populating missing cryptographic material so downstream components can rely on `Jwt.Secret` and `Encryption.Key` being present from the outset. The implementation favors an in-place, file-based approach that aligns with conventional .NET configuration loading, so subsequent code that reads configuration from `appsettings.json` will see the generated values.
## Notes
- Idempotent: existing Jwt.Secret or Encryption.Key are preserved; new values are generated only if missing or marked as CHANGE_ME.
- Path dependency: relies on the current working directory (the project root). In non-standard deployments, you may need to adjust the working directory or extend the helper to accept explicit paths.
- Silent on parse failure: if the JSON cannot be parsed (root is null), the method returns without writing changes.
- IO or JSON parsing/writing operations may throw if the filesystem is inaccessible or the JSON is malformed; there is no explicit exception handling in this bootstrap path.
- The JWT secret is only regenerated if it is missing, empty, or begins with `CHANGE_ME`, preventing accidental overwrites on a healthy existing configuration.
- The encryption key is generated only when the `Encryption.Key` value is absent; existing keys are preserved to avoid needless rotation.
- The logic relies on the current working directory to locate `appsettings.json` and `appsettings.example.json`, so running from an unexpected directory can affect behavior.