Compare commits

32 Commits
Author SHA1 Message Date
HueByte 3b45bb5661 feat: update DirectoryHubUrl to point to the production server 2026-04-24 16:39:16 +02:00
Hue 6235565480 Merge pull request #46 from HueByte/dev
Dev merge
2026-04-24 16:13:39 +02:00
HueByte ecb20c4c52 feat: update .env.example with additional server configuration comments and add release checklist workflow 2026-04-24 15:51:01 +02:00
HueByte a85d16fff8 feat: update installation script and documentation for version 0.2.11 release 2026-04-24 15:51:01 +02:00
HueByte 6db93ecfea feat: enhance server registration handling with response envelope and error management 2026-04-24 15:51:01 +02:00
HueByte 67587dafc2 feat: implement DirectoryClaimStore for managing directory claims and enhance ServerDirectoryService with registration error handling 2026-04-24 15:51:01 +02:00
HueByte 1bbe099835 feat: enhance presence tracking and server registration with user count updates and multi-host support 2026-04-24 15:51:01 +02:00
Hue 769aa5b468 Merge pull request #44 from HueByte/dev
fix: resolve client startup crash by explicitly passing Configuration…
2026-04-20 18:18:01 +02:00
Hue 6660944588 Merge branch 'master' into dev 2026-04-20 18:16:20 +02:00
HueByte 4b41438af0 fix: resolve client startup crash by explicitly passing ConfigurationReaderOptions for Serilog 2026-04-20 18:14:54 +02:00
Hue a4fe432992 Merge pull request #43 from HueByte/dev
Dev v0.2.10
2026-04-20 17:45:39 +02:00
HueByte 9279e8be06 fix: update release notes for v0.2.10 to reflect Command Palette and Infinite History Scroll features 2026-04-20 17:42:06 +02:00
Hue 7f9fcfe3cc Merge pull request #36 from HueByte/dev_fix_notification_sound
fix: error when playing notification sounds in quick succession
2026-04-20 17:42:06 +02:00
Hue 62c5ab27c5 Merge pull request #39 from HueByte/dev_load_more_messages_on_scroll
feat: load more messages on scroll
2026-04-20 17:42:05 +02:00
Stone_Red 240892495b chore: add notification bug fix to changelog 2026-04-20 17:42:05 +02:00
Stone_Red 6e7cbf39f0 chore: add channel history loading and refactor GetChannelHistory with offset to changelog 2026-04-20 17:42:05 +02:00
Stone_Red 83d257591e fix: error when playing notification sounds in quick succession 2026-04-20 17:42:05 +02:00
Stone_Red e42f1a0965 feat: load more message history when scrolling to top 2026-04-20 17:42:05 +02:00
Hue fe6dfd3d4d Merge pull request #40 from HueByte/dev_search_dialog
feat: add search dialog for quick navigation
2026-04-20 17:42:04 +02:00
Stone_Red d6282885e3 refactor: move search dialog handling to AppOrchestrator 2026-04-20 17:42:04 +02:00
Stone_Red bd8b88add2 chore: add command palette with Ctrl+K for quick navigation and actions to changelog 2026-04-20 17:42:04 +02:00
Stone_Red ff9a9e3dd0 chore: mark search bar/modal as completed in the todo list 2026-04-20 17:42:04 +02:00
Stone_Red aa6599a4e0 feat: search dialog to navigate app 2026-04-20 17:42:04 +02:00
Hue 3091a146eb Merge pull request #41 from HueByte/dev_move_cursor_to_end_on_autocomplete
fix: set input field insertion point to end after autocomplete
2026-04-20 17:42:03 +02:00
HueByte 000764fdb4 chore: update version to 0.2.10 and enhance installation scripts and changelog 2026-04-20 17:42:03 +02:00
Stone_Red 56fccf5cfb chore: add cursor position fix to changelog 2026-04-20 17:42:03 +02:00
Stone_Red b98673f8c5 fix: set input field insertion point to end after autocomplete 2026-04-20 17:42:03 +02:00
Stone_Red 335cfcc28a Merge pull request #42 from HueByte/dev_fix_update_service
fix: update service
2026-04-20 17:41:23 +02:00
HueByte 6aef6890cf fix: ensure progress dialog updates on the UI thread 2026-04-20 17:41:22 +02:00
Stone_Red 5b8df9d505 fix: remove unnecessary _app.Invoke calls around update progress dialog updates 2026-04-20 17:41:22 +02:00
Stone_Red 6dbc29818c fix: error when trying to zip open log file 2026-04-20 17:41:22 +02:00
HueByte 6758aceb5d chore: add v0.2.9 release notes to changelog and table of contents 2026-04-07 15:41:18 +02:00
33 changed files with 1209 additions and 71 deletions
+6 -1
View File
@@ -6,7 +6,12 @@
Server__Name=My EchoHub Server Server__Name=My EchoHub Server
Server__Description=A self-hosted EchoHub chat server Server__Description=A self-hosted EchoHub chat server
Server__PublicServer=false Server__PublicServer=false
# Server__PublicHost=echohub.example.com # Hostnames advertised to the EchoHubSpace directory. Index per entry.
# Server__PublicHosts__0=echohub.example.com
# Server__PublicHosts__1=alias.example.com
# Topic tags surfaced in the EchoHubSpace browser. Index per entry.
# Server__Tags__0=community
# Server__Tags__1=gaming
# Server__Admins__0=adminUsername # Server__Admins__0=adminUsername
# ── JWT ────────────────────────────────────────────────────────────── # ── JWT ──────────────────────────────────────────────────────────────
+60
View File
@@ -0,0 +1,60 @@
name: Release Checklist
on:
pull_request:
branches: [master]
workflow_dispatch:
jobs:
release-checklist:
name: Release Checklist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Extract version
id: version
run: |
VERSION=$(grep -oP '(?<=<Version>)[^<]+' src/Directory.Build.props)
if [ -z "$VERSION" ]; then
echo "::error file=src/Directory.Build.props::Could not read version from Directory.Build.props"
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "Version: $VERSION"
- name: Check version was bumped from master
run: |
BRANCH_VERSION="${{ steps.version.outputs.version }}"
git fetch origin master --depth=1
MASTER_VERSION=$(git show origin/master:src/Directory.Build.props | grep -oP '(?<=<Version>)[^<]+')
echo "Branch: $BRANCH_VERSION | Master: $MASTER_VERSION"
if [ "$BRANCH_VERSION" = "$MASTER_VERSION" ]; then
echo "::error file=src/Directory.Build.props::Version $BRANCH_VERSION was not bumped from master. Update <Version> in src/Directory.Build.props."
exit 1
fi
- name: Check changelog file exists
run: |
VERSION="${{ steps.version.outputs.version }}"
FILE="docs/changelog/v${VERSION}.md"
if [ ! -f "$FILE" ]; then
echo "::error::Missing changelog file: $FILE"
exit 1
fi
echo "Found: $FILE"
- name: Check changelog TOC
run: |
VERSION="${{ steps.version.outputs.version }}"
if ! grep -q "v${VERSION}.md" docs/changelog/toc.yml; then
echo "::error file=docs/changelog/toc.yml::v${VERSION} not found in changelog TOC. Add it to docs/changelog/toc.yml."
exit 1
fi
if ! grep -q "v${VERSION}" docs/changelog/index.md; then
echo "::error file=docs/changelog/index.md::v${VERSION} not found in changelog index. Add it to docs/changelog/index.md."
exit 1
fi
echo "toc.yml and index.md: OK"
+1 -1
View File
@@ -17,7 +17,7 @@ curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/inst
To install a specific version or to a custom directory: To install a specific version or to a custom directory:
```bash ```bash
curl -sSfL .../install.sh | sh -s -- --version 0.2.8 curl -sSfL .../install.sh | sh -s -- --version 0.2.11
curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub
``` ```
+3
View File
@@ -4,6 +4,9 @@ Release history for EchoHub.
## Releases ## Releases
- [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata
- [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes
- [v0.2.9](v0.2.9.md) - Install Script & Chocolatey Fixes
- [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix - [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix
- [v0.2.7](v0.2.7.md) - User List Fix & Terminal.Gui NuGet Migration - [v0.2.7](v0.2.7.md) - User List Fix & Terminal.Gui NuGet Migration
- [v0.2.6](v0.2.6.md) - Major Refactoring & Code Organization - [v0.2.6](v0.2.6.md) - Major Refactoring & Code Organization
+6
View File
@@ -1,5 +1,11 @@
- name: Overview - name: Overview
href: index.md href: index.md
- name: v0.2.11
href: v0.2.11.md
- name: v0.2.10
href: v0.2.10.md
- name: v0.2.9
href: v0.2.9.md
- name: v0.2.8 - name: v0.2.8
href: v0.2.8.md href: v0.2.8.md
- name: v0.2.7 - name: v0.2.7
+23
View File
@@ -0,0 +1,23 @@
# v0.2.10
Follow-up patch release for v0.2.9 addressing auto-updater regressions, adding a command palette, infinite-scroll message history, and input polish.
## New Features
- Command palette — press Ctrl+K from the message input (or anywhere in the main window) to open a searchable dialog for navigating channels and triggering app actions (connect, disconnect, logout, profile, status, create/delete channel, saved servers, toggle users panel, check for updates, quit). Fuzzy matches against both the label and the underlying key so typing `ch` surfaces channel actions alongside `#channel` entries
- Scroll-to-load message history — scrolling to the top of a channel now fetches the next batch of older messages in the background (previously only the most recent 100 messages were available). Duplicate messages are filtered by ID, a per-channel guard prevents concurrent fetches, and the scroll position is preserved after the prepend so your reading position doesn't jump
## Bug Fixes
- Fix update progress dialog freezing / not repainting — progress callbacks now run on the UI thread so the download and extraction percentage actually updates while an update is in progress
- Fix pre-update backup failing when a Serilog-held log file is locked — `UpdateBackupService` now enumerates files manually, skips the `logs/` directory and `.log` files, and logs-and-continues on `IOException`/`UnauthorizedAccessException` instead of aborting the whole backup
- Simplify update progress dispatch — remove redundant `Application.Invoke` wrappers around progress updates that are already called from the UI thread (introduced while fixing the freeze above)
- Fix cursor position being reset to the start of the line when auto-completing commands in the CLI app — insertion point is now moved to the end of the completed text
- Fix notification sounds crashing or being silently dropped when several arrive in quick succession — playback is now serialized through a semaphore that's held for the duration of each sound (using `PlaybackFinished` with a 10s safety timeout) and always released in `finally`, so back-to-back notifications queue up and play in order instead of racing the underlying audio player (fixes #20)
- Fix client crashing on startup with `No Serilog:Using configuration section is defined` under single-file publish — pass `ConfigurationReaderOptions` with the `Serilog.Sinks.File` assembly explicitly so Serilog can resolve sinks without scanning the filesystem for `.dll`s (which don't exist in a bundled exe)
## Refactoring
- Move search-dialog dispatch out of `MainWindow` into `AppOrchestrator``MainWindow` now just raises `OnSearchRequested`, keeping the view dumb and letting the orchestrator own navigation/action routing
- `ChatHub.GetChannelHistory` and `IChatService.GetChannelHistoryAsync` gain an additional `offset` parameter for paginated history loading (defaults to `0` — existing callers are unaffected)
- `ValidationConstants.MaxHistoryCount` raised from `100` to `200` so power users and paginated fetches can request larger batches; `DefaultHistoryCount` stays at `100`
+23
View File
@@ -0,0 +1,23 @@
# v0.2.11
EchoHubSpace directory protocol overhaul: authenticated server registration with persistent claim tokens, near-real-time user-count updates, and richer server metadata (tags, multi-host, version). Coordinated cutover with the EchoHubSpace directory deploy.
## New Features
- EchoHubSpace claim-token authentication — the directory issues a per-server claim token on first registration, persisted atomically alongside the SQLite database (chmod 0600 on Unix). Subsequent reconnects authenticate with the token instead of relying on raw hostname-squatting protection. Token survives both client and directory restarts; lost tokens require an admin-side `DELETE /api/servers/{id}` on the directory to recover
- Server tags — public servers can advertise topic tags via the new `Server:Tags` config array, surfacing as filter facets in the EchoHubSpace browser
- Multi-host advertisement — a single server can register multiple hostnames (e.g. apex domain, IPv6, alias domains) by listing them in `Server:PublicHosts`. All hosts route to the same directory row
- Server version sent to directory — the EchoHubSpace browser shows what version each public server is running, pulled from the server's assembly informational version
- Operator-facing `GET /api/server/directory` endpoint (Admin role required) — returns `ServerId`, `IsRegistered`, `LastRegisteredAt`, `LastError`, and any `ConflictingHosts` for support tickets. Never exposes the claim token itself, only a `HasClaimToken` boolean
## Refactoring
- Replace 30s polling with event-driven directory updates — `PresenceTracker` now raises `UserCountChanged` only when the distinct user count actually changes (multi-tab/multi-connection users no longer trigger). `ServerDirectoryService` consumes via a single-slot `Channel<int>` (latest-wins coalesces bursts) with a 1-second min-interval throttle. Directory reflects user-count changes within ~1s instead of up to 30s stale
- Wrap directory hub responses in a `Response<T>` envelope with `IsSuccess`/`Data`/`Errors`/`Version` shape — protocol version is pinned client-side (currently `1.0`); mismatches trigger a permanent-failure stop with operator-facing log
- Stop attempting re-registration after permanent failures (`HostAlreadyClaimed`, `InvalidToken`, `HostConflict`, `InvalidInput`) — the directory no longer terminates the connection on these errors, so the client suppresses re-register on `Reconnected` to avoid tight retry loops. Operator must restart the server after fixing config
## Configuration
- **Breaking**: `Server:PublicHost` (string) renamed to `Server:PublicHosts` (string array). Public servers must update `appsettings.json` — single-host deployments use a one-element array
- New `Server:Tags` (string array) — defaults to empty
- New optional `Server:DirectoryClaimPath` — overrides the path of the persisted claim file. Defaults to a `directory-claim.json` next to the SQLite database. Treat the file as a secret; back it up alongside the database
+18
View File
@@ -3,3 +3,21 @@
## Bug Fixes ## Bug Fixes
- Fix Linux/macOS client install — enable single-file publish so the install script copies one self-contained binary instead of just the native host (which failed with "does not exist: EchoHub.Client.dll") - Fix Linux/macOS client install — enable single-file publish so the install script copies one self-contained binary instead of just the native host (which failed with "does not exist: EchoHub.Client.dll")
- Fix Chocolatey install path on Windows — `chocolateyInstall.ps1` was joining the install directory and executable name into a single segment, producing an invalid target path
- Fix Chocolatey package metadata — corrected GitHub repository URLs and documentation URLs in `echohub.nuspec` that pointed at the wrong location
- Fix double-click on "Public" checkbox in the Create Channel dialog accidentally submitting the dialog — checkbox toggle commands no longer bubble up to the dialog's default button
## Documentation
- Add a dedicated configuration guide (`docs/articles/configuration.md`) covering server settings, client settings, and environment overrides
- Refresh README badges and reorganize the articles table of contents for better discoverability
- Polish Docker, getting-started, and flow docs to match the current configuration surface
## Dependencies
- Bump `Terminal.Gui` to `2.0.0-develop.5043` (from `5039`)
## CI
- Release workflow now publishes a single-file self-contained client binary for Linux and macOS so the install script works out-of-the-box
- Chocolatey publishing step now triggers only when the package source actually changes and performs a proper version check against the feed before pushing
+1 -1
View File
@@ -17,7 +17,7 @@
- that means basically multiple servers linked, so users can chat cross-server in this network - that means basically multiple servers linked, so users can chat cross-server in this network
- [x] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch - [x] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch
- [ ] add keyboard only controls | at least for most important parts and the rest might be accessible with: (down) - [ ] add keyboard only controls | at least for most important parts and the rest might be accessible with: (down)
- [ ] add search bar / search modal that will allow users to instantly navigate to room / focus on app element & etc - [x] add search bar / search modal that will allow users to instantly navigate to room / focus on app element & etc
- [ ] Actually smart data management cache messages, lazy load messages on scroll (currently hardcoded 100msgs fetched + new ones) - [ ] Actually smart data management cache messages, lazy load messages on scroll (currently hardcoded 100msgs fetched + new ones)
- [x] Another thing would be stateful userlist basically fetch once and listen for userlist updates - [x] Another thing would be stateful userlist basically fetch once and listen for userlist updates
- [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update - [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update
+1 -1
View File
@@ -30,7 +30,7 @@ while [ $# -gt 0 ]; do
sed -n '2,8p' "$0" 2>/dev/null || true sed -n '2,8p' "$0" 2>/dev/null || true
echo "" echo ""
echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh" echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh"
echo " curl ... | sh -s -- --version 0.2.8" echo " curl ... | sh -s -- --version 0.2.11"
echo " curl ... | sh -s -- --install-dir /opt/echohub" echo " curl ... | sh -s -- --install-dir /opt/echohub"
exit 0 exit 0
;; ;;
+1 -1
View File
@@ -1,6 +1,6 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<Version>0.2.9</Version> <Version>0.2.11</Version>
<GenerateDocumentationFile>true</GenerateDocumentationFile> <GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);CS1591</NoWarn> <NoWarn>$(NoWarn);CS1591</NoWarn>
</PropertyGroup> </PropertyGroup>
+60
View File
@@ -30,6 +30,7 @@ public sealed class AppOrchestrator : IDisposable
private readonly ConnectionManager _conn = new(); private readonly ConnectionManager _conn = new();
private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary<string, List<UserPresenceDto>> _channelUsers = new(StringComparer.OrdinalIgnoreCase);
private readonly Lock _channelUsersLock = new(); private readonly Lock _channelUsersLock = new();
private readonly HashSet<string> _channelsLoadingMore = new(StringComparer.OrdinalIgnoreCase);
private ClientConfig _config; private ClientConfig _config;
private readonly UserSession _session = new(); private readonly UserSession _session = new();
@@ -91,6 +92,8 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.OnRollbackRequested += HandleRollbackRequested; _mainWindow.OnRollbackRequested += HandleRollbackRequested;
_mainWindow.OnUserProfileRequested += HandleViewProfile; _mainWindow.OnUserProfileRequested += HandleViewProfile;
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage; _mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
_mainWindow.OnSearchRequested += HandleSearchRequested;
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
} }
// ── Command Handler Wiring ───────────────────────────────────────────── // ── Command Handler Wiring ─────────────────────────────────────────────
@@ -720,6 +723,31 @@ public sealed class AppOrchestrator : IDisposable
}, "Failed to join channel"); }, "Failed to join channel");
} }
private void HandleLoadMoreRequested()
{
if (!_conn.IsConnected) return;
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return;
if (!_channelsLoadingMore.Add(channel)) return;
var offset = _messageManager.GetMessages(channel)?.Count ?? 0;
RunAsync(async () =>
{
try
{
var history = await _conn.GetHistoryAsync(channel, HubConstants.DefaultHistoryCount, offset);
InvokeUI(() => _messageManager.PrependHistory(channel, history));
}
finally
{
_channelsLoadingMore.Remove(channel);
}
}, "Failed to load more messages");
}
private void HandleChannelJoinFromMessage(string channelName) private void HandleChannelJoinFromMessage(string channelName)
{ {
if (!_conn.IsConnected) return; if (!_conn.IsConnected) return;
@@ -733,6 +761,38 @@ public sealed class AppOrchestrator : IDisposable
HandleChannelSelected(channelName); HandleChannelSelected(channelName);
} }
private void HandleSearchRequested()
{
var result = SearchDialog.Show(_app, _mainWindow.GetChannelNames());
if (result is null) return;
switch (result.Type)
{
case SearchResultType.Channel:
_mainWindow.SwitchToChannel(result.Key);
HandleChannelSelected(result.Key);
break;
case SearchResultType.Action:
switch (result.Key)
{
case "connect": HandleConnect(); break;
case "disconnect": HandleDisconnect(); break;
case "logout": HandleLogout(); break;
case "profile": HandleProfileRequested(); break;
case "status": HandleStatusRequested(); break;
case "create-channel": HandleCreateChannelRequested(); break;
case "delete-channel": HandleDeleteChannelRequested(); break;
case "servers": HandleSavedServersRequested(); break;
case "toggle-users": _mainWindow.ToggleUsersPanel(); break;
case "updates": HandleCheckForUpdatesRequested(); break;
case "quit": _app.RequestStop(); break;
}
break;
}
}
private void HandleProfileRequested() private void HandleProfileRequested()
{ {
HandleViewProfile(null); HandleViewProfile(null);
+5 -1
View File
@@ -4,6 +4,7 @@ using EchoHub.Client.Services;
using EchoHub.Client.Themes; using EchoHub.Client.Themes;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Serilog; using Serilog;
using Serilog.Settings.Configuration;
using Terminal.Gui.App; using Terminal.Gui.App;
// == CLI rollback: works without TUI, before anything else ================ // == CLI rollback: works without TUI, before anything else ================
@@ -71,8 +72,11 @@ var configuration = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: true, reloadOnChange: false) .AddJsonFile("appsettings.json", optional: true, reloadOnChange: false)
.Build(); .Build();
// Explicit sink-assembly reference is required under PublishSingleFile — the default
// AssemblyFinder scans for Serilog.Sinks.*.dll on disk, which don't exist in a bundled exe.
var serilogOptions = new ConfigurationReaderOptions(typeof(FileLoggerConfigurationExtensions).Assembly);
Log.Logger = new LoggerConfiguration() Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration) .ReadFrom.Configuration(configuration, serilogOptions)
.CreateLogger(); .CreateLogger();
Log.Information("EchoHub client starting"); Log.Information("EchoHub client starting");
@@ -196,8 +196,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
_connection?.SendMessageAsync(channel, content) _connection?.SendMessageAsync(channel, content)
?? throw new InvalidOperationException("Not connected"); ?? throw new InvalidOperationException("Not connected");
public Task<List<MessageDto>> GetHistoryAsync(string channel) => public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
_connection?.GetHistoryAsync(channel) _connection?.GetHistoryAsync(channel, count, offset)
?? throw new InvalidOperationException("Not connected"); ?? throw new InvalidOperationException("Not connected");
public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) => public Task<List<UserPresenceDto>> GetOnlineUsersAsync(string channel) =>
@@ -154,9 +154,9 @@ public sealed class EchoHubConnection : IAsyncDisposable
await _connection.InvokeAsync("SendMessage", channelName, encrypted); await _connection.InvokeAsync("SendMessage", channelName, encrypted);
} }
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount) public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
{ {
var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count); var messages = await _connection.InvokeAsync<List<MessageDto>>("GetChannelHistory", channelName, count, offset);
return DecryptMessages(messages); return DecryptMessages(messages);
} }
@@ -6,7 +6,11 @@ namespace EchoHub.Client.Services;
public class NotificationSoundService public class NotificationSoundService
{ {
// Safety net: if PlaybackFinished never fires we don't want to block future notifications forever.
private static readonly TimeSpan PlaybackTimeout = TimeSpan.FromSeconds(10);
private readonly Player _player = new(); private readonly Player _player = new();
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly NotificationConfig _config; private readonly NotificationConfig _config;
private string? _resolvedSoundPath; private string? _resolvedSoundPath;
@@ -41,18 +45,31 @@ public class NotificationSoundService
private async Task PlayInternal() private async Task PlayInternal()
{ {
await _lock.WaitAsync();
// _player.Play returns as soon as playback starts, so we wait on PlaybackFinished
// to hold the lock for the duration of the sound. A one-shot handler + timeout
// keeps the finally release robust: never-fires → timeout; fires twice → ignored
// (TrySetResult); handler throws → caller's catch still runs finally.
var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void OnFinished(object? s, EventArgs e) => completion.TrySetResult();
_player.PlaybackFinished += OnFinished;
try try
{ {
if (_player.Playing)
await _player.Stop();
await _player.SetVolume(_config.Volume); await _player.SetVolume(_config.Volume);
await _player.Play(_resolvedSoundPath!); await _player.Play(_resolvedSoundPath!);
await Task.WhenAny(completion.Task, Task.Delay(PlaybackTimeout));
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Warning(ex, "Failed to play notification sound"); Log.Warning(ex, "Failed to play notification sound");
} }
finally
{
_player.PlaybackFinished -= OnFinished;
_lock.Release();
}
} }
private void ResolveSoundPath() private void ResolveSoundPath()
@@ -40,7 +40,37 @@ public static class UpdateBackupService
Log.Information("Creating pre-update backup of {AppDir} (v{Version})", appDir, version); Log.Information("Creating pre-update backup of {AppDir} (v{Version})", appDir, version);
ZipFile.CreateFromDirectory(appDir, BackupZipPath, CompressionLevel.Fastest, includeBaseDirectory: false); using (var archive = ZipFile.Open(BackupZipPath, ZipArchiveMode.Create))
{
foreach (var file in Directory.EnumerateFiles(appDir, "*", SearchOption.AllDirectories))
{
var relativePath = Path.GetRelativePath(appDir, file);
// Skip log files to prevent locking errors with Serilog while zipping
if (relativePath.StartsWith("logs" + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
relativePath.StartsWith("logs" + Path.AltDirectorySeparatorChar, StringComparison.OrdinalIgnoreCase) ||
relativePath.EndsWith(".log", StringComparison.OrdinalIgnoreCase))
{
continue;
}
// Normalize path separators for the zip archive format
var entryName = relativePath.Replace(Path.DirectorySeparatorChar, '/').Replace(Path.AltDirectorySeparatorChar, '/');
try
{
archive.CreateEntryFromFile(file, entryName, CompressionLevel.Fastest);
}
catch (IOException ex)
{
Log.Warning(ex, "Skipped locked file {FileName} during backup calculation", relativePath);
}
catch (UnauthorizedAccessException ex)
{
Log.Warning(ex, "Skipped inaccessible file {FileName} during backup calculation", relativePath);
}
}
}
var info = new BackupInfo(version, appDir, DateTimeOffset.UtcNow); var info = new BackupInfo(version, appDir, DateTimeOffset.UtcNow);
var json = JsonSerializer.Serialize(info, BackupJsonContext.Default.BackupInfo); var json = JsonSerializer.Serialize(info, BackupJsonContext.Default.BackupInfo);
+9 -15
View File
@@ -71,7 +71,7 @@ public sealed class UpdateChecker : IDisposable
// Create backup before the update starts // Create backup before the update starts
try try
{ {
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Creating backup...")); _progressDialog?.UpdateProgress(0f, "Creating backup...");
UpdateBackupService.CreateBackup(); UpdateBackupService.CreateBackup();
} }
catch (Exception ex) catch (Exception ex)
@@ -79,27 +79,21 @@ public sealed class UpdateChecker : IDisposable
Log.Error(ex, "Failed to create pre-update backup"); Log.Error(ex, "Failed to create pre-update backup");
var proceed = false; var proceed = false;
_app.Invoke(() => proceed = MessageBox.Query(
{ _app,
proceed = MessageBox.Query( "Backup Warning",
_app, $"Could not create backup: {ex.Message}\n\nContinue update without backup?",
"Backup Warning", "Continue", "Cancel") == 0;
$"Could not create backup: {ex.Message}\n\nContinue update without backup?",
"Continue", "Cancel") == 0;
});
if (!proceed) if (!proceed)
{ {
_app.Invoke(() => _progressDialog?.Close();
{ _progressDialog = null;
_progressDialog?.Close();
_progressDialog = null;
});
return; return;
} }
} }
_app.Invoke(() => _progressDialog?.UpdateProgress(0f, "Downloading update...")); _progressDialog?.UpdateProgress(0f, "Downloading update...");
await _updater.UpdateAsync(); await _updater.UpdateAsync();
}); });
@@ -186,6 +186,39 @@ public sealed class ChatMessageManager
MessagesChanged?.Invoke(channelName); MessagesChanged?.Invoke(channelName);
} }
/// <summary>
/// Prepend older messages at the front of a channel's buffer, skipping any that are already present.
/// Fires <see cref="HistoryPrepended"/> when new lines are actually inserted.
/// </summary>
public void PrependHistory(string channelName, List<MessageDto> olderMessages)
{
if (!_channelMessages.TryGetValue(channelName, out var existing))
return;
var existingIds = existing
.Where(l => l.MessageId.HasValue)
.Select(l => l.MessageId!.Value)
.ToHashSet();
var newLines = olderMessages
.Where(m => !existingIds.Contains(m.Id))
.SelectMany(FormatMessage)
.ToList();
if (newLines.Count == 0)
return;
existing.InsertRange(0, newLines);
if (channelName == _currentChannel)
HistoryPrepended?.Invoke(channelName);
}
/// <summary>
/// Fired after older messages are prepended to a channel's buffer. Parameter is the channel name.
/// </summary>
public event Action<string>? HistoryPrepended;
/// <summary> /// <summary>
/// Reset all message state (used on disconnect). /// Reset all message state (used on disconnect).
/// </summary> /// </summary>
@@ -0,0 +1,160 @@
using EchoHub.Client.UI.ListSources;
using System.Collections;
using System.Collections.Specialized;
using System.Diagnostics;
using Terminal.Gui.App;
using Terminal.Gui.Drawing;
using Terminal.Gui.Input;
using Terminal.Gui.Text;
using Terminal.Gui.ViewBase;
using Terminal.Gui.Views;
namespace EchoHub.Client.UI.Dialogs;
public enum SearchResultType
{
Channel,
Action
}
public record SearchResult(SearchResultType Type, string Key, string Label);
/// <summary>
/// Command-palette style search dialog (Ctrl+K) for navigating channels and triggering app actions.
/// </summary>
public static class SearchDialog
{
private static readonly IReadOnlyList<SearchResult> DefaultActions = [
new(SearchResultType.Action, "connect", "Connect to Server"),
new(SearchResultType.Action, "disconnect", "Disconnect"),
new(SearchResultType.Action, "logout", "Logout"),
new(SearchResultType.Action, "profile", "My Profile"),
new(SearchResultType.Action, "status", "Set Status"),
new(SearchResultType.Action, "create-channel", "Create Channel"),
new(SearchResultType.Action, "delete-channel", "Delete Channel"),
new(SearchResultType.Action, "servers", "Saved Servers"),
new(SearchResultType.Action, "toggle-users", "Toggle Users Panel"),
new(SearchResultType.Action, "updates", "Check for Updates"),
new(SearchResultType.Action, "quit", "Quit"),
];
public static SearchResult? Show(IApplication app, IReadOnlyList<string> channels)
{
SearchResult? result = null;
var source = new SearchListSource(BuildAllItems(channels));
var dialog = new Dialog
{
Title = "Search",
Width = 59,
Height = 22,
};
var hintLabel = new Label
{
Text = "Channels and actions \u2502 \u2193 to navigate \u2502 Enter to select",
X = 1,
Y = 1,
};
var searchField = new TextField
{
X = 1,
Y = 2,
Title = "Search",
Width = Dim.Fill(2),
};
var resultList = new ListView
{
X = 1,
Y = 4,
Width = Dim.Fill(2),
Height = Dim.Fill(3),
Source = source
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center(),
Y = Pos.AnchorEnd(1),
};
if (source.Count > 0)
resultList.SelectedItem = 0;
searchField.KeyDown += (s, e) =>
{
if (e.KeyCode == Key.K.WithCtrl)
{
e.Handled = true;
app.RequestStop();
}
};
searchField.TextChanged += (s, e) =>
{
source.Filter(searchField.Text ?? string.Empty);
resultList.Source = source;
if (source.Count > 0)
resultList.SelectedItem = 0;
};
searchField.Accepting += (s, e) => TryConfirm(e);
resultList.Accepting += (s, e) => TryConfirm(e);
resultList.KeystrokeNavigator.SearchStringChanged += (s, e) =>
{
app.Invoke(() =>
{
searchField.SetFocus();
});
};
cancelButton.Accepting += (s, e) =>
{
result = null;
e.Handled = true;
app.RequestStop();
};
dialog.KeyDown += (s, e) =>
{
if (e.KeyCode == Key.K.WithCtrl)
{
e.Handled = true;
app.RequestStop();
}
};
dialog.Add(hintLabel, searchField, resultList, cancelButton);
searchField.SetFocus();
app.Run(dialog);
return result;
void TryConfirm(CommandEventArgs e)
{
var idx = resultList.SelectedItem ?? 0;
if (source.Count > 0 && idx >= 0 && idx < source.Count)
{
result = source.GetItem(idx);
e.Handled = true;
app.RequestStop();
}
}
}
private static List<SearchResult> BuildAllItems(IReadOnlyList<string> channels)
{
var items = new List<SearchResult>();
foreach (var ch in channels)
items.Add(new SearchResult(SearchResultType.Channel, ch, $"#{ch}"));
items.AddRange(DefaultActions);
return items;
}
}
@@ -0,0 +1,87 @@
using EchoHub.Client.UI.Chat;
using EchoHub.Client.UI.Dialogs;
using System.Collections;
using System.Collections.Specialized;
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.ListSources;
/// <summary>
/// List data source for the search dialog with filtering and colored rendering.
/// </summary>
public class SearchListSource(List<SearchResult> items) : IListDataSource
{
private readonly List<SearchResult> _allItems = items;
private List<SearchResult> _filtered = [.. items];
private static readonly Attribute ChannelAttribute = new(Color.BrightCyan, Color.None);
private static readonly Attribute ActionAttribute = new(Color.White, Color.None);
public event NotifyCollectionChangedEventHandler? CollectionChanged;
public int Count => _filtered.Count;
public int MaxItemLength => _filtered.Count > 0 ? _filtered.Max(i => i.Label.GetColumns()) : 0;
public bool SuspendCollectionChangedEvent { get; set; }
public void Filter(string query)
{
if (string.IsNullOrWhiteSpace(query))
{
_filtered = [.. _allItems];
}
else
{
_filtered = [.. _allItems.Where(i =>
i.Label.Contains(query, StringComparison.OrdinalIgnoreCase)
|| i.Key.Contains(query, StringComparison.OrdinalIgnoreCase))];
}
if (!SuspendCollectionChangedEvent)
CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
}
public SearchResult? GetItem(int index) => index >= 0 && index < _filtered.Count ? _filtered[index] : null;
public bool IsMarked(int item) => false;
public void SetMark(int item, bool value) { }
public IList ToList() => _filtered.Select(i => (object)i.Label).ToList();
public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0)
{
listView.Move(Math.Max(col - viewportX, 0), row);
var entry = _filtered[item];
var fillAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal);
Attribute itemAttr;
if (selected)
{
itemAttr = fillAttr;
}
else
{
var raw = entry.Type switch
{
SearchResultType.Channel => ChannelAttribute,
SearchResultType.Action => ActionAttribute,
_ => fillAttr
};
itemAttr = raw.Background == Color.None ? raw with { Background = fillAttr.Background } : raw;
}
listView.SetAttribute(itemAttr);
var drawn = RenderHelpers.WriteText(listView, entry.Label, 0, width);
listView.SetAttribute(fillAttr);
for (var i = drawn; i < width; i++)
listView.AddStr(" ");
}
public void Dispose() { }
}
+61 -1
View File
@@ -1,3 +1,4 @@
using System.Diagnostics;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using EchoHub.Client.Services; using EchoHub.Client.Services;
using EchoHub.Client.Themes; using EchoHub.Client.Themes;
@@ -48,6 +49,7 @@ public sealed partial class MainWindow : Runnable
private static readonly Key NewlineKey = Key.N.WithCtrl; private static readonly Key NewlineKey = Key.N.WithCtrl;
private static readonly Key AltQKey = Key.Q.WithAlt; private static readonly Key AltQKey = Key.Q.WithAlt;
private static readonly Key TabKey = Key.Tab; private static readonly Key TabKey = Key.Tab;
private static readonly Key CtrlKKey = Key.K.WithCtrl;
// Available slash commands for Tab autocomplete // Available slash commands for Tab autocomplete
private static readonly string[] SlashCommands = private static readonly string[] SlashCommands =
@@ -116,6 +118,11 @@ public sealed partial class MainWindow : Runnable
/// </summary> /// </summary>
public event Action? OnSavedServersRequested; public event Action? OnSavedServersRequested;
/// <summary>
/// Fired when the user scrolls to the top of the message list and older messages should be loaded.
/// </summary>
public event Action? OnLoadMoreRequested;
/// <summary> /// <summary>
/// Fired when the user requests to create a new channel. /// Fired when the user requests to create a new channel.
/// </summary> /// </summary>
@@ -151,11 +158,17 @@ public sealed partial class MainWindow : Runnable
/// </summary> /// </summary>
public event Action<string>? OnChannelJoinRequested; public event Action<string>? OnChannelJoinRequested;
/// <summary>
/// Fired when the user requests to open the search dialog (via menu or Ctrl+K).
/// </summary>
public event Action? OnSearchRequested;
public MainWindow(IApplication app, ChatMessageManager messageManager) public MainWindow(IApplication app, ChatMessageManager messageManager)
{ {
_app = app; _app = app;
_messageManager = messageManager; _messageManager = messageManager;
_messageManager.MessagesChanged += OnMessagesChanged; _messageManager.MessagesChanged += OnMessagesChanged;
_messageManager.HistoryPrepended += OnHistoryPrepended;
Arrangement = ViewArrangement.Fixed; Arrangement = ViewArrangement.Fixed;
// Menu bar at the top // Menu bar at the top
@@ -216,13 +229,16 @@ public sealed partial class MainWindow : Runnable
}; };
_messageList.Source = new ChatListSource(); _messageList.Source = new ChatListSource();
_messageList.Accepting += OnMessageListAccepting; _messageList.Accepting += OnMessageListAccepting;
_messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled;
_messageList.VerticalScrollBar.Visible = true;
_chatFrame.Add(_messageList); _chatFrame.Add(_messageList);
Add(_chatFrame); Add(_chatFrame);
// Bottom input area // Bottom input area
_inputFrame = new FrameView _inputFrame = new FrameView
{ {
Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete", Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete \u2502 Ctrl+K=search",
X = 22, X = 22,
Y = Pos.Bottom(_chatFrame), Y = Pos.Bottom(_chatFrame),
Width = Dim.Fill(UsersPanelWidth), Width = Dim.Fill(UsersPanelWidth),
@@ -472,6 +488,12 @@ public sealed partial class MainWindow : Runnable
} }
} }
private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs<int> e)
{
if (_messageList.VerticalScrollBar.Value == 0)
OnLoadMoreRequested?.Invoke();
}
private void OnUsersListAccepting(object? sender, CommandEventArgs e) private void OnUsersListAccepting(object? sender, CommandEventArgs e)
{ {
var index = _usersList.SelectedItem; var index = _usersList.SelectedItem;
@@ -513,6 +535,11 @@ public sealed partial class MainWindow : Runnable
_app.RequestStop(); _app.RequestStop();
e.Handled = true; e.Handled = true;
} }
else if (e.KeyCode == CtrlKKey.KeyCode)
{
ShowSearchDialog();
e.Handled = true;
}
} }
private bool _suppressEmojiReplace; private bool _suppressEmojiReplace;
@@ -572,6 +599,9 @@ public sealed partial class MainWindow : Runnable
if (prefix.Length > text.Length) if (prefix.Length > text.Length)
_inputField.Text = prefix; _inputField.Text = prefix;
} }
// Move cursor to end after autocomplete
_inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0);
} }
private void OnChatViewportChanged() private void OnChatViewportChanged()
@@ -597,6 +627,16 @@ public sealed partial class MainWindow : Runnable
ToggleUsersPanel(); ToggleUsersPanel();
e.Handled = true; e.Handled = true;
} }
else if (e.KeyCode == CtrlKKey.KeyCode)
{
ShowSearchDialog();
e.Handled = true;
}
}
private void ShowSearchDialog()
{
OnSearchRequested?.Invoke();
} }
private void OnMessagesChanged(string channelName) private void OnMessagesChanged(string channelName)
@@ -607,6 +647,26 @@ public sealed partial class MainWindow : Runnable
RefreshChannelList(); RefreshChannelList();
} }
private void OnHistoryPrepended(string channelName)
{
if (channelName != _messageManager.CurrentChannel)
return;
var messages = _messageManager.GetMessages(channelName);
if (messages is null)
return;
var oldCount = (_messageList.Source as ChatListSource)?.Count ?? 0;
RefreshMessages();
// Scroll to the item that was at the top before the prepend so the user
// stays at their previous reading position rather than jumping to the top.
var prependedCount = (_messageList.Source as ChatListSource)?.Count - oldCount;
if (prependedCount > 0)
_messageList.SelectedItem = prependedCount;
}
/// <summary> /// <summary>
/// Set the list of available channels, storing topics, and refresh the channel list view. /// Set the list of available channels, storing topics, and refresh the channel list view.
/// </summary> /// </summary>
@@ -13,7 +13,7 @@ public static partial class ValidationConstants
public const int MaxBioLength = 500; public const int MaxBioLength = 500;
public const int MaxStatusMessageLength = 100; public const int MaxStatusMessageLength = 100;
public const int MaxChannelTopicLength = 500; public const int MaxChannelTopicLength = 500;
public const int MaxHistoryCount = 100; public const int MaxHistoryCount = 200;
[GeneratedRegex(UsernamePattern)] [GeneratedRegex(UsernamePattern)]
public static partial Regex UsernameRegex(); public static partial Regex UsernameRegex();
+1 -1
View File
@@ -15,7 +15,7 @@ public interface IChatService
// Messaging // Messaging
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content); Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content);
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count); Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0);
// Presence // Presence
Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage); Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage);
@@ -1,5 +1,8 @@
using System.Security.Claims;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -13,11 +16,13 @@ public class ServerController : ControllerBase
{ {
private readonly EchoHubDbContext _db; private readonly EchoHubDbContext _db;
private readonly IConfiguration _config; private readonly IConfiguration _config;
private readonly DirectoryClaimStore _claimStore;
public ServerController(EchoHubDbContext db, IConfiguration config) public ServerController(EchoHubDbContext db, IConfiguration config, DirectoryClaimStore claimStore)
{ {
_db = db; _db = db;
_config = config; _config = config;
_claimStore = claimStore;
} }
[HttpGet("info")] [HttpGet("info")]
@@ -47,4 +52,46 @@ public class ServerController : ControllerBase
return Ok(new EncryptionKeyResponse(key)); return Ok(new EncryptionKeyResponse(key));
} }
/// <summary>
/// Operator-facing view of the EchoHubSpace directory registration: ServerId for admin
/// support tickets, current registration state, and the last error/conflict if any.
/// Never exposes the claim token itself.
/// </summary>
[HttpGet("directory")]
[Authorize]
public async Task<IActionResult> GetDirectoryStatus()
{
var (_, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var status = _claimStore.Status;
var response = new
{
ServerId = _claimStore.ServerId,
HasClaimToken = _claimStore.ClaimToken is not null,
status.IsRegistered,
status.LastRegisteredAt,
status.LastError,
status.ConflictingHosts,
};
return Ok(response);
}
private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return (null, Unauthorized(new ErrorResponse("Authentication required.")));
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
if (caller is null)
return (null, Unauthorized(new ErrorResponse("User not found.")));
if (caller.Role < minimumRole)
return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher.")));
return (caller, null);
}
} }
+2 -2
View File
@@ -106,11 +106,11 @@ public class ChatHub : Hub<IEchoHubClient>
} }
} }
public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount) public async Task<List<MessageDto>> GetChannelHistory(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
{ {
try try
{ {
return await _chatService.GetChannelHistoryAsync(channelName, count); return await _chatService.GetChannelHistoryAsync(channelName, count, offset);
} }
catch (Exception ex) catch (Exception ex)
{ {
+1
View File
@@ -107,6 +107,7 @@ while (true)
builder.Services.AddSingleton<ImageToAsciiService>(); builder.Services.AddSingleton<ImageToAsciiService>();
builder.Services.AddSingleton<FileStorageService>(); builder.Services.AddSingleton<FileStorageService>();
builder.Services.AddSingleton<LinkEmbedService>(); builder.Services.AddSingleton<LinkEmbedService>();
builder.Services.AddSingleton<DirectoryClaimStore>();
builder.Services.AddHostedService<ServerDirectoryService>(); builder.Services.AddHostedService<ServerDirectoryService>();
builder.Services.AddHostedService<FileCleanupService>(); builder.Services.AddHostedService<FileCleanupService>();
builder.Services.AddHostedService<MuteExpirationService>(); builder.Services.AddHostedService<MuteExpirationService>();
+5 -3
View File
@@ -245,15 +245,16 @@ public class ChatService : IChatService
return null; return null;
} }
public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count) public async Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0)
{ {
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount); count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
offset = Math.Max(offset, 0);
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>(); var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
return await GetChannelHistoryInternalAsync(db, channelName, count); return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
} }
public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
@@ -366,7 +367,7 @@ public class ChatService : IChatService
return string.Join('\n', result); return string.Join('\n', result);
} }
private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count) private async Task<List<MessageDto>> GetChannelHistoryInternalAsync(EchoHubDbContext db, string channelName, int count, int offset = 0)
{ {
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName); var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
if (channel is null) if (channel is null)
@@ -375,6 +376,7 @@ public class ChatService : IChatService
var raw = await db.Messages var raw = await db.Messages
.Where(m => m.ChannelId == channel.Id) .Where(m => m.ChannelId == channel.Id)
.OrderByDescending(m => m.SentAt) .OrderByDescending(m => m.SentAt)
.Skip(offset)
.Take(count) .Take(count)
.Join(db.Users, .Join(db.Users,
m => m.SenderUserId, m => m.SenderUserId,
@@ -0,0 +1,204 @@
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Data.Sqlite;
namespace EchoHub.Server.Services;
/// <summary>
/// Persists and exposes the EchoHubSpace directory claim — the opaque token issued on first
/// registration and the row's stable <c>ServerId</c>. Also surfaces ephemeral registration
/// status (success/failure code, conflicting hosts) for operator-facing endpoints.
///
/// Persistence uses atomic write (tmp + rename). Treat the file contents as a secret.
/// </summary>
public sealed class DirectoryClaimStore
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
};
private readonly string _filePath;
private readonly ILogger<DirectoryClaimStore> _logger;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private PersistedClaim _persisted = new(null, null);
private RegistrationStatus _status = new(false, null, null, null, null);
public DirectoryClaimStore(IConfiguration configuration, ILogger<DirectoryClaimStore> logger)
{
_logger = logger;
_filePath = ResolveFilePath(configuration);
Load();
}
public string FilePath => _filePath;
public string? ClaimToken => Volatile.Read(ref _persisted).ClaimToken;
public Guid? ServerId => Volatile.Read(ref _persisted).ServerId;
public RegistrationStatus Status => Volatile.Read(ref _status);
/// <summary>
/// Persist a freshly-issued claim token alongside the server's stable ServerId.
/// Called exactly once per row's lifetime — on first claim. Atomic on-disk swap.
/// </summary>
public async Task SaveClaimAsync(string claimToken, Guid serverId, CancellationToken ct = default)
{
await _writeLock.WaitAsync(ct);
try
{
var next = new PersistedClaim(claimToken, serverId);
await WriteAtomicAsync(next, ct);
Volatile.Write(ref _persisted, next);
_logger.LogInformation("Persisted directory claim token for ServerId {ServerId} at {Path}", serverId, _filePath);
}
finally
{
_writeLock.Release();
}
}
/// <summary>
/// Update only the ServerId — used when re-registering with an existing token (Success path,
/// hub returns ServerId again but no fresh token). No-op if the value is unchanged.
/// </summary>
public async Task UpdateServerIdAsync(Guid serverId, CancellationToken ct = default)
{
var current = Volatile.Read(ref _persisted);
if (current.ServerId == serverId)
return;
await _writeLock.WaitAsync(ct);
try
{
var next = current with { ServerId = serverId };
await WriteAtomicAsync(next, ct);
Volatile.Write(ref _persisted, next);
}
finally
{
_writeLock.Release();
}
}
public void SetSuccess(Guid serverId)
{
Volatile.Write(ref _status, new RegistrationStatus(
IsRegistered: true,
ServerId: serverId,
LastRegisteredAt: DateTimeOffset.UtcNow,
LastError: null,
ConflictingHosts: null));
}
public void SetFailure(string errorCode, string[]? conflictingHosts)
{
var current = Volatile.Read(ref _status);
Volatile.Write(ref _status, current with
{
IsRegistered = false,
LastError = errorCode,
ConflictingHosts = conflictingHosts,
});
}
private void Load()
{
if (!File.Exists(_filePath))
return;
try
{
using var stream = File.OpenRead(_filePath);
var loaded = JsonSerializer.Deserialize<PersistedClaim>(stream, JsonOptions);
if (loaded is not null)
{
_persisted = loaded;
_logger.LogInformation("Loaded directory claim from {Path} (ServerId {ServerId})", _filePath, loaded.ServerId);
}
}
catch (Exception ex)
{
// Don't crash startup over a corrupt state file — log and proceed as if no claim exists.
// Operator will see HostAlreadyClaimed on next register and can intervene.
_logger.LogError(ex, "Failed to read directory claim file at {Path} — treating as unclaimed", _filePath);
}
}
private async Task WriteAtomicAsync(PersistedClaim claim, CancellationToken ct)
{
var dir = Path.GetDirectoryName(_filePath);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var tmpPath = _filePath + ".tmp";
await using (var stream = new FileStream(
tmpPath,
FileMode.Create,
FileAccess.Write,
FileShare.None,
bufferSize: 4096,
useAsync: true))
{
await JsonSerializer.SerializeAsync(stream, claim, JsonOptions, ct);
await stream.FlushAsync(ct);
}
// 0600 on Unix — the file holds a secret. No-op on Windows.
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
try
{
File.SetUnixFileMode(tmpPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set restrictive permissions on {Path}", tmpPath);
}
}
File.Move(tmpPath, _filePath, overwrite: true);
}
private static string ResolveFilePath(IConfiguration configuration)
{
var configured = configuration["Server:DirectoryClaimPath"];
if (!string.IsNullOrWhiteSpace(configured))
return configured;
// Co-locate with the SQLite database so a single data-directory backup captures both.
var connectionString = configuration.GetConnectionString("DefaultConnection");
if (!string.IsNullOrWhiteSpace(connectionString))
{
try
{
var builder = new SqliteConnectionStringBuilder(connectionString);
if (!string.IsNullOrWhiteSpace(builder.DataSource))
{
var dir = Path.GetDirectoryName(Path.GetFullPath(builder.DataSource));
if (!string.IsNullOrWhiteSpace(dir))
return Path.Combine(dir, "directory-claim.json");
}
}
catch
{
// Fall through to default
}
}
return Path.Combine(AppContext.BaseDirectory, "directory-claim.json");
}
private sealed record PersistedClaim(string? ClaimToken, Guid? ServerId);
}
public sealed record RegistrationStatus(
bool IsRegistered,
Guid? ServerId,
DateTimeOffset? LastRegisteredAt,
string? LastError,
string[]? ConflictingHosts);
+39 -5
View File
@@ -10,10 +10,18 @@ public class PresenceTracker
private readonly object _lock = new(); private readonly object _lock = new();
/// <summary>
/// Raised when the distinct online user count changes (multi-connection users only fire once).
/// </summary>
public event Action<int>? UserCountChanged;
public void UserConnected(string connectionId, Guid userId, string username) public void UserConnected(string connectionId, Guid userId, string username)
{ {
_connections[connectionId] = (userId, username); _connections[connectionId] = (userId, username);
bool userIsNew;
int newCount;
// Lock is required: ConcurrentDictionary only protects its own slots, not the HashSet values inside. // Lock is required: ConcurrentDictionary only protects its own slots, not the HashSet values inside.
// It also makes the TryGetValue → add sequence atomic to prevent race conditions. // It also makes the TryGetValue → add sequence atomic to prevent race conditions.
lock (_lock) lock (_lock)
@@ -22,10 +30,19 @@ public class PresenceTracker
{ {
connections = new HashSet<string>(); connections = new HashSet<string>();
_userConnections[username] = connections; _userConnections[username] = connections;
userIsNew = true;
}
else
{
userIsNew = false;
} }
connections.Add(connectionId); connections.Add(connectionId);
newCount = _userConnections.Count;
} }
if (userIsNew)
UserCountChanged?.Invoke(newCount);
} }
public string? UserDisconnected(string connectionId) public string? UserDisconnected(string connectionId)
@@ -34,6 +51,8 @@ public class PresenceTracker
return null; return null;
var username = userInfo.username; var username = userInfo.username;
bool userRemoved = false;
int newCount;
lock (_lock) lock (_lock)
{ {
@@ -45,10 +64,16 @@ public class PresenceTracker
{ {
_userConnections.TryRemove(username, out _); _userConnections.TryRemove(username, out _);
_userChannels.TryRemove(username, out _); _userChannels.TryRemove(username, out _);
userRemoved = true;
} }
} }
newCount = _userConnections.Count;
} }
if (userRemoved)
UserCountChanged?.Invoke(newCount);
return username; return username;
} }
@@ -160,20 +185,29 @@ public class PresenceTracker
/// </summary> /// </summary>
public (List<string> ConnectionIds, List<string> Channels) ForceRemoveUser(string username) public (List<string> ConnectionIds, List<string> Channels) ForceRemoveUser(string username)
{ {
bool userRemoved;
int newCount;
List<string> channels;
List<string> connectionIds;
lock (_lock) lock (_lock)
{ {
var channels = _userChannels.TryRemove(username, out var ch) channels = _userChannels.TryRemove(username, out var ch)
? ch.ToList() ? ch.ToList()
: []; : [];
var connectionIds = _userConnections.TryRemove(username, out var conns) userRemoved = _userConnections.TryRemove(username, out var conns);
? conns.ToList() connectionIds = userRemoved ? conns!.ToList() : [];
: [];
foreach (var connId in connectionIds) foreach (var connId in connectionIds)
_connections.TryRemove(connId, out _); _connections.TryRemove(connId, out _);
return (connectionIds, channels); newCount = _userConnections.Count;
} }
if (userRemoved)
UserCountChanged?.Invoke(newCount);
return (connectionIds, channels);
} }
} }
@@ -1,3 +1,6 @@
using System.Reflection;
using System.Text.Json;
using System.Threading.Channels;
using Microsoft.AspNetCore.SignalR.Client; using Microsoft.AspNetCore.SignalR.Client;
namespace EchoHub.Server.Services; namespace EchoHub.Server.Services;
@@ -5,24 +8,36 @@ namespace EchoHub.Server.Services;
public sealed class ServerDirectoryService : BackgroundService public sealed class ServerDirectoryService : BackgroundService
{ {
private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers"; private const string DirectoryHubUrl = "https://echohub.voidcube.cloud/hubs/servers";
private static readonly TimeSpan UpdateInterval = TimeSpan.FromSeconds(30);
private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2); private static readonly TimeSpan ReconnectBaseDelay = TimeSpan.FromSeconds(2);
private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30); private static readonly TimeSpan ReconnectMaxDelay = TimeSpan.FromSeconds(30);
private static readonly TimeSpan UserCountMinInterval = TimeSpan.FromSeconds(1);
private readonly IConfiguration _configuration; private readonly IConfiguration _configuration;
private readonly PresenceTracker _presenceTracker; private readonly PresenceTracker _presenceTracker;
private readonly DirectoryClaimStore _claimStore;
private readonly ILogger<ServerDirectoryService> _logger; private readonly ILogger<ServerDirectoryService> _logger;
// Single-slot, latest-wins channel coalesces bursts of presence changes into one update.
private readonly Channel<int> _userCountUpdates = Channel.CreateBounded<int>(
new BoundedChannelOptions(1) { FullMode = BoundedChannelFullMode.DropOldest });
private HubConnection? _connection; private HubConnection? _connection;
private int _lastReportedUserCount = -1; private int _lastReportedUserCount = -1;
// Set true when a registration error code arrives (HostAlreadyClaimed/InvalidToken/HostConflict).
// Once set, we stop attempting register on this connection AND on any reconnects, since the
// hub won't kick us off and we'd otherwise tight-loop. Operator must restart after fixing config.
private bool _registrationPermanentlyFailed;
public ServerDirectoryService( public ServerDirectoryService(
IConfiguration configuration, IConfiguration configuration,
PresenceTracker presenceTracker, PresenceTracker presenceTracker,
DirectoryClaimStore claimStore,
ILogger<ServerDirectoryService> logger) ILogger<ServerDirectoryService> logger)
{ {
_configuration = configuration; _configuration = configuration;
_presenceTracker = presenceTracker; _presenceTracker = presenceTracker;
_claimStore = claimStore;
_logger = logger; _logger = logger;
} }
@@ -38,19 +53,44 @@ public sealed class ServerDirectoryService : BackgroundService
return; return;
} }
var host = _configuration["Server:PublicHost"]; var hosts = _configuration.GetSection("Server:PublicHosts").Get<string[]>()
?.Where(h => !string.IsNullOrWhiteSpace(h))
.ToArray() ?? Array.Empty<string>();
if (string.IsNullOrWhiteSpace(host)) if (hosts.Length == 0)
{ {
_logger.LogWarning("PublicServer is enabled but Server:PublicHost is not set — skipping directory registration"); _logger.LogWarning("PublicServer is enabled but Server:PublicHosts is empty — skipping directory registration");
return; return;
} }
var serverName = _configuration["Server:Name"] ?? "EchoHub Server"; var serverName = _configuration["Server:Name"] ?? "EchoHub Server";
var description = _configuration["Server:Description"]; var description = _configuration["Server:Description"];
var tags = _configuration.GetSection("Server:Tags").Get<string[]>()
?.Where(t => !string.IsNullOrWhiteSpace(t))
.ToArray() ?? Array.Empty<string>();
var version = ResolveVersion();
_logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Host})", serverName, host); _logger.LogInformation("PublicServer is enabled — connecting to EchoHubSpace directory as {Name} ({Hosts})", serverName, string.Join(", ", hosts));
_presenceTracker.UserCountChanged += OnUserCountChanged;
try
{
await RunConnectionLoopAsync(serverName, description, hosts, version, tags, stoppingToken);
}
finally
{
_presenceTracker.UserCountChanged -= OnUserCountChanged;
}
}
private async Task RunConnectionLoopAsync(
string serverName,
string? description,
string[] hosts,
string version,
string[] tags,
CancellationToken stoppingToken)
{
// Outer loop: rebuilds the connection if automatic reconnect permanently fails // Outer loop: rebuilds the connection if automatic reconnect permanently fails
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
@@ -76,9 +116,15 @@ public sealed class ServerDirectoryService : BackgroundService
connection.Reconnected += async _ => connection.Reconnected += async _ =>
{ {
if (_registrationPermanentlyFailed)
{
_logger.LogWarning("Reconnected to directory but previous registration permanently failed — not re-registering. Restart the server after fixing configuration.");
return;
}
_logger.LogInformation("Reconnected to directory — re-registering server"); _logger.LogInformation("Reconnected to directory — re-registering server");
_lastReportedUserCount = -1; _lastReportedUserCount = -1;
await RegisterAsync(serverName, description, host); await RegisterAsync(serverName, description, hosts, version, tags);
}; };
connection.Closed += ex => connection.Closed += ex =>
@@ -97,10 +143,10 @@ public sealed class ServerDirectoryService : BackgroundService
return; return;
_logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl); _logger.LogInformation("Successfully connected to EchoHubSpace API at {Url}", DirectoryHubUrl);
await RegisterAsync(serverName, description, host); await RegisterAsync(serverName, description, hosts, version, tags);
// Poll user count until the connection is permanently closed or cancellation // Push user-count updates as PresenceTracker raises events, until the connection closes or cancellation
await PollUserCountAsync(connection, connectionPermanentlyClosed.Task, stoppingToken); await ProcessUserCountUpdatesAsync(connection, connectionPermanentlyClosed.Task, stoppingToken);
if (stoppingToken.IsCancellationRequested) if (stoppingToken.IsCancellationRequested)
return; return;
@@ -151,33 +197,62 @@ public sealed class ServerDirectoryService : BackgroundService
return false; return false;
} }
private async Task PollUserCountAsync(HubConnection connection, Task connectionClosed, CancellationToken ct) private void OnUserCountChanged(int newCount)
{ {
// Single-slot channel: latest write wins, so a burst of presence changes coalesces.
_userCountUpdates.Writer.TryWrite(newCount);
}
private async Task ProcessUserCountUpdatesAsync(HubConnection connection, Task connectionClosed, CancellationToken ct)
{
var lastSentAt = DateTimeOffset.MinValue;
while (!ct.IsCancellationRequested) while (!ct.IsCancellationRequested)
{ {
var delayTask = Task.Delay(UpdateInterval, ct); var waitTask = _userCountUpdates.Reader.WaitToReadAsync(ct).AsTask();
var completed = await Task.WhenAny(delayTask, connectionClosed); var completed = await Task.WhenAny(waitTask, connectionClosed);
if (completed == connectionClosed) if (completed == connectionClosed)
return; return;
// Observe the delay task (may throw if cancelled) bool hasUpdate;
try { await delayTask; } try { hasUpdate = await waitTask; }
catch (OperationCanceledException) { return; } catch (OperationCanceledException) { return; }
if (!hasUpdate)
return;
if (!_userCountUpdates.Reader.TryRead(out var count))
continue;
// Throttle: enforce a minimum interval between sends. While we wait, drain newer
// values so the eventual send carries the latest count, not a stale snapshot.
var elapsed = DateTimeOffset.UtcNow - lastSentAt;
if (elapsed < UserCountMinInterval)
{
try { await Task.Delay(UserCountMinInterval - elapsed, ct); }
catch (OperationCanceledException) { return; }
while (_userCountUpdates.Reader.TryRead(out var newer))
count = newer;
}
if (count == _lastReportedUserCount)
continue;
if (connection.State != HubConnectionState.Connected) if (connection.State != HubConnectionState.Connected)
continue; continue;
var currentCount = _presenceTracker.GetOnlineUserCount(); // No point pushing presence to a row we don't own (or never claimed)
if (_registrationPermanentlyFailed || !_claimStore.Status.IsRegistered)
if (currentCount == _lastReportedUserCount)
continue; continue;
try try
{ {
await connection.InvokeAsync("UpdateUserCount", currentCount, ct); await connection.InvokeAsync("UpdateUserCount", count, ct);
_lastReportedUserCount = currentCount; _lastReportedUserCount = count;
_logger.LogDebug("Updated directory user count to {Count}", currentCount); lastSentAt = DateTimeOffset.UtcNow;
_logger.LogDebug("Updated directory user count to {Count}", count);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -192,18 +267,22 @@ public sealed class ServerDirectoryService : BackgroundService
return delay > ReconnectMaxDelay ? ReconnectMaxDelay : delay; return delay > ReconnectMaxDelay ? ReconnectMaxDelay : delay;
} }
private async Task RegisterAsync(string name, string? description, string host) private async Task RegisterAsync(string name, string? description, string[] hosts, string version, string[] tags)
{ {
if (_connection?.State != HubConnectionState.Connected) if (_connection?.State != HubConnectionState.Connected)
return; return;
if (_registrationPermanentlyFailed)
return;
try try
{ {
var userCount = _presenceTracker.GetOnlineUserCount(); var userCount = _presenceTracker.GetOnlineUserCount();
var dto = new RegisterServerDto(name, description, host, userCount); // ClaimToken is null on first-ever registration; otherwise the token persisted on first claim.
await _connection.InvokeAsync("RegisterServer", dto); var dto = new RegisterServerDto(name, description, hosts, userCount, version, tags, _claimStore.ClaimToken);
_lastReportedUserCount = userCount;
_logger.LogInformation("Registered with directory as {Name} at {Host}", name, host); var envelope = await _connection.InvokeAsync<Response<RegisterServerResult>>("RegisterServer", dto);
await HandleRegistrationResponseAsync(envelope, userCount, name, hosts);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -211,6 +290,153 @@ public sealed class ServerDirectoryService : BackgroundService
} }
} }
private async Task HandleRegistrationResponseAsync(Response<RegisterServerResult>? envelope, int userCount, string name, string[] hosts)
{
if (envelope is null)
{
_registrationPermanentlyFailed = true;
_logger.LogError("Directory returned a null envelope for RegisterServer — treating as malformed. Server will not retry until restarted.");
_claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null);
return;
}
// Pin protocol version. Spec: fail hard on mismatch — bumps are coordinated.
if (!string.Equals(envelope.Version, DirectoryProtocol.Version, StringComparison.Ordinal))
{
_registrationPermanentlyFailed = true;
_logger.LogError(
"Directory protocol version mismatch: client expects {Expected}, hub returned {Actual}. " +
"Refusing to operate. Coordinate a deploy that aligns both sides.",
DirectoryProtocol.Version, envelope.Version ?? "(null)");
_claimStore.SetFailure(DirectoryRegistrationErrors.ProtocolVersionMismatch, null);
return;
}
if (!envelope.IsSuccess)
{
await HandleRegistrationErrorAsync(envelope.Errors);
return;
}
if (envelope.Data is null)
{
_registrationPermanentlyFailed = true;
_logger.LogError("Directory returned IsSuccess=true but Data was null — treating as malformed. Server will not retry until restarted.");
_claimStore.SetFailure(DirectoryRegistrationErrors.MalformedResponse, null);
return;
}
var data = envelope.Data;
var serverId = data.ServerId;
// Persist a freshly-issued claim token *before* anything else acks success — durability guarantee for first claim.
if (!string.IsNullOrEmpty(data.ClaimToken))
{
await _claimStore.SaveClaimAsync(data.ClaimToken, serverId);
}
else
{
// No fresh token (re-register): just keep the persisted ServerId in sync defensively.
await _claimStore.UpdateServerIdAsync(serverId);
}
_claimStore.SetSuccess(serverId);
_lastReportedUserCount = userCount;
_logger.LogInformation("Registered with directory as {Name} at {Hosts} (ServerId {ServerId})", name, string.Join(", ", hosts), serverId);
}
private Task HandleRegistrationErrorAsync(ErrorDetail[]? errors)
{
_registrationPermanentlyFailed = true;
var firstError = errors is { Length: > 0 } ? errors[0] : null;
var code = firstError?.Code ?? "UnknownError";
var conflictingHosts = ExtractConflictingHosts(firstError);
var conflicts = conflictingHosts is { Length: > 0 }
? string.Join(", ", conflictingHosts)
: "(none reported)";
switch (code)
{
case DirectoryRegistrationErrors.HostAlreadyClaimed:
_logger.LogError(
"Directory rejected registration: host(s) already claimed by another server: {ConflictingHosts}. " +
"Change Server:PublicHosts or contact the directory admin to release the claim. Server will not retry until restarted.",
conflicts);
break;
case DirectoryRegistrationErrors.InvalidToken:
_logger.LogError(
"Directory rejected registration: persisted claim token is invalid (likely deleted by admin or stale). " +
"Delete the claim file ({ClaimFile}) to claim fresh, or contact the directory admin. Server will not retry until restarted.",
_claimStore.FilePath);
break;
case DirectoryRegistrationErrors.HostConflict:
_logger.LogError(
"Directory rejected registration: token is valid but newly-advertised host(s) conflict with another server's row: {ConflictingHosts}. " +
"Remove the conflicting entries from Server:PublicHosts. Server will not retry until restarted.",
conflicts);
break;
case DirectoryRegistrationErrors.InvalidInput:
_logger.LogError(
"Directory rejected registration as InvalidInput ({Message}). Likely a client/hub contract drift — check Server config. Server will not retry until restarted.",
firstError?.Message ?? "(no message)");
break;
default:
_logger.LogError(
"Directory rejected registration with unknown error code: {Error} ({Message}). Server will not retry until restarted.",
code, firstError?.Message ?? "(no message)");
break;
}
_claimStore.SetFailure(code, conflictingHosts);
return Task.CompletedTask;
}
/// <summary>
/// Pulls <c>ConflictingHosts</c> out of an error's loosely-typed <c>Data</c> payload.
/// Tolerates both PascalCase and camelCase keys since SignalR's wire casing depends on
/// the hub's serializer config and the field is typed <c>object?</c>.
/// </summary>
private static string[]? ExtractConflictingHosts(ErrorDetail? error)
{
if (error?.Data is not JsonElement element || element.ValueKind != JsonValueKind.Object)
return null;
if (!element.TryGetProperty("ConflictingHosts", out var hostsProp)
&& !element.TryGetProperty("conflictingHosts", out hostsProp))
return null;
if (hostsProp.ValueKind != JsonValueKind.Array)
return null;
List<string> hosts = [];
foreach (var item in hostsProp.EnumerateArray())
{
if (item.ValueKind == JsonValueKind.String && item.GetString() is { } s)
hosts.Add(s);
}
return hosts.Count == 0 ? null : hosts.ToArray();
}
private static string ResolveVersion()
{
var assembly = typeof(ServerDirectoryService).Assembly;
var informational = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion;
if (!string.IsNullOrWhiteSpace(informational))
{
// Strip git SHA suffix that SourceLink appends (e.g. "0.2.10+abc123")
var plus = informational.IndexOf('+');
return plus >= 0 ? informational[..plus] : informational;
}
return assembly.GetName().Version?.ToString() ?? "0.0.0";
}
private static async Task DisposeConnectionAsync(HubConnection connection) private static async Task DisposeConnectionAsync(HubConnection connection)
{ {
try try
@@ -243,4 +469,44 @@ public sealed class ServerDirectoryService : BackgroundService
} }
} }
internal record RegisterServerDto(string Name, string? Description, string Host, int UserCount); internal record RegisterServerDto(
string Name,
string? Description,
string[] Hosts,
int UserCount,
string Version,
string[] Tags,
string? ClaimToken);
internal record RegisterServerResult(Guid ServerId, string? ClaimToken);
/// <summary>
/// Envelope wrapping every directory hub response. Mirrors the EchoHubSpace contract.
/// </summary>
internal record Response<T>(bool IsSuccess, T? Data, ErrorDetail[]? Errors, string? Version);
/// <summary>
/// Error entry inside a <see cref="Response{T}"/>. <c>Data</c> is loosely-typed because the
/// payload shape varies by error code (e.g. <c>{ ConflictingHosts: string[] }</c> for host errors).
/// </summary>
internal record ErrorDetail(string Code, string? Message, JsonElement? Data);
internal static class DirectoryProtocol
{
/// <summary>
/// Pinned envelope protocol version. Bumps are coordinated across both repos.
/// </summary>
public const string Version = "1.0";
}
internal static class DirectoryRegistrationErrors
{
public const string InvalidInput = "InvalidInput";
public const string InvalidToken = "InvalidToken";
public const string HostAlreadyClaimed = "HostAlreadyClaimed";
public const string HostConflict = "HostConflict";
// Client-side synthetic codes (never returned by hub, generated locally for status reporting)
public const string ProtocolVersionMismatch = "ProtocolVersionMismatch";
public const string MalformedResponse = "MalformedResponse";
}
+2 -1
View File
@@ -12,7 +12,8 @@
"Name": "My EchoHub Server", "Name": "My EchoHub Server",
"Description": "A self-hosted EchoHub chat server", "Description": "A self-hosted EchoHub chat server",
"PublicServer": false, "PublicServer": false,
"PublicHost": "", "PublicHosts": [],
"Tags": [],
"Admins": [] "Admins": []
}, },
"Storage": { "Storage": {
+1 -1
View File
@@ -190,7 +190,7 @@ internal sealed class FakeChatService : IChatService
return Task.FromResult(SendMessageError); return Task.FromResult(SendMessageError);
} }
public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count) => public Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0) =>
Task.FromResult(HistoryToReturn); Task.FromResult(HistoryToReturn);
public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage) public Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)