mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-05 23:34:09 +02:00
docs: Update documentation for 145 files
Generated by AurionDocs
Job ID: c99fff50-67a3-4294-b4df-3e73f4f12de9
Source commit: 4dcb480
This commit is contained in:
@@ -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 channel’s 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 channel’s 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 channel’s 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 channel’s 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 broadcaster’s 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 target’s content to obtain a plaintext snippet, conditionally truncating it, and then re-encrypting the result into a ReplyRefDto that carries the target’s 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 entry’s 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 entry’s 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 stream’s 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 user’s 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 host’s 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 server’s 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; it’s 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.
|
||||
+48
@@ -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.
|
||||
Reference in New Issue
Block a user