docs: Update documentation for 145 files

Generated by AurionDocs
Job ID: c99fff50-67a3-4294-b4df-3e73f4f12de9
Source commit: 4dcb480
This commit is contained in:
Hue
2026-07-23 08:10:35 +02:00
parent 4dcb480d1d
commit f8f4e03ddd
145 changed files with 22779 additions and 0 deletions
@@ -0,0 +1,18 @@
# JwtTokenService
> **File:** `src/EchoHub.Server/Auth/JwtTokenService.cs`
> **Kind:** class
```csharp
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.
@@ -0,0 +1,11 @@
# ServerLogsOptions
> **File:** `src/EchoHub.Server/Config/ServerLogsOptions.cs`
> **Kind:** class
```csharp
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.
@@ -0,0 +1,19 @@
# SpamOptions
> **File:** `src/EchoHub.Server/Config/SpamOptions.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
@@ -0,0 +1,19 @@
# StatsOptions
> **File:** `src/EchoHub.Server/Config/StatsOptions.cs`
> **Kind:** class
```csharp
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.
## 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.
@@ -0,0 +1,31 @@
# UploadLimits
> **File:** `src/EchoHub.Server/Config/UploadLimits.cs`
> **Kind:** class
```csharp
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.
## 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.
## Example
```csharp
var limits = new UploadLimits
{
MaxFileSizeMB = 64,
MaxAttachmentsPerMessage = 4
};
long maxImageBytes = limits.MaxImageSizeBytes;
long imageCeiling = limits.MaxForKind(AttachmentKind.Image);
long requestBody = limits.MaxRequestBodyBytes;
```
## 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.
@@ -0,0 +1,21 @@
# AuthController
> **File:** `src/EchoHub.Server/Controllers/AuthController.cs`
> **Kind:** class
```csharp
[ApiController]
[Route("api/auth")]
[EnableRateLimiting("auth")]
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.
@@ -0,0 +1,453 @@
# ChannelsController.cs
> **Source:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
## Contents
- [ChannelsController](#channelscontroller)
- [ChannelsController (constructor)](#channelscontroller-constructor)
- [CreateChannel](#createchannel)
- [DeleteChannel](#deletechannel)
- [GetChannelCrypto](#getchannelcrypto)
- [MapChannelError](#mapchannelerror)
- [ParseKind](#parsekind)
- [GetChannelMeta](#getchannelmeta)
- [GetChannels](#getchannels)
- [RekeyChannel](#rekeychannel)
- [SendMessageWithAttachments](#sendmessagewithattachments)
- [SendUrl](#sendurl)
- [UpdateTopic](#updatetopic)
---
## ChannelsController
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** class
```csharp
[ApiController]
[Route("api/channels")]
[Authorize]
[EnableRateLimiting("general")]
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.
## 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.
## 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.
---
### ChannelsController (constructor)
> **File:** `src/EchoHub.Server/Controllers/ChannelsController.cs`
> **Kind:** constructor
```csharp
public ChannelsController(
IChannelService channelService,
EchoHubDbContext db,
FileStorageService fileStorage,
ImageToAsciiService asciiService,
IHttpClientFactory httpClientFactory,
IChatService chatService,
IMessageEncryptionService encryption,
UploadLimits uploadLimits,
ILogger<ChannelsController> logger)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `channelService` | [`IChannelService`](../../EchoHub.Core/Contracts/IChannelService.cs.md) | — |
| `db` | [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) | — |
| `fileStorage` | [`FileStorageService`](../Services/FileStorageService.cs.md) | — |
| `asciiService` | [`ImageToAsciiService`](../../EchoHub.Core/Services/ImageToAsciiService.cs.md) | — |
| `httpClientFactory` | `IHttpClientFactory` | — |
| `chatService` | [`IChatService`](../../EchoHub.Core/Contracts/IChatService.cs.md) | — |
| `encryption` | [`IMessageEncryptionService`](../../EchoHub.Core/Contracts/IMessageEncryptionService.cs.md) | — |
| `uploadLimits` | [`UploadLimits`](../Config/UploadLimits.cs.md) | — |
| `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.
## 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.
## 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.
---
@@ -0,0 +1,23 @@
# FilesController
> **File:** `src/EchoHub.Server/Controllers/FilesController.cs`
> **Kind:** class
```csharp
[ApiController]
[Route("api/files")]
[Authorize]
[EnableRateLimiting("general")]
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.
## 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.
## 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.
@@ -0,0 +1,23 @@
# InvitesController
> **File:** `src/EchoHub.Server/Controllers/InvitesController.cs`
> **Kind:** class
```csharp
[ApiController]
[Route("api/invites")]
[Authorize]
[EnableRateLimiting("general")]
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.
## 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.
## 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.
@@ -0,0 +1,71 @@
# ModerationController
> **File:** `src/EchoHub.Server/Controllers/ModerationController.cs`
> **Kind:** class
*Figure: How ModerationController works.*
```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"]
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
```
```csharp
[ApiController]
[Route("api/moderation")]
[Authorize]
[EnableRateLimiting("general")]
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.
## 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.
## 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.
@@ -0,0 +1,23 @@
# ServerController
> **File:** `src/EchoHub.Server/Controllers/ServerController.cs`
> **Kind:** class
```csharp
[ApiController]
[Route("api/server")]
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.
## 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.
## 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.
@@ -0,0 +1,60 @@
# UsersController
> **File:** `src/EchoHub.Server/Controllers/UsersController.cs`
> **Kind:** class
*Figure: How UsersController works.*
```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"]
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
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
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
```
```csharp
[ApiController]
[Route("api/users")]
[Authorize]
[EnableRateLimiting("general")]
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.
## 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.
## 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.
@@ -0,0 +1,11 @@
# EchoHubDbContext
> **File:** `src/EchoHub.Server/Data/EchoHubDbContext.cs`
> **Kind:** class
```csharp
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.
@@ -0,0 +1,60 @@
# ChatHub
> **File:** `src/EchoHub.Server/Hubs/ChatHub.cs`
> **Kind:** class
*Figure: How ChatHub works.*
```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"]
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')"]
User --> CH_OnConnected
CH_OnConnected --> IChatServiceConn
IChatServiceConn --> BaseOnConnected
CH_OnConnected -->|"exception"| OnConnectedCatch
User --> CH_Join
CH_Join --> IChatServiceJoin
IChatServiceJoin --> CheckError
CheckError -->|"yes"| ReturnFail
CheckError -->|"no"| AddGroup
AddGroup --> IChannelServiceNode
IChannelServiceNode --> ReturnSuccess
CH_Join -->|"exception"| JoinCatch
IChatServiceJoin -->|"exception"| JoinCatch
JoinCatch --> ReturnJoinCatch
```
```csharp
[Authorize]
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.
## 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.
## 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.
@@ -0,0 +1,15 @@
# Program
> **File:** `src/EchoHub.Server/Program.cs`
> **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.
@@ -0,0 +1,555 @@
# ChannelService.cs
> **Source:** `src/EchoHub.Server/Services/ChannelService.cs`
## Contents
- [ChannelService](#channelservice)
- [ChannelService (constructor)](#channelservice-constructor)
- [CreateChannelAsync](#createchannelasync)
- [DeleteChannelAsync](#deletechannelasync)
- [EnsureChannelMembershipAsync](#ensurechannelmembershipasync)
- [EnsureDefaultChannelAsync](#ensuredefaultchannelasync)
- [EnsureSystemChannelAsync](#ensuresystemchannelasync)
- [GetChannelByNameAsync](#getchannelbynameasync)
- [GetChannelCryptoAsync](#getchannelcryptoasync)
- [GetChannelListAsync](#getchannellistasync)
- [GetChannelMetaAsync](#getchannelmetaasync)
- [GetChannelsAsync](#getchannelsasync)
- [RekeyChannelAsync](#rekeychannelasync)
- [SetChannelPasswordAsync](#setchannelpasswordasync)
- [UpdateTopicAsync](#updatetopicasync)
- [ValidateChannelPassword](#validatechannelpassword)
- [GetChannelKeyEnvelopeAsync](#getchannelkeyenvelopeasync)
- [GetChannelTopicAsync](#getchanneltopicasync)
---
## ChannelService
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
---
### ChannelService (constructor)
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** constructor
```csharp
public ChannelService(
IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker,
SpamGuard spamGuard,
ServerLogsService serverLogs,
ILogger<ChannelService> logger)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `scopeFactory` | `IServiceScopeFactory` | — |
| `presenceTracker` | [`PresenceTracker`](PresenceTracker.cs.md) | — |
| `spamGuard` | [`SpamGuard`](SpamGuard.cs.md) | — |
| `serverLogs` | [`ServerLogsService`](ServerLogs/ServerLogsService.cs.md) | — |
| `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.
## 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.
---
### CreateChannelAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```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)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `creatorUserId` | `Guid` | — |
| `name` | `string` | — |
| `topic` | `string?` | — |
| `isPublic` | `bool` | — |
| `encryptionSalt` | `string? [REDACTED:CONNECTION_STRING_PASSWORD] 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.
## 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.
## 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.
---
### DeleteChannelAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<ChannelOperationResult> DeleteChannelAsync(Guid callerUserId, string channelName)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `callerUserId` | `Guid` | — |
| `channelName` | `string` | — |
**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.
---
### EnsureChannelMembershipAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(
Guid userId, string channelName, string? [REDACTED:CONNECTION_STRING_PASSWORD]
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `Success` | `bool` | — |
| `Error` | `string?` | — |
| `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.
## 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).
## 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.
---
### EnsureDefaultChannelAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `db` | [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) | — |
**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.
## 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.
## 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.
---
### EnsureSystemChannelAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<ChannelDto> EnsureSystemChannelAsync(string channelName, string? topic = null)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `channelName` | `string` | — |
| `topic` | `string?` | `null` |
**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.
## 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.
---
### GetChannelByNameAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<ChannelDto?> GetChannelByNameAsync(string channelName)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `channelName` | `string` | — |
**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.
## 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.
## 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.
---
### GetChannelCryptoAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `channelName` | `string` | — |
**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.
---
### GetChannelListAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
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.
---
### GetChannelMetaAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `channelName` | `string` | — |
**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.
---
### GetChannelsAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<PaginatedResponse<ChannelDto>> GetChannelsAsync(Guid userId, int offset, int limit)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `userId` | `Guid` | — |
| `offset` | `int` | — |
| `limit` | `int` | — |
**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.
## 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.
---
### RekeyChannelAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<ChannelOperationResult> RekeyChannelAsync(Guid callerUserId, string channelName,
string oldPassword, string newPassword, string newEncryptionSalt, string newWrappedRoomKey)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `callerUserId` | `Guid` | — |
| `channelName` | `string` | — |
| `oldPassword` | `string` | — |
| `newPassword` | `string` | — |
| `newEncryptionSalt` | `string` | — |
| `newWrappedRoomKey` | `string` | — |
**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.
## 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.
## 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.
---
### SetChannelPasswordAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<ChannelOperationResult> SetChannelPasswordAsync(Guid callerUserId, string channelName, string? password)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `callerUserId` | `Guid` | — |
| `channelName` | `string` | — |
| `password` | `string?` | — |
**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.
## 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.
## Notes
- 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.
---
### UpdateTopicAsync
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
public async Task<ChannelOperationResult> UpdateTopicAsync(
Guid callerUserId, string channelName, string? topic)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `callerUserId` | `Guid` | — |
| `channelName` | `string` | — |
| `topic` | `string?` | — |
**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.
## 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.
## 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.
---
### ValidateChannelPassword
> **File:** `src/EchoHub.Server/Services/ChannelService.cs`
> **Kind:** method
```csharp
private static string? ValidateChannelPassword(ref string? password)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `password` | `string?` | — |
**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.
## 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.
## 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.
---
@@ -0,0 +1,674 @@
# ChatService.cs
> **Source:** `src/EchoHub.Server/Services/ChatService.cs`
## Contents
- [ChatService](#chatservice)
- [ChatService (constructor)](#chatservice-constructor)
- [BroadcastChannelDeletedAsync](#broadcastchanneldeletedasync)
- [BroadcastChannelUpdatedAsync](#broadcastchannelupdatedasync)
- [BroadcastMessageAsync](#broadcastmessageasync)
- [BroadcastToAllAsync](#broadcasttoallasync)
- [BuildReplyRef](#buildreplyref)
- [FileIdFromUrl](#fileidfromurl)
- [GetChannelHistoryAsync](#getchannelhistoryasync)
- [GetChannelHistoryInternalAsync](#getchannelhistoryinternalasync)
- [GetChannelsForUserAsync](#getchannelsforuserasync)
- [GetOnlineUsersAsync](#getonlineusersasync)
- [LeaveChannelAsync](#leavechannelasync)
- [SendMessageAsync](#sendmessageasync)
- [UpdateStatusAsync](#updatestatusasync)
- [UserConnectedAsync](#userconnectedasync)
- [UserDisconnectedAsync](#userdisconnectedasync)
- [BuildLogBacklog](#buildlogbacklog)
- [JoinChannelAsync](#joinchannelasync)
- [SanitizeNewlines](#sanitizenewlines)
---
## ChatService
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** class
```csharp
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.
## 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).
## 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.
---
### ChatService (constructor)
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** constructor
```csharp
public ChatService(
IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker,
IEnumerable<IChatBroadcaster> broadcasters,
LinkEmbedService embedService,
IMessageEncryptionService encryption,
IChannelService channelService,
FileStorageService fileStorage,
SpamGuard spamGuard,
ServerLogsService serverLogs,
ServerStatsCollector statsCollector,
ILogger<ChatService> logger)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `scopeFactory` | `IServiceScopeFactory` | — |
| `presenceTracker` | [`PresenceTracker`](PresenceTracker.cs.md) | — |
| `broadcasters` | `IEnumerable<IChatBroadcaster>` | — |
| `embedService` | [`LinkEmbedService`](LinkEmbedService.cs.md) | — |
| `encryption` | [`IMessageEncryptionService`](../../EchoHub.Core/Contracts/IMessageEncryptionService.cs.md) | — |
| `channelService` | [`IChannelService`](../../EchoHub.Core/Contracts/IChannelService.cs.md) | — |
| `fileStorage` | [`FileStorageService`](FileStorageService.cs.md) | — |
| `spamGuard` | [`SpamGuard`](SpamGuard.cs.md) | — |
| `serverLogs` | [`ServerLogsService`](ServerLogs/ServerLogsService.cs.md) | — |
| `statsCollector` | [`ServerStatsCollector`](Stats/ServerStatsCollector.cs.md) | — |
| `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.
---
### BroadcastChannelDeletedAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
public Task BroadcastChannelDeletedAsync(string channelName)
=> BroadcastToAllAsync(b => b.SendChannelDeletedAsync(channelName))
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `channelName` | `string` | — |
**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.
## 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.
---
### BroadcastChannelUpdatedAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
=> BroadcastToAllAsync(b => b.SendChannelUpdatedAsync(channel, channelName))
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `channel` | [`ChannelDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) | — |
| `channelName` | `string?` | `null` |
**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.
## 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.
---
### BroadcastMessageAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
public Task BroadcastMessageAsync(string channelName, MessageDto message)
=> BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, message))
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `channelName` | `string` | — |
| `message` | [`MessageDto`](../../EchoHub.Core/DTOs/ChatDtos.cs.md) | — |
**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.
## 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.
## 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).
---
### BroadcastToAllAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `action` | `Func<IChatBroadcaster, Task>` | — |
**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.
## 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.
## 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.
---
### BuildReplyRef
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
private ReplyRefDto BuildReplyRef(Message target)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `target` | [`Message`](../../EchoHub.Core/Models/Message.cs.md) | — |
**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.
## 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.
## 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.
---
### FileIdFromUrl
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
private static string FileIdFromUrl(string url) => url.Split('/')[^1]
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `url` | `string` | — |
**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.
## 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.
## 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.
---
### GetChannelHistoryAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `channelName` | `string` | — |
| `count` | `int` | — |
| `offset` | `int` | `0` |
**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>`.
## 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.
## Notes
- Be aware that for log channels, only the first page contains backlog data; requesting subsequent pages returns an empty list.
---
### GetChannelHistoryInternalAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count, int offset = 0)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `db` | [`EchoHubDbContext`](../Data/EchoHubDbContext.cs.md) | — |
| `channelName` | `string` | — |
| `count` | `int` | — |
| `offset` | `int` | `0` |
**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.
## 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.
---
### GetChannelsForUserAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
public Task<List<string>> GetChannelsForUserAsync(string username)
=> Task.FromResult(_presenceTracker.GetChannelsForUser(username))
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `username` | `string` | — |
**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.
## 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.
---
### GetOnlineUsersAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
public async Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channelName)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `channelName` | `string` | — |
**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.
## 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}");
```
## 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.
---
### LeaveChannelAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
public async Task LeaveChannelAsync(string connectionId, string username, string channelName)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `connectionId` | `string` | — |
| `username` | `string` | — |
| `channelName` | `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.
## 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");
```
## Notes
- 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.
---
### SendMessageAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `userId` | `Guid` | — |
| `username` | `string` | — |
| `channelName` | `string` | — |
| `content` | `string` | — |
| `originConnectionId` | `string?` | `null` |
| `replyToMessageId` | `Guid?` | `null` |
**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.
---
### UpdateStatusAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `userId` | `Guid` | — |
| `username` | `string` | — |
| `status` | [`UserStatus`](../../EchoHub.Core/Models/UserStatus.cs.md) | — |
| `statusMessage` | `string?` | — |
**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.
## 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.
## 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.
---
### UserConnectedAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `connectionId` | `string` | — |
| `userId` | `Guid` | — |
| `username` | `string` | — |
**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.
---
### UserDisconnectedAsync
> **File:** `src/EchoHub.Server/Services/ChatService.cs`
> **Kind:** method
```csharp
public async Task<string?> UserDisconnectedAsync(string connectionId)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `connectionId` | `string` | — |
**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.
---
@@ -0,0 +1,90 @@
# DirectoryClaimStore.cs
> **Source:** `src/EchoHub.Server/Services/DirectoryClaimStore.cs`
## Contents
- [DirectoryClaimStore](#directoryclaimstore)
- [RegistrationStatus](#registrationstatus)
---
## DirectoryClaimStore
> **File:** `src/EchoHub.Server/Services/DirectoryClaimStore.cs`
> **Kind:** class
```csharp
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.
## 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;
```
## 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.
---
## RegistrationStatus
> **File:** `src/EchoHub.Server/Services/DirectoryClaimStore.cs`
> **Kind:** record
```csharp
public sealed record RegistrationStatus(
bool IsRegistered,
Guid? ServerId,
DateTimeOffset? LastRegisteredAt,
string? LastError,
string[]? ConflictingHosts)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `IsRegistered` | `bool` | — |
| `ServerId` | `Guid?` | — |
| `LastRegisteredAt` | `DateTimeOffset?` | — |
| `LastError` | `string?` | — |
| `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.
## 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.
## Notes
- Nullable fields indicate optional context; always guard before accessing ServerId, LastRegisteredAt, LastError, and ConflictingHosts to avoid NullReferenceException.
---
@@ -0,0 +1,18 @@
# FileCleanupService
> **File:** `src/EchoHub.Server/Services/FileCleanupService.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
@@ -0,0 +1,21 @@
# FileStorageService
> **File:** `src/EchoHub.Server/Services/FileStorageService.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
@@ -0,0 +1,124 @@
# LinkEmbedService
> **File:** `src/EchoHub.Server/Services/LinkEmbedService.cs`
> **Kind:** class
*Figure: How LinkEmbedService works.*
```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)"]
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
```
```csharp
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).
## 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
}
}
```
## 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.
@@ -0,0 +1,19 @@
# MessageEncryptionService
> **File:** `src/EchoHub.Server/Services/MessageEncryptionService.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
@@ -0,0 +1,21 @@
# MuteExpirationService
> **File:** `src/EchoHub.Server/Services/MuteExpirationService.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
@@ -0,0 +1,39 @@
# PresenceTracker
> **File:** `src/EchoHub.Server/Services/PresenceTracker.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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
// join a channel and query who is in it
tracker.JoinChannel("alice", "general");
var usersInGeneral = tracker.GetOnlineUsersInChannel("general"); // contains "alice"
// disconnect one connection; user still online because another connection remains
tracker.UserDisconnected("conn-1"); // returns "alice"; no UserCountChanged
// final disconnect removes the user and triggers UserCountChanged
tracker.UserDisconnected("conn-2"); // returns "alice"; triggers UserCountChanged -> 0
```
## 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.
@@ -0,0 +1,820 @@
# ServerDirectoryService.cs
> **Source:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
## Contents
- [ServerDirectoryService](#serverdirectoryservice)
- [InfiniteRetryPolicy](#infiniteretrypolicy)
- [BuildConnection](#buildconnection)
- [ConnectWithRetryAsync](#connectwithretryasync)
- [DisposeConnectionAsync](#disposeconnectionasync)
- [ExecuteAsync](#executeasync)
- [ExtractConflictingHosts](#extractconflictinghosts)
- [GetBackoffDelay](#getbackoffdelay)
- [HandleRegistrationErrorAsync](#handleregistrationerrorasync)
- [HandleRegistrationResponseAsync](#handleregistrationresponseasync)
- [OnUserCountChanged](#onusercountchanged)
- [ProcessUserCountUpdatesAsync](#processusercountupdatesasync)
- [RegisterAsync](#registerasync)
- [ResolveVersion](#resolveversion)
- [RunConnectionLoopAsync](#runconnectionloopasync)
- [StopAsync](#stopasync)
- [DirectoryHubUrl](#directoryhuburl)
- [ReconnectBaseDelay](#reconnectbasedelay)
- [ReconnectMaxDelay](#reconnectmaxdelay)
- [DirectoryProtocol](#directoryprotocol)
- [DirectoryRegistrationErrors](#directoryregistrationerrors)
- [ErrorDetail](#errordetail)
- [RegisterServerDto](#registerserverdto)
- [RegisterServerResult](#registerserverresult)
- [Response](#response)
- [ServerDirectoryService (constructor)](#serverdirectoryservice-constructor)
- [UserCountMinInterval](#usercountmininterval)
---
## ServerDirectoryService
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
---
### InfiniteRetryPolicy
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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).
---
### BuildConnection
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
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.
## 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.
## 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.
---
### DisposeConnectionAsync
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
private static async Task DisposeConnectionAsync(HubConnection connection)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `connection` | `HubConnection` | — |
**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.
## 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);
```
## 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.
---
### ExecuteAsync
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `stoppingToken` | `CancellationToken` | — |
**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.
---
### ExtractConflictingHosts
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
private static string[]? ExtractConflictingHosts(ErrorDetail? error)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `error` | `ErrorDetail?` | — |
**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.
## 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.
## 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.
---
### GetBackoffDelay
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
private static TimeSpan GetBackoffDelay(int attempt)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `attempt` | `int` | — |
**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.
## 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.
## 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.
---
### HandleRegistrationErrorAsync
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
private Task HandleRegistrationErrorAsync(ErrorDetail[]? errors)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `errors` | `ErrorDetail[]?` | — |
**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.
## 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.
---
### HandleRegistrationResponseAsync
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
private async Task HandleRegistrationResponseAsync(Response<RegisterServerResult>? envelope, int userCount, string name, string[] hosts)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `envelope` | `Response<RegisterServerResult>?` | — |
| `userCount` | `int` | — |
| `name` | `string` | — |
| `hosts` | `string[]` | — |
**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.
---
### OnUserCountChanged
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
private void OnUserCountChanged(int newCount)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `newCount` | `int` | — |
**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.
## 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.
## 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.
---
### ProcessUserCountUpdatesAsync
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
private async Task ProcessUserCountUpdatesAsync(HubConnection connection, Task connectionClosed, CancellationToken ct)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `connection` | `HubConnection` | — |
| `connectionClosed` | `Task` | — |
| `ct` | `CancellationToken` | — |
**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.
## 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.
---
### RegisterAsync
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
private async Task RegisterAsync(string name, string? description, string[] hosts, string version, string[] tags)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `name` | `string` | — |
| `description` | `string?` | — |
| `hosts` | `string[]` | — |
| `version` | `string` | — |
| `tags` | `string[]` | — |
**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.
---
### ResolveVersion
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
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.
## 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.
## 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.
---
### RunConnectionLoopAsync
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
private async Task RunConnectionLoopAsync(
string serverName,
string? description,
string[] hosts,
string version,
string[] tags,
CancellationToken stoppingToken)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `serverName` | `string` | — |
| `description` | `string?` | — |
| `hosts` | `string[]` | — |
| `version` | `string` | — |
| `tags` | `string[]` | — |
| `stoppingToken` | `CancellationToken` | — |
**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.
## 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
---
### StopAsync
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** method
```csharp
public override async Task StopAsync(CancellationToken cancellationToken)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `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.
## 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.
## Notes
- If base.StopAsync throws, _connection will not be cleared; consider wrapping the cleanup in a finally block to guarantee cleanup.
---
### DirectoryHubUrl
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** field
```csharp
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers"
```
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.
---
### ReconnectMaxDelay
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** field
```csharp
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.
## 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.
## 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.
---
## DirectoryProtocol
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
---
## DirectoryRegistrationErrors
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** class
```csharp
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.
## 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.
## Notes
- Not accessible from outside the server assembly; if you need client-visible error codes, expose a separate contract instead.
---
## ErrorDetail
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** record
```csharp
internal record ErrorDetail(string Code, string? Message, JsonElement? Data)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `Code` | `string` | — |
| [`Message`](../../EchoHub.Core/Models/Message.cs.md) | `string?` | — |
| `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.
## 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
);
```
## 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.
---
## RegisterServerDto
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** record
```csharp
internal record RegisterServerDto(
string Name,
string? Description,
string[] Hosts,
int UserCount,
string Version,
string[] Tags,
string? ClaimToken)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `Name` | `string` | — |
| `Description` | `string?` | — |
| `Hosts` | `string[]` | — |
| `UserCount` | `int` | — |
| `Version` | `string` | — |
| `Tags` | `string[]` | — |
| `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.
## 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.
## Example
```csharp
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
);
```
## 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.
---
## RegisterServerResult
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** record
```csharp
internal record RegisterServerResult(Guid ServerId, string? ClaimToken)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `ServerId` | `Guid` | — |
| `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.
## 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"
```
## 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.
---
## Response
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** record
```csharp
internal record Response<T>(bool IsSuccess, T? Data, ErrorDetail[]? Errors, string? Version)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `IsSuccess` | `bool` | — |
| `Data` | `T?` | — |
| `Errors` | `ErrorDetail[]?` | — |
| `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.
## 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");
```
## 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.
---
## 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>` | — |
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.
## 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.
## 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.
---
## UserCountMinInterval
> **File:** `src/EchoHub.Server/Services/ServerDirectoryService.cs`
> **Kind:** field
```csharp
private static readonly TimeSpan UserCountMinInterval = TimeSpan.FromSeconds(1)
```
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.
## 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.
## 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.
---
@@ -0,0 +1,99 @@
# ServerLogsService.cs
> **Source:** `src/EchoHub.Server/Services/ServerLogs/ServerLogsService.cs`
*Figure: How ServerLogsService works.*
```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
```
## Contents
- [ServerLogsService](#serverlogsservice)
- [LogBacklogEntry](#logbacklogentry)
---
## ServerLogsService
> **File:** `src/EchoHub.Server/Services/ServerLogs/ServerLogsService.cs`
> **Kind:** class
```csharp
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).
## 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"
```
## 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.
---
## LogBacklogEntry
> **File:** `src/EchoHub.Server/Services/ServerLogs/ServerLogsService.cs`
> **Kind:** record
```csharp
public record LogBacklogEntry(DateTimeOffset Timestamp, string Content)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `Timestamp` | `DateTimeOffset` | — |
| `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.
## 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.
## 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.
---
@@ -0,0 +1,19 @@
# ServerLogsSink
> **File:** `src/EchoHub.Server/Services/ServerLogs/ServerLogsSink.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
@@ -0,0 +1,48 @@
# ServerLogsStreamService
> **File:** `src/EchoHub.Server/Services/ServerLogs/ServerLogsStreamService.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
## 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.
## 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
- …and 7 more member(s) not shown
## Symbol To Document
- Name: ServerLogsStreamService
- Kind: class
- File: src/EchoHub.Server/Services/ServerLogs/ServerLogsStreamService.cs
- Language: csharp
- ID: fe54ac96-642e-4dbe-af25-3d2559e01299
@@ -0,0 +1,20 @@
# SignalRBroadcaster
> **File:** `src/EchoHub.Server/Services/SignalRBroadcaster.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
@@ -0,0 +1,99 @@
# SpamGuard.cs
> **Source:** `src/EchoHub.Server/Services/SpamGuard.cs`
## Contents
- [SpamGuard](#spamguard)
- [SpamVerdict](#spamverdict)
- [SpamVerdictKind](#spamverdictkind)
---
## SpamGuard
> **File:** `src/EchoHub.Server/Services/SpamGuard.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
---
## SpamVerdict
> **File:** `src/EchoHub.Server/Services/SpamGuard.cs`
> **Kind:** record
```csharp
public readonly record struct SpamVerdict(SpamVerdictKind Kind, string? Reason = null, TimeSpan MuteDuration = default)
{
public static readonly SpamVerdict Allowed = new(SpamVerdictKind.Allowed);
}
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `Kind` | `SpamVerdictKind` | — |
| `Reason` | `string?` | `null` |
| `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.
## 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;
```
## 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.
---
## SpamVerdictKind
> **File:** `src/EchoHub.Server/Services/SpamGuard.cs`
> **Kind:** enum
```csharp
public enum SpamVerdictKind
{
Allowed,
Rejected,
AutoMute,
}
```
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.
## 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.
## 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.
---
@@ -0,0 +1,75 @@
# ServerStatsCollector.cs
> **Source:** `src/EchoHub.Server/Services/Stats/ServerStatsCollector.cs`
## Contents
- [ServerStatsCollector](#serverstatscollector)
- [StatsCounters](#statscounters)
---
## ServerStatsCollector
> **File:** `src/EchoHub.Server/Services/Stats/ServerStatsCollector.cs`
> **Kind:** class
```csharp
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.
## 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.
---
## StatsCounters
> **File:** `src/EchoHub.Server/Services/Stats/ServerStatsCollector.cs`
> **Kind:** record
```csharp
public readonly record struct StatsCounters(
long Connections,
long Disconnections,
long Kicks,
long Bans,
int PeakOnline)
```
**Parameters:**
| Parameter | Type | Default |
|-----------|------|---------|
| `Connections` | `long` | — |
| `Disconnections` | `long` | — |
| `Kicks` | `long` | — |
| `Bans` | `long` | — |
| `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.
## 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.
## 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
---
@@ -0,0 +1,19 @@
# ServerStatsReportService
> **File:** `src/EchoHub.Server/Services/Stats/ServerStatsReportService.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
@@ -0,0 +1,104 @@
# UserService
> **File:** `src/EchoHub.Server/Services/UserService.cs`
> **Kind:** class
*Figure: How UserService works.*
```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)"]
```
```csharp
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.
## 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.
## Example
```csharp
// Typical usage from an async context where `userService` is resolved from DI
var result = await userService.RegisterUserAsync("alice", "s3cretP@ss", displayName: "Alice");
if (result.IsSuccess)
{
var profile = result; // UserOperationResult.Success wraps the created UserProfileDto
// proceed with signed-in flow
}
else
{
// registration failed; map user-visible error to response
}
```
## 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.
@@ -0,0 +1,69 @@
# DataMigrationService
> **File:** `src/EchoHub.Server/Setup/DataMigrationService.cs`
> **Kind:** class
*Figure: How DataMigrationService works.*
```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 --> 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
```
```csharp
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.
## 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.
## 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.
@@ -0,0 +1,18 @@
# DatabaseSetup
> **File:** `src/EchoHub.Server/Setup/DatabaseSetup.cs`
> **Kind:** class
```csharp
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.
## 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.
## 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.
@@ -0,0 +1,25 @@
# FirstRunSetup
> **File:** `src/EchoHub.Server/Setup/FirstRunSetup.cs`
> **Kind:** class
```csharp
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.
## 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();
```
## 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.