Skip to content

Commit bbb8dd8

Browse files
authored
Merge pull request #45 from HueByte/dev_browser_update
feat: enhance presence tracking and server registration with user cou…
2 parents 6660944 + e3728a7 commit bbb8dd8

12 files changed

Lines changed: 616 additions & 37 deletions

File tree

docs/articles/getting-started.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ curl -sSfL https://raw.githubusercontent.com/HueByte/EchoHub/master/scripts/inst
1717
To install a specific version or to a custom directory:
1818

1919
```bash
20-
curl -sSfL .../install.sh | sh -s -- --version 0.2.10
20+
curl -sSfL .../install.sh | sh -s -- --version 0.2.11
2121
curl -sSfL .../install.sh | sh -s -- --install-dir /opt/echohub
2222
```
2323

docs/changelog/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ Release history for EchoHub.
44

55
## Releases
66

7+
- [v0.2.11](v0.2.11.md) - EchoHubSpace Auth, Live Directory Updates & Server Browser Metadata
78
- [v0.2.10](v0.2.10.md) - Command Palette, Infinite History Scroll & Auto-Updater Fixes
89
- [v0.2.9](v0.2.9.md) - Install Script & Chocolatey Fixes
910
- [v0.2.8](v0.2.8.md) - Docker Support, IRC Account Creation & BOM Fix

docs/changelog/toc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
- name: Overview
22
href: index.md
3+
- name: v0.2.11
4+
href: v0.2.11.md
35
- name: v0.2.10
46
href: v0.2.10.md
57
- name: v0.2.9

docs/changelog/v0.2.11.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
# v0.2.11
2+
3+
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.
4+
5+
## New Features
6+
7+
- 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
8+
- Server tags — public servers can advertise topic tags via the new `Server:Tags` config array, surfacing as filter facets in the EchoHubSpace browser
9+
- 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
10+
- Server version sent to directory — the EchoHubSpace browser shows what version each public server is running, pulled from the server's assembly informational version
11+
- 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
12+
13+
## Refactoring
14+
15+
- 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
16+
- 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
17+
- 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
18+
19+
## Configuration
20+
21+
- **Breaking**: `Server:PublicHost` (string) renamed to `Server:PublicHosts` (string array). Public servers must update `appsettings.json` — single-host deployments use a one-element array
22+
- New `Server:Tags` (string array) — defaults to empty
23+
- 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

scripts/install.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ while [ $# -gt 0 ]; do
3030
sed -n '2,8p' "$0" 2>/dev/null || true
3131
echo ""
3232
echo " curl -sSfL https://raw.githubusercontent.com/$REPO/master/scripts/install.sh | sh"
33-
echo " curl ... | sh -s -- --version 0.2.10"
33+
echo " curl ... | sh -s -- --version 0.2.11"
3434
echo " curl ... | sh -s -- --install-dir /opt/echohub"
3535
exit 0
3636
;;

src/Directory.Build.props

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<Project>
22
<PropertyGroup>
3-
<Version>0.2.10</Version>
3+
<Version>0.2.11</Version>
44
<GenerateDocumentationFile>true</GenerateDocumentationFile>
55
<NoWarn>$(NoWarn);CS1591</NoWarn>
66
</PropertyGroup>

src/EchoHub.Server/Controllers/ServerController.cs

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1+
using System.Security.Claims;
12
using EchoHub.Core.DTOs;
3+
using EchoHub.Core.Models;
24
using EchoHub.Server.Data;
5+
using EchoHub.Server.Services;
36
using Microsoft.AspNetCore.Authorization;
47
using Microsoft.AspNetCore.Mvc;
58
using Microsoft.AspNetCore.RateLimiting;
@@ -13,11 +16,13 @@ public class ServerController : ControllerBase
1316
{
1417
private readonly EchoHubDbContext _db;
1518
private readonly IConfiguration _config;
19+
private readonly DirectoryClaimStore _claimStore;
1620

17-
public ServerController(EchoHubDbContext db, IConfiguration config)
21+
public ServerController(EchoHubDbContext db, IConfiguration config, DirectoryClaimStore claimStore)
1822
{
1923
_db = db;
2024
_config = config;
25+
_claimStore = claimStore;
2126
}
2227

2328
[HttpGet("info")]
@@ -47,4 +52,46 @@ public IActionResult GetEncryptionKey()
4752

4853
return Ok(new EncryptionKeyResponse(key));
4954
}
55+
56+
/// <summary>
57+
/// Operator-facing view of the EchoHubSpace directory registration: ServerId for admin
58+
/// support tickets, current registration state, and the last error/conflict if any.
59+
/// Never exposes the claim token itself.
60+
/// </summary>
61+
[HttpGet("directory")]
62+
[Authorize]
63+
public async Task<IActionResult> GetDirectoryStatus()
64+
{
65+
var (_, error) = await GetCallerAsync(ServerRole.Admin);
66+
if (error is not null) return error;
67+
68+
var status = _claimStore.Status;
69+
var response = new
70+
{
71+
ServerId = _claimStore.ServerId,
72+
HasClaimToken = _claimStore.ClaimToken is not null,
73+
status.IsRegistered,
74+
status.LastRegisteredAt,
75+
status.LastError,
76+
status.ConflictingHosts,
77+
};
78+
79+
return Ok(response);
80+
}
81+
82+
private async Task<(User? Caller, IActionResult? Error)> GetCallerAsync(ServerRole minimumRole)
83+
{
84+
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
85+
if (userIdClaim is null)
86+
return (null, Unauthorized(new ErrorResponse("Authentication required.")));
87+
88+
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
89+
if (caller is null)
90+
return (null, Unauthorized(new ErrorResponse("User not found.")));
91+
92+
if (caller.Role < minimumRole)
93+
return (null, StatusCode(403, new ErrorResponse($"Requires {minimumRole} role or higher.")));
94+
95+
return (caller, null);
96+
}
5097
}

src/EchoHub.Server/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@
107107
builder.Services.AddSingleton<ImageToAsciiService>();
108108
builder.Services.AddSingleton<FileStorageService>();
109109
builder.Services.AddSingleton<LinkEmbedService>();
110+
builder.Services.AddSingleton<DirectoryClaimStore>();
110111
builder.Services.AddHostedService<ServerDirectoryService>();
111112
builder.Services.AddHostedService<FileCleanupService>();
112113
builder.Services.AddHostedService<MuteExpirationService>();
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
using System.Runtime.InteropServices;
2+
using System.Text.Json;
3+
using System.Text.Json.Serialization;
4+
using Microsoft.Data.Sqlite;
5+
6+
namespace EchoHub.Server.Services;
7+
8+
/// <summary>
9+
/// Persists and exposes the EchoHubSpace directory claim — the opaque token issued on first
10+
/// registration and the row's stable <c>ServerId</c>. Also surfaces ephemeral registration
11+
/// status (success/failure code, conflicting hosts) for operator-facing endpoints.
12+
///
13+
/// Persistence uses atomic write (tmp + rename). Treat the file contents as a secret.
14+
/// </summary>
15+
public sealed class DirectoryClaimStore
16+
{
17+
private static readonly JsonSerializerOptions JsonOptions = new()
18+
{
19+
WriteIndented = true,
20+
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
21+
};
22+
23+
private readonly string _filePath;
24+
private readonly ILogger<DirectoryClaimStore> _logger;
25+
private readonly SemaphoreSlim _writeLock = new(1, 1);
26+
27+
private PersistedClaim _persisted = new(null, null);
28+
private RegistrationStatus _status = new(false, null, null, null, null);
29+
30+
public DirectoryClaimStore(IConfiguration configuration, ILogger<DirectoryClaimStore> logger)
31+
{
32+
_logger = logger;
33+
_filePath = ResolveFilePath(configuration);
34+
Load();
35+
}
36+
37+
public string FilePath => _filePath;
38+
39+
public string? ClaimToken => Volatile.Read(ref _persisted).ClaimToken;
40+
public Guid? ServerId => Volatile.Read(ref _persisted).ServerId;
41+
42+
public RegistrationStatus Status => Volatile.Read(ref _status);
43+
44+
/// <summary>
45+
/// Persist a freshly-issued claim token alongside the server's stable ServerId.
46+
/// Called exactly once per row's lifetime — on first claim. Atomic on-disk swap.
47+
/// </summary>
48+
public async Task SaveClaimAsync(string claimToken, Guid serverId, CancellationToken ct = default)
49+
{
50+
await _writeLock.WaitAsync(ct);
51+
try
52+
{
53+
var next = new PersistedClaim(claimToken, serverId);
54+
await WriteAtomicAsync(next, ct);
55+
Volatile.Write(ref _persisted, next);
56+
_logger.LogInformation("Persisted directory claim token for ServerId {ServerId} at {Path}", serverId, _filePath);
57+
}
58+
finally
59+
{
60+
_writeLock.Release();
61+
}
62+
}
63+
64+
/// <summary>
65+
/// Update only the ServerId — used when re-registering with an existing token (Success path,
66+
/// hub returns ServerId again but no fresh token). No-op if the value is unchanged.
67+
/// </summary>
68+
public async Task UpdateServerIdAsync(Guid serverId, CancellationToken ct = default)
69+
{
70+
var current = Volatile.Read(ref _persisted);
71+
if (current.ServerId == serverId)
72+
return;
73+
74+
await _writeLock.WaitAsync(ct);
75+
try
76+
{
77+
var next = current with { ServerId = serverId };
78+
await WriteAtomicAsync(next, ct);
79+
Volatile.Write(ref _persisted, next);
80+
}
81+
finally
82+
{
83+
_writeLock.Release();
84+
}
85+
}
86+
87+
public void SetSuccess(Guid serverId)
88+
{
89+
Volatile.Write(ref _status, new RegistrationStatus(
90+
IsRegistered: true,
91+
ServerId: serverId,
92+
LastRegisteredAt: DateTimeOffset.UtcNow,
93+
LastError: null,
94+
ConflictingHosts: null));
95+
}
96+
97+
public void SetFailure(string errorCode, string[]? conflictingHosts)
98+
{
99+
var current = Volatile.Read(ref _status);
100+
Volatile.Write(ref _status, current with
101+
{
102+
IsRegistered = false,
103+
LastError = errorCode,
104+
ConflictingHosts = conflictingHosts,
105+
});
106+
}
107+
108+
private void Load()
109+
{
110+
if (!File.Exists(_filePath))
111+
return;
112+
113+
try
114+
{
115+
using var stream = File.OpenRead(_filePath);
116+
var loaded = JsonSerializer.Deserialize<PersistedClaim>(stream, JsonOptions);
117+
if (loaded is not null)
118+
{
119+
_persisted = loaded;
120+
_logger.LogInformation("Loaded directory claim from {Path} (ServerId {ServerId})", _filePath, loaded.ServerId);
121+
}
122+
}
123+
catch (Exception ex)
124+
{
125+
// Don't crash startup over a corrupt state file — log and proceed as if no claim exists.
126+
// Operator will see HostAlreadyClaimed on next register and can intervene.
127+
_logger.LogError(ex, "Failed to read directory claim file at {Path} — treating as unclaimed", _filePath);
128+
}
129+
}
130+
131+
private async Task WriteAtomicAsync(PersistedClaim claim, CancellationToken ct)
132+
{
133+
var dir = Path.GetDirectoryName(_filePath);
134+
if (!string.IsNullOrEmpty(dir))
135+
Directory.CreateDirectory(dir);
136+
137+
var tmpPath = _filePath + ".tmp";
138+
139+
await using (var stream = new FileStream(
140+
tmpPath,
141+
FileMode.Create,
142+
FileAccess.Write,
143+
FileShare.None,
144+
bufferSize: 4096,
145+
useAsync: true))
146+
{
147+
await JsonSerializer.SerializeAsync(stream, claim, JsonOptions, ct);
148+
await stream.FlushAsync(ct);
149+
}
150+
151+
// 0600 on Unix — the file holds a secret. No-op on Windows.
152+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
153+
{
154+
try
155+
{
156+
File.SetUnixFileMode(tmpPath, UnixFileMode.UserRead | UnixFileMode.UserWrite);
157+
}
158+
catch (Exception ex)
159+
{
160+
_logger.LogWarning(ex, "Failed to set restrictive permissions on {Path}", tmpPath);
161+
}
162+
}
163+
164+
File.Move(tmpPath, _filePath, overwrite: true);
165+
}
166+
167+
private static string ResolveFilePath(IConfiguration configuration)
168+
{
169+
var configured = configuration["Server:DirectoryClaimPath"];
170+
if (!string.IsNullOrWhiteSpace(configured))
171+
return configured;
172+
173+
// Co-locate with the SQLite database so a single data-directory backup captures both.
174+
var connectionString = configuration.GetConnectionString("DefaultConnection");
175+
if (!string.IsNullOrWhiteSpace(connectionString))
176+
{
177+
try
178+
{
179+
var builder = new SqliteConnectionStringBuilder(connectionString);
180+
if (!string.IsNullOrWhiteSpace(builder.DataSource))
181+
{
182+
var dir = Path.GetDirectoryName(Path.GetFullPath(builder.DataSource));
183+
if (!string.IsNullOrWhiteSpace(dir))
184+
return Path.Combine(dir, "directory-claim.json");
185+
}
186+
}
187+
catch
188+
{
189+
// Fall through to default
190+
}
191+
}
192+
193+
return Path.Combine(AppContext.BaseDirectory, "directory-claim.json");
194+
}
195+
196+
private sealed record PersistedClaim(string? ClaimToken, Guid? ServerId);
197+
}
198+
199+
public sealed record RegistrationStatus(
200+
bool IsRegistered,
201+
Guid? ServerId,
202+
DateTimeOffset? LastRegisteredAt,
203+
string? LastError,
204+
string[]? ConflictingHosts);

0 commit comments

Comments
 (0)