feat: Add invite codes and message replies functionality

- Introduced a new migration to add InviteCodes table and ReplyToMessageId column in Messages.
- Updated ChatHub to support replying to messages.
- Enhanced ChatService to handle message replies and validate reply targets.
- Modified UserService to implement invite-only registration mode with invite code consumption.
- Added configuration options for registration modes in appsettings.
- Created unit tests for new features including invite code registration and message reply formatting.
This commit is contained in:
HueByte
2026-07-17 19:47:57 +02:00
parent bb987dda82
commit 3281064720
44 changed files with 2484 additions and 74 deletions
+225 -5
View File
@@ -43,6 +43,9 @@ public sealed class AppOrchestrator : IDisposable
// Cleared on connect/reconnect; an explicit /join or a send attempt re-offers the prompt.
private readonly HashSet<string> _declinedUnlocks = new(StringComparer.OrdinalIgnoreCase);
// Pending reply target — the next text message in that channel is sent as a reply to it.
private (string Channel, Guid MessageId)? _pendingReply;
private ClientConfig _config;
private readonly UserSession _session = new();
@@ -118,6 +121,8 @@ public sealed class AppOrchestrator : IDisposable
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
_mainWindow.OnSearchRequested += HandleSearchRequested;
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
_mainWindow.OnReplyRequested += HandleReplyRequested;
_mainWindow.OnReplyCancelRequested += HandleReplyCancelRequested;
}
// ── Command Handler Wiring ─────────────────────────────────────────────
@@ -150,17 +155,200 @@ public sealed class AppOrchestrator : IDisposable
_commandHandler.OnNukeChannel += HandleCmdNukeChannel;
_commandHandler.OnTestSound += HandleCmdTestSound;
_commandHandler.OnQuit += HandleCmdQuit;
_commandHandler.OnSendAction += HandleCmdSendAction;
_commandHandler.OnSendBanner += HandleCmdSendBanner;
_commandHandler.OnCreateInvite += HandleCmdCreateInvite;
_commandHandler.OnListInvites += HandleCmdListInvites;
_commandHandler.OnRevokeInvite += HandleCmdRevokeInvite;
_commandHandler.OnExportData += HandleCmdExportData;
_commandHandler.OnDeleteAccount += HandleCmdDeleteAccount;
}
// ── Command Handlers ──────────────────────────────────────────────────
private async Task HandleCmdSetStatus(UserStatus status, string? message)
private async Task HandleCmdSetStatus(UserStatus? status, string? message)
{
if (!_conn.IsConnected) return;
await _conn.UpdateStatusAsync(status, message);
_session.Status = status;
_session.StatusMessage = message;
// null status keeps the current one; null message keeps it, empty clears it —
// so "/status away" no longer wipes your message and "/status msg brb" keeps Away
var newStatus = status ?? _session.Status;
var newMessage = message is null
? _session.StatusMessage
: (message.Length == 0 ? null : message);
await _conn.UpdateStatusAsync(newStatus, newMessage);
_session.Status = newStatus;
_session.StatusMessage = newMessage;
}
private Task HandleCmdSendAction(string text)
{
if (!_conn.IsConnected) return Task.CompletedTask;
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
// CTCP ACTION content flows through the normal send path — room encryption included
RunAsync(async () =>
{
if (!await EnsureRoomUnlockedForSendAsync(channel))
return;
await _conn.SendMessageAsync(channel, MessageConventions.FormatAction(text));
}, "Send failed");
return Task.CompletedTask;
}
private Task HandleCmdSendBanner(string text)
{
if (!_conn.IsConnected) return Task.CompletedTask;
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
var banner = AsciiBannerService.Render(text);
if (banner is null)
{
InvokeUI(() => _mainWindow.ShowError(
$"Nothing to render — /banner supports letters, digits, and basic punctuation (max {AsciiBannerService.MaxInputLength} chars)."));
return Task.CompletedTask;
}
RunAsync(async () =>
{
if (!await EnsureRoomUnlockedForSendAsync(channel))
return;
await _conn.SendMessageAsync(channel, banner);
}, "Send failed");
return Task.CompletedTask;
}
private Task HandleCmdCreateInvite(int? maxUses, int? expiresHours)
{
if (!_conn.IsAuthenticated) return Task.CompletedTask;
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
RunAsync(async () =>
{
var invite = await _conn.Api!.CreateInviteAsync(maxUses, expiresHours);
if (invite is null) return;
var expiry = invite.ExpiresAt is { } exp ? $", expires {exp.ToLocalTime():yyyy-MM-dd HH:mm}" : "";
InvokeUI(() => _messageManager.AddSystemMessage(channel,
$"Invite code: {invite.Code} (uses: {invite.MaxUses}{expiry})\n" +
$"Share it out-of-band. Revoke with: /invite revoke {invite.Code}"));
}, "Failed to create invite");
return Task.CompletedTask;
}
private Task HandleCmdListInvites()
{
if (!_conn.IsAuthenticated) return Task.CompletedTask;
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
RunAsync(async () =>
{
var invites = await _conn.Api!.GetInvitesAsync();
var text = invites.Count == 0
? "No invite codes. Create one with /invite [uses] [hours]."
: string.Join('\n', invites.Select(i =>
{
var state = i.UseCount >= i.MaxUses ? "used up"
: i.ExpiresAt is { } exp && exp <= DateTimeOffset.UtcNow ? "expired"
: i.ExpiresAt is { } e2 ? $"expires {e2.ToLocalTime():yyyy-MM-dd HH:mm}"
: "active";
return $"{i.Code} {i.UseCount}/{i.MaxUses} used ({state}, by {i.CreatedByUsername})";
}));
InvokeUI(() => _messageManager.AddSystemMessage(channel, text));
}, "Failed to list invites");
return Task.CompletedTask;
}
private Task HandleCmdRevokeInvite(string code)
{
if (!_conn.IsAuthenticated) return Task.CompletedTask;
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return Task.CompletedTask;
RunAsync(async () =>
{
await _conn.Api!.RevokeInviteAsync(code);
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Invite {code.ToUpperInvariant()} revoked."));
}, "Failed to revoke invite");
return Task.CompletedTask;
}
private Task HandleCmdExportData()
{
if (!_conn.IsAuthenticated) return Task.CompletedTask;
var channel = _mainWindow.CurrentChannel;
RunAsync(async () =>
{
var json = await _conn.Api!.ExportMyDataAsync();
var fileName = $"echohub-export-{_session.Username}-{DateTime.Now:yyyyMMdd-HHmmss}.json";
var destination = DedupPath(GetDownloadDir(), fileName);
await File.WriteAllTextAsync(destination, json);
InvokeUI(() =>
{
if (!string.IsNullOrEmpty(channel))
_messageManager.AddSystemMessage(channel,
$"Data export saved to: {destination}\n(Encrypted-room content in it is ciphertext — the server never had the plaintext.)");
});
}, "Export failed");
return Task.CompletedTask;
}
private Task HandleCmdDeleteAccount()
{
if (!_conn.IsAuthenticated) return Task.CompletedTask;
InvokeUI(() =>
{
var confirm = MessageBox.ErrorQuery(_app, "Delete Account",
$"This permanently deletes '{_session.Username}' on this server:\n" +
"profile, sessions, and every file you uploaded.\n" +
"Your messages remain, attributed to 'deleted-user'.\n\n" +
"This cannot be undone.",
"Cancel", "Delete my account");
if (confirm != 1) return;
var password = PromptPassword("Enter your password to confirm deletion:");
if (password is null) return;
PersistLastReads();
RunAsync(async () =>
{
var baseUrl = _conn.Api!.BaseUrl;
await _conn.Api!.DeleteMyAccountAsync(password);
ClearSavedToken(baseUrl);
await _conn.CleanupAsync();
InvokeUI(() =>
{
_mainWindow.ClearAll();
_mainWindow.UpdateStatusBar("Disconnected");
MessageBox.Query(_app, "Account Deleted",
"Your account and uploaded files have been deleted from this server.", "OK");
});
}, "Account deletion failed", "DeleteAccount");
});
return Task.CompletedTask;
}
/// <summary>Modal password prompt; returns null when cancelled or empty.</summary>
private string? PromptPassword(string prompt)
{
string? result = null;
var dialog = new Dialog { Title = "Confirm Password", Width = 56, Height = 8 };
var label = new Label { Text = prompt, X = 1, Y = 1 };
var field = new TextField { X = 1, Y = 2, Width = Terminal.Gui.ViewBase.Dim.Fill(2), Secret = true };
var ok = new Button { Text = "Confirm", IsDefault = true, X = Terminal.Gui.ViewBase.Pos.Center() - 12, Y = 4 };
var cancel = new Button { Text = "Cancel", X = Terminal.Gui.ViewBase.Pos.Center() + 2, Y = 4 };
ok.Accepting += (_, e) => { result = field.Text; e.Handled = true; _app.RequestStop(); };
cancel.Accepting += (_, e) => { result = null; e.Handled = true; _app.RequestStop(); };
dialog.Add(label, field, ok, cancel);
field.SetFocus();
_app.Run(dialog);
return string.IsNullOrEmpty(result) ? null : result;
}
private async Task HandleCmdSetNick(string displayName)
@@ -1193,6 +1381,8 @@ public sealed class AppOrchestrator : IDisposable
{
Log.Information("Disconnecting from server");
lock (_channelUsersLock) _channelUsers.Clear();
_pendingReply = null;
InvokeUI(() => _mainWindow.SetReplyingTo(null));
PersistLastReads();
RunAsync(async () =>
@@ -1262,14 +1452,40 @@ public sealed class AppOrchestrator : IDisposable
return;
}
// A pending reply only applies to a plain text message in its own channel
Guid? replyTo = _pendingReply is { } pending
&& pending.Channel.Equals(channelName, StringComparison.OrdinalIgnoreCase)
? pending.MessageId : null;
RunAsync(async () =>
{
if (!await EnsureRoomUnlockedForSendAsync(channelName))
return;
await _conn.SendMessageAsync(channelName, content);
await _conn.SendMessageAsync(channelName, content, replyTo);
if (replyTo is not null)
InvokeUI(ClearPendingReply);
}, "Send failed");
}
private void HandleReplyRequested(Guid messageId, string sender, string snippet)
{
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return;
_pendingReply = (channel, messageId);
if (snippet.Length > 40)
snippet = snippet[..40] + "…";
_mainWindow.SetReplyingTo($"{sender}: {snippet}");
}
private void HandleReplyCancelRequested() => ClearPendingReply();
private void ClearPendingReply()
{
_pendingReply = null;
_mainWindow.SetReplyingTo(null);
}
private void HandleDeleteMessageRequested(Guid messageId)
{
if (!_conn.IsAuthenticated) return;
@@ -1284,6 +1500,10 @@ public sealed class AppOrchestrator : IDisposable
{
if (!_conn.IsConnected) return;
// A reply pending in the previous channel doesn't carry over
if (_pendingReply is { } pending && !pending.Channel.Equals(channelName, StringComparison.OrdinalIgnoreCase))
InvokeUI(ClearPendingReply);
// Checkpoint read positions — the previous channel was just marked read
PersistLastReads();
+127 -12
View File
@@ -6,7 +6,19 @@ public record CommandResult(bool Handled, string? Message = null, bool IsError =
public class CommandHandler
{
public event Func<UserStatus, string?, Task>? OnSetStatus;
/// <summary>
/// Status update. A null status means "keep the current status"; a null message means
/// "keep the current message" and an empty message means "clear it". The orchestrator
/// resolves both against the session state.
/// </summary>
public event Func<UserStatus?, string?, Task>? OnSetStatus;
public event Func<string, Task>? OnSendAction;
public event Func<string, Task>? OnSendBanner;
public event Func<int?, int?, Task>? OnCreateInvite;
public event Func<Task>? OnListInvites;
public event Func<string, Task>? OnRevokeInvite;
public event Func<Task>? OnExportData;
public event Func<Task>? OnDeleteAccount;
public event Func<string, Task>? OnSetNick;
public event Func<string, Task>? OnSetColor;
public event Func<string, Task>? OnSetTheme;
@@ -48,6 +60,11 @@ public class CommandHandler
return command switch
{
"status" => await HandleStatus(args),
"me" => await HandleMe(args),
"banner" => await HandleBanner(args),
"invite" => await HandleInvite(args),
"export" => await HandleExport(),
"deleteaccount" => await HandleDeleteAccount(),
"nick" => await HandleNick(args),
"color" => await HandleColor(args),
"theme" => await HandleTheme(args),
@@ -78,12 +95,28 @@ public class CommandHandler
};
}
private const string StatusUsage =
"Usage: /status <online|away|dnd|invisible> or /status msg <text> (empty text clears it)";
private async Task<CommandResult> HandleStatus(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /status <online|away|dnd|invisible> or /status <message>", IsError: true);
return new CommandResult(true, StatusUsage, IsError: true);
var parts = args.Trim().Split(' ', 2, StringSplitOptions.TrimEntries);
var statusArg = parts[0].ToLowerInvariant();
// /status msg <text> — set/clear the message, keep the current status
if (statusArg is "msg" or "message")
{
var message = parts.Length > 1 ? parts[1] : string.Empty;
if (OnSetStatus is not null)
await OnSetStatus(null, message);
return new CommandResult(true, message.Length > 0
? $"Status message set: {message}"
: "Status message cleared.");
}
var statusArg = args.ToLowerInvariant().Trim();
UserStatus? status = statusArg switch
{
"online" => UserStatus.Online,
@@ -93,17 +126,92 @@ public class CommandHandler
_ => null,
};
if (status.HasValue)
// Strict: anything else is an error — no silent "it became your status message"
if (!status.HasValue)
return new CommandResult(true, $"Unknown status '{parts[0]}'. {StatusUsage}", IsError: true);
if (parts.Length > 1)
return new CommandResult(true, StatusUsage, IsError: true);
if (OnSetStatus is not null)
await OnSetStatus(status.Value, null);
return new CommandResult(true, $"Status set to {status.Value}");
}
private async Task<CommandResult> HandleMe(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /me <action> (e.g. /me waves)", IsError: true);
if (OnSendAction is not null)
await OnSendAction(args.Trim());
return new CommandResult(true);
}
private async Task<CommandResult> HandleBanner(string args)
{
if (string.IsNullOrWhiteSpace(args))
return new CommandResult(true, "Usage: /banner <text> (letters, digits, basic punctuation)", IsError: true);
if (OnSendBanner is not null)
await OnSendBanner(args.Trim());
return new CommandResult(true);
}
private async Task<CommandResult> HandleInvite(string args)
{
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
// /invite list
if (parts.Length > 0 && parts[0].Equals("list", StringComparison.OrdinalIgnoreCase))
{
if (OnSetStatus is not null)
await OnSetStatus(status.Value, null);
return new CommandResult(true, $"Status set to {status.Value}");
if (OnListInvites is not null)
await OnListInvites();
return new CommandResult(true);
}
// Treat as custom status message (keep current status)
if (OnSetStatus is not null)
await OnSetStatus(UserStatus.Online, args);
return new CommandResult(true, $"Status message set: {args}");
// /invite revoke <code>
if (parts.Length > 0 && parts[0].Equals("revoke", StringComparison.OrdinalIgnoreCase))
{
if (parts.Length < 2)
return new CommandResult(true, "Usage: /invite revoke <code>", IsError: true);
if (OnRevokeInvite is not null)
await OnRevokeInvite(parts[1]);
return new CommandResult(true);
}
// /invite [maxUses] [expiresHours] — defaults to a single-use, never-expiring code
int? maxUses = null, expiresHours = null;
if (parts.Length > 0)
{
if (!int.TryParse(parts[0], out var uses) || uses < 1)
return new CommandResult(true, "Usage: /invite [maxUses] [expiresHours] | /invite list | /invite revoke <code>", IsError: true);
maxUses = uses;
}
if (parts.Length > 1)
{
if (!int.TryParse(parts[1], out var hours) || hours < 1)
return new CommandResult(true, "Usage: /invite [maxUses] [expiresHours]", IsError: true);
expiresHours = hours;
}
if (OnCreateInvite is not null)
await OnCreateInvite(maxUses, expiresHours);
return new CommandResult(true);
}
private async Task<CommandResult> HandleExport()
{
if (OnExportData is not null)
await OnExportData();
return new CommandResult(true);
}
private async Task<CommandResult> HandleDeleteAccount()
{
if (OnDeleteAccount is not null)
await OnDeleteAccount();
return new CommandResult(true);
}
private async Task<CommandResult> HandleNick(string args)
@@ -392,7 +500,9 @@ public class CommandHandler
return new CommandResult(true, """
Available commands:
/status <online|away|dnd|invisible> - Set your status
/status <message> - Set status message
/status msg <text> - Set status message (empty = clear)
/me <action> - Action message (* nick waves)
/banner <text> - Send text as an ASCII banner
/nick <name> - Set display name
/color <#hex> - Set nickname color
/theme <name> - Switch theme
@@ -413,6 +523,9 @@ public class CommandHandler
/topic <text> - Set channel topic
/users - List online users
/meta - Show room info (size, messages, users, created, id)
/export - Download everything the server stores about you
/deleteaccount - Permanently delete your account
(Tip: right-click a message and pick Reply to quote it; Esc cancels a pending reply.)
Moderation:
/kick <user> [reason] - Kick a user (Mod+)
/ban <user> [reason] - Ban a user (Admin+)
@@ -420,6 +533,8 @@ public class CommandHandler
/mute <user> [minutes] - Mute a user (Mod+)
/unmute <user> - Unmute a user (Mod+)
/role <user> <admin|mod|member> - Assign role (Admin+)
/invite [uses] [hours] - Create a registration invite code (Admin+)
/invite list | revoke <code> - Manage invite codes (Admin+)
/nuke - Clear channel history (Mod+)
/test-sound - Play notification sound
/quit - Exit the app
+50 -2
View File
@@ -29,9 +29,9 @@ public sealed class ApiClient : IDisposable
};
}
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null)
public async Task<LoginResponse> RegisterAsync(string username, string password, string? displayName = null, string? inviteCode = null)
{
var request = new RegisterRequest(username, password, displayName);
var request = new RegisterRequest(username, password, displayName, inviteCode);
using var response = await _http.PostAsJsonAsync("/api/auth/register", request);
await EnsureSuccessAsync(response);
@@ -312,6 +312,54 @@ public sealed class ApiClient : IDisposable
await EnsureSuccessAsync(response);
}
// ── Invites / Account ─────────────────────────────────────────────────
public async Task<InviteDto?> CreateInviteAsync(int? maxUses = null, int? expiresInHours = null)
{
EnsureAuthenticated();
using var response = await AuthenticatedRequestAsync(() =>
_http.PostAsJsonAsync("/api/invites", new CreateInviteRequest(maxUses, expiresInHours)));
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<InviteDto>();
}
public async Task<List<InviteDto>> GetInvitesAsync()
{
EnsureAuthenticated();
using var response = await AuthenticatedGetAsync("/api/invites");
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<List<InviteDto>>() ?? [];
}
public async Task RevokeInviteAsync(string code)
{
EnsureAuthenticated();
using var response = await AuthenticatedRequestAsync(() =>
_http.DeleteAsync($"/api/invites/{Uri.EscapeDataString(code)}"));
await EnsureSuccessAsync(response);
}
/// <summary>Downloads the caller's full data export as raw JSON text.</summary>
public async Task<string> ExportMyDataAsync()
{
EnsureAuthenticated();
using var response = await AuthenticatedGetAsync("/api/users/me/export");
await EnsureSuccessAsync(response);
return await response.Content.ReadAsStringAsync();
}
/// <summary>Deletes the caller's account. The password re-confirms intent.</summary>
public async Task DeleteMyAccountAsync(string password)
{
EnsureAuthenticated();
using var response = await AuthenticatedRequestAsync(() =>
_http.SendAsync(new HttpRequestMessage(HttpMethod.Delete, "/api/users/me")
{
Content = JsonContent.Create(new DeleteAccountRequest(password)),
}));
await EnsureSuccessAsync(response);
}
// ── Moderation ────────────────────────────────────────────────────────
public async Task AssignRoleAsync(string username, ServerRole role)
@@ -78,7 +78,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
}
else if (info.IsRegister)
{
loginResponse = await _apiClient.RegisterAsync(info.Username, info.Password);
loginResponse = await _apiClient.RegisterAsync(
info.Username, info.Password, info.DisplayName, info.InviteCode);
}
else
{
@@ -244,8 +245,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
// ── Delegate Operations ───────────────────────────────────────────────
public Task SendMessageAsync(string channel, string content) =>
_connection?.SendMessageAsync(channel, content)
public Task SendMessageAsync(string channel, string content, Guid? replyToMessageId = null) =>
_connection?.SendMessageAsync(channel, content, replyToMessageId)
?? throw new InvalidOperationException("Not connected");
public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
@@ -198,7 +198,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
await _connection.InvokeAsync("LeaveChannel", channelName);
}
public async Task SendMessageAsync(string channelName, string content)
public async Task SendMessageAsync(string channelName, string content, Guid? replyToMessageId = null)
{
// Room layer first (end-to-end, server can't read), then transport encryption
if (_roomKeys.TryGetKey(channelName, out var roomKey))
@@ -207,7 +207,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
throw new RoomLockedException(channelName); // never fall through to plaintext
var encrypted = _encryption.Encrypt(content);
await _connection.InvokeAsync("SendMessage", channelName, encrypted);
await _connection.InvokeAsync("SendMessage", channelName, encrypted, replyToMessageId);
}
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
@@ -250,7 +250,12 @@ public sealed class EchoHubConnection : IAsyncDisposable
.ToList();
}
return message with { Content = content, Attachments = attachments };
// Reply snippets are encrypted exactly like message content
var replyTo = message.ReplyTo is { } reply
? reply with { Content = DecryptField(reply.Content, roomKey) ?? LockedMessagePlaceholder }
: null;
return message with { Content = content, Attachments = attachments, ReplyTo = replyTo };
}
/// <summary>
+7
View File
@@ -31,6 +31,12 @@ public partial class ChatLine
public AttachmentKind? AttachmentKind { get; set; }
public string? SenderUsername { get; set; }
/// <summary>
/// Set on a reply's quote line: activating the line jumps to this message
/// if it is in the loaded history.
/// </summary>
public Guid? JumpToMessageId { get; set; }
/// <summary>
/// Clickable sub-line targets (e.g. the "[open]" and "[save original]" brackets under an
/// image). Columns are relative to the unwrapped line, so only the first wrapped line
@@ -176,6 +182,7 @@ public partial class ChatLine
wrapped.MessageId = MessageId;
wrapped.SenderUsername = SenderUsername;
wrapped.IsMention = IsMention;
wrapped.JumpToMessageId = JumpToMessageId;
}
// Span columns only line up with the first wrapped line; later lines fall
@@ -1,6 +1,7 @@
using System.Text;
using System.Text.RegularExpressions;
using EchoHub.Client.UI.Helpers;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Terminal.Gui.Drawing;
@@ -312,7 +313,9 @@ public sealed class ChatMessageManager
if (!string.IsNullOrEmpty(_currentUser))
{
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
if (messages.Skip(firstUnread).Any(m => Regex.IsMatch(m.Content, pattern, RegexOptions.IgnoreCase)))
if (messages.Skip(firstUnread).Any(m =>
Regex.IsMatch(m.Content, pattern, RegexOptions.IgnoreCase)
|| (m.ReplyTo is { } reply && reply.SenderUsername.Equals(_currentUser, StringComparison.OrdinalIgnoreCase))))
_mentionChannels.Add(channelName);
}
}
@@ -391,8 +394,36 @@ public sealed class ChatMessageManager
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
var attachments = message.Attachments ?? [];
// Reply → dim quote line above the message; activating it jumps to the original
if (message.ReplyTo is { } replyTo)
lines.Add(ReplyQuoteLine(replyTo));
// /me action → "* nick waves" (CTCP ACTION content)
string? actionText = null;
var isAction = hasContent && MessageConventions.TryParseAction(message.Content, out actionText);
if (isAction)
{
var actionLines = EmojiHelper.ReplaceEmoji(actionText!).Split('\n');
var header = ActionHeaderSegments(time);
header.Add(new(senderName, senderColor));
header.Add(new(" ", null));
header.AddRange(ChatColors.SplitMentions(actionLines[0].TrimEnd('\r')));
lines.Add(new ChatLine(header));
for (int i = 1; i < actionLines.Length; i++)
{
var segments = RailPrefix();
segments.AddRange(ChatColors.SplitMentions(actionLines[i].TrimEnd('\r')));
lines.Add(new ChatLine(segments));
}
}
// Header line: caption text, or a summary when the message is attachments-only
if (hasContent)
if (isAction)
{
// already rendered above
}
else if (hasContent)
{
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
var contentLines = displayContent.Split('\n');
@@ -474,10 +505,14 @@ public sealed class ChatMessageManager
line.SenderUsername = message.SenderUsername;
}
if (hasContent && !string.IsNullOrEmpty(_currentUser))
if (!string.IsNullOrEmpty(_currentUser))
{
// Being replied to counts as a mention, same as an explicit @nick
var isReplyToMe = message.ReplyTo is { } reply
&& reply.SenderUsername.Equals(_currentUser, StringComparison.OrdinalIgnoreCase);
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
if (isReplyToMe
|| ((hasContent || isAction) && Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase)))
{
foreach (var line in lines)
line.IsMention = true;
@@ -558,6 +593,54 @@ public sealed class ChatMessageManager
new(" │ ", ChatColors.RailAttr),
];
/// <summary>Header variant for /me actions: "*" in the nick column, "* nick text" content.</summary>
private static List<ChatSegment> ActionHeaderSegments(string time) =>
[
new($"{time} ", ChatColors.TimestampAttr),
new(PadNick("*"), ChatColors.TimestampAttr),
new(" │ ", ChatColors.RailAttr),
];
/// <summary>
/// The dim "┌ nick: snippet" line above a reply. Carries the original message id so
/// activating it jumps there. Room-encrypted snippets arrive already decrypted (or as
/// the locked placeholder) — this only truncates for display.
/// </summary>
private static ChatLine ReplyQuoteLine(ReplyRefDto replyTo)
{
const int maxSnippetCols = 60;
var snippet = replyTo.Content.Replace('\n', ' ').Replace('\r', ' ');
if (MessageConventions.TryParseAction(snippet, out var actionText))
snippet = $"* {replyTo.SenderUsername} {actionText}";
snippet = EmojiHelper.ReplaceEmoji(snippet);
if (snippet.GetColumns() > maxSnippetCols)
{
var sb = new StringBuilder();
int used = 0;
foreach (var g in GraphemeHelper.GetGraphemes(snippet))
{
var gCols = Math.Max(g.GetColumns(), 1);
if (used + gCols > maxSnippetCols - 1) break;
sb.Append(g);
used += gCols;
}
snippet = sb.Append('…').ToString();
}
var segments = RailPrefix();
segments.Add(new("┌ ", ChatColors.RailAttr));
segments.Add(new($"{replyTo.SenderUsername}: ", NickColorHelper.GetAttribute(replyTo.SenderUsername)));
segments.Add(new(snippet, ChatColors.SystemAttr));
return new ChatLine(segments)
{
JumpToMessageId = replyTo.MessageId,
ContinuationPrefixSegments = RailPrefix(),
};
}
/// <summary>
/// Indent segments aligning continuation/attachment/embed lines under the message
/// text, extending the │ rail. Returns a fresh mutable list each call.
+27 -6
View File
@@ -11,7 +11,8 @@ namespace EchoHub.Client.UI.Dialogs;
/// </summary>
public record ConnectDialogResult(
string ServerUrl, string Username, string Password,
bool IsRegister, bool RememberMe, string? SavedRefreshToken);
bool IsRegister, bool RememberMe, string? SavedRefreshToken,
string? DisplayName = null, string? InviteCode = null);
/// <summary>
/// A Terminal.Gui dialog for entering server connection and authentication details.
@@ -29,7 +30,7 @@ public sealed class ConnectDialog
savedServers ??= [];
var hasSavedServers = savedServers.Count > 0;
var dialogHeight = hasSavedServers ? 22 : 18;
var dialogHeight = hasSavedServers ? 24 : 20;
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
@@ -153,26 +154,41 @@ public sealed class ConnectDialog
Width = Dim.Fill(2)
};
// Only needed on servers with invite-gated registration; harmless elsewhere
var inviteLabel = new Label
{
Text = "Invite Code:",
X = 1,
Y = yOffset + 11
};
var inviteField = new TextField
{
Text = "",
X = 15,
Y = yOffset + 11,
Width = Dim.Fill(2)
};
var loginButton = new Button
{
Text = "Login",
IsDefault = true,
X = Pos.Center() - 20,
Y = yOffset + 11
Y = yOffset + 13
};
var registerButton = new Button
{
Text = "Register",
X = Pos.Center() - 5,
Y = yOffset + 11
Y = yOffset + 13
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center() + 10,
Y = yOffset + 11
Y = yOffset + 13
};
// Wire saved server selection to auto-fill fields
@@ -262,7 +278,11 @@ public sealed class ConnectDialog
return;
}
result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null);
var displayName = displayField.Text?.Trim();
var inviteCode = inviteField.Text?.Trim();
result = new ConnectDialogResult(url, user, pass, IsRegister: true, rememberMe, SavedRefreshToken: null,
DisplayName: string.IsNullOrEmpty(displayName) ? null : displayName,
InviteCode: string.IsNullOrEmpty(inviteCode) ? null : inviteCode);
e.Handled = true;
app.RequestStop();
};
@@ -276,6 +296,7 @@ public sealed class ConnectDialog
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
tokenHintLabel, rememberMeCheckbox, displayLabel, displayField,
inviteLabel, inviteField,
loginButton, registerButton, cancelButton);
if (hasSavedServers && savedServerList is not null)
+92 -4
View File
@@ -64,10 +64,11 @@ public sealed partial class MainWindow : Runnable
// Available slash commands for Tab autocomplete
private static readonly string[] SlashCommands =
[
"/status", "/nick", "/color", "/theme", "/send",
"/status", "/nick", "/color", "/theme", "/send", "/me", "/banner",
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/size", "/downloadpath",
"/topic", "/users", "/kick", "/ban", "/unban",
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
"/mute", "/unmute", "/role", "/invite", "/export", "/deleteaccount",
"/nuke", "/test-sound", "/quit", "/help"
];
private readonly List<string> _channelNames = [];
@@ -187,6 +188,17 @@ public sealed partial class MainWindow : Runnable
/// </summary>
public event Action<Guid>? OnDeleteMessageRequested;
/// <summary>
/// Fired when the user picks "Reply" on a message. Parameters: message id, sender username,
/// a short plain-text snippet for the reply strip.
/// </summary>
public event Action<Guid, string, string>? OnReplyRequested;
/// <summary>
/// Fired when the user cancels a pending reply (Esc in the input field).
/// </summary>
public event Action? OnReplyCancelRequested;
/// <summary>
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
/// </summary>
@@ -357,6 +369,9 @@ public sealed partial class MainWindow : Runnable
KeyDown += OnWindowKeyDown;
}
private string? _stagedTitleFragment;
private string? _replyTitleFragment;
/// <summary>
/// Updates the attachment staging indicator shown on the input frame's title, including the
/// current ASCII-art size for images. Passing an empty list restores the default hint.
@@ -366,15 +381,38 @@ public sealed partial class MainWindow : Runnable
_hasStagedAttachments = fileNames.Count > 0;
if (fileNames.Count == 0)
{
_inputFrame.Title = DefaultInputTitle;
_stagedTitleFragment = null;
}
else
{
var names = string.Join(", ", fileNames);
if (names.Length > 45)
names = names[..42] + "...";
_inputFrame.Title = $"📎 {fileNames.Count}: {names} │ art: {asciiSizeLabel} (/size) │ Enter=send │ /clear";
_stagedTitleFragment = $"📎 {fileNames.Count}: {names} │ art: {asciiSizeLabel} (/size) │ Enter=send │ /clear";
}
UpdateInputTitle();
}
/// <summary>
/// Shows/clears the "replying to" strip on the input frame's title. Pass null to clear.
/// </summary>
public void SetReplyingTo(string? label)
{
_replyTitleFragment = label is null ? null : $"↩ Replying to {label} │ Esc=cancel";
UpdateInputTitle();
}
public bool HasPendingReplyIndicator => _replyTitleFragment is not null;
private void UpdateInputTitle()
{
_inputFrame.Title = (_replyTitleFragment, _stagedTitleFragment) switch
{
(null, null) => DefaultInputTitle,
({ } reply, null) => reply,
(null, { } staged) => staged,
({ } reply, { } staged) => $"{reply} │ {staged}",
};
_inputFrame.SetNeedsDraw();
}
@@ -519,6 +557,14 @@ public sealed partial class MainWindow : Runnable
var line = source.GetLine(index.Value);
if (line is null) return;
// A reply's quote line jumps to the original message (if it's in the buffer)
if (line.JumpToMessageId is { } jumpTarget)
{
ScrollToMessage(jumpTarget);
e.Handled = true;
return;
}
// Audio/file attachments take priority
if (line.AttachmentUrl is not null && line.AttachmentFileName is not null)
{
@@ -681,6 +727,20 @@ public sealed partial class MainWindow : Runnable
}
}
if (sender is not null && line.MessageId is { } replyTargetId)
{
items.Add(new MenuItem("Reply", "", () =>
{
// Strip the "HH:mm nick │ " header so the strip shows just the text
var snippet = line.ToString();
var railIdx = snippet.IndexOf(" │ ", StringComparison.Ordinal);
if (railIdx >= 0)
snippet = snippet[(railIdx + 3)..];
OnReplyRequested?.Invoke(replyTargetId, sender, snippet.Trim());
_inputField.SetFocus();
}, Key.Empty));
}
if (sender is not null)
{
items.Add(new MenuItem($"Mention @{sender}", "", () => MentionUser(sender), Key.Empty));
@@ -722,6 +782,30 @@ public sealed partial class MainWindow : Runnable
}
}
/// <summary>
/// Scrolls the message list to a message's first line (used by reply quote lines).
/// No-op when the message isn't in the loaded buffer.
/// </summary>
private void ScrollToMessage(Guid messageId)
{
if (_messageList.Source is not ChatListSource source)
return;
for (int i = 0; i < source.Count; i++)
{
var line = source.GetLine(i);
// Match the message's own lines, not other replies' quote lines pointing at it
if (line?.MessageId == messageId && line.JumpToMessageId is null)
{
_messageList.SelectedItem = i;
_messageList.TopItem = Math.Max(0, i - 3);
_messageList.SetFocus();
_messageList.SetNeedsDraw();
return;
}
}
}
private void ConfirmDeleteMessage(Guid messageId)
{
var confirm = MessageBox.Query(_app, "Delete Message", "Delete this message?", "Delete", "Cancel");
@@ -757,6 +841,10 @@ public sealed partial class MainWindow : Runnable
TryAutocompleteCommand();
break;
case KeyCode.Esc when HasPendingReplyIndicator:
OnReplyCancelRequested?.Invoke();
break;
case NewlineKey:
_inputField.InsertText("\n");
break;
@@ -0,0 +1,31 @@
using System.Diagnostics.CodeAnalysis;
namespace EchoHub.Core.Constants;
/// <summary>
/// Cross-protocol message conventions. Action messages (<c>/me</c>) use the IRC CTCP ACTION
/// wire format 0x01 + "ACTION " + text + 0x01 as the stored content, so IRC clients
/// interoperate natively (irssi's /me arrives in exactly this shape) and the TUI renders
/// "* nick text". In end-to-end encrypted rooms the marker encrypts along with the text.
/// </summary>
public static class MessageConventions
{
public const string ActionPrefix = "\u0001ACTION ";
public const string ActionSuffix = "\u0001";
public static string FormatAction(string text) => $"{ActionPrefix}{text}{ActionSuffix}";
public static bool TryParseAction(string content, [NotNullWhen(true)] out string? actionText)
{
if (content.Length > ActionPrefix.Length + ActionSuffix.Length
&& content.StartsWith(ActionPrefix, StringComparison.Ordinal)
&& content.EndsWith(ActionSuffix, StringComparison.Ordinal))
{
actionText = content[ActionPrefix.Length..^ActionSuffix.Length];
return actionText.Length > 0;
}
actionText = null;
return false;
}
}
+3 -2
View File
@@ -15,8 +15,9 @@ public interface IChatService
// Messaging. originConnectionId identifies the connection the message came from so
// broadcasters can avoid echoing it back to that one connection (IRC convention);
// the sender's other sessions still receive it.
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null);
// the sender's other sessions still receive it. replyToMessageId references the message
// being replied to; it must exist in the same channel.
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null);
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0);
// Presence
+3 -1
View File
@@ -4,7 +4,9 @@ namespace EchoHub.Core.Contracts;
public interface IUserService
{
Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null);
// inviteCode is required when the server runs with Server:Registration = "invite";
// "closed" refuses all new accounts. Both REST and the IRC gateway funnel through here.
Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null, string? inviteCode = null);
Task<UserOperationResult> AuthenticateUserAsync(string username, string password);
Task<UserProfileDto?> GetUserProfileAsync(string username);
Task<UserProfileDto?> GetUserByIdAsync(Guid userId);
+30
View File
@@ -0,0 +1,30 @@
namespace EchoHub.Core.DTOs;
/// <summary>Password re-confirmation for destructive self-service account actions.</summary>
public record DeleteAccountRequest(string Password);
/// <summary>
/// Everything the server holds about a user, as stored. For end-to-end encrypted rooms the
/// message content in here is room ciphertext — the server cannot include plaintext it never had.
/// </summary>
public record UserDataExportDto(
DateTimeOffset ExportedAt,
string ServerName,
UserProfileDto Profile,
List<ExportedMessageDto> Messages,
List<ExportedAttachmentDto> Attachments);
public record ExportedMessageDto(
Guid Id,
string ChannelName,
DateTimeOffset SentAt,
string Content,
Guid? ReplyToMessageId);
public record ExportedAttachmentDto(
string FileName,
string Url,
long FileSize,
string Kind,
string ChannelName,
DateTimeOffset SentAt);
+1 -1
View File
@@ -1,6 +1,6 @@
namespace EchoHub.Core.DTOs;
public record RegisterRequest(string Username, string Password, string? DisplayName = null);
public record RegisterRequest(string Username, string Password, string? DisplayName = null, string? InviteCode = null);
public record LoginRequest(string Username, string Password);
+13 -1
View File
@@ -11,7 +11,19 @@ public record MessageDto(
DateTimeOffset SentAt,
List<AttachmentDto>? Attachments = null,
List<EmbedDto>? Embeds = null,
string? SenderDisplayName = null);
string? SenderDisplayName = null,
ReplyRefDto? ReplyTo = null);
/// <summary>
/// Reference to the message a reply targets. <see cref="Content"/> is treated exactly like
/// message content on the wire: transport-encrypted, and for end-to-end encrypted rooms it is
/// room ciphertext the client must decrypt (the server truncates only plaintext snippets).
/// Null on a <see cref="MessageDto"/> when the original message no longer exists.
/// </summary>
public record ReplyRefDto(
Guid MessageId,
string SenderUsername,
string Content);
/// <summary>
/// A file attached to a message. <see cref="AsciiPreview"/> holds the color-tag art for
+11
View File
@@ -0,0 +1,11 @@
namespace EchoHub.Core.DTOs;
public record CreateInviteRequest(int? MaxUses = null, int? ExpiresInHours = null);
public record InviteDto(
string Code,
string CreatedByUsername,
DateTimeOffset CreatedAt,
DateTimeOffset? ExpiresAt,
int MaxUses,
int UseCount);
+6 -1
View File
@@ -1,5 +1,10 @@
namespace EchoHub.Core.DTOs;
public record ServerStatusDto(string Name, string? Description, int OnlineUsers, int TotalChannels);
public record ServerStatusDto(
string Name,
string? Description,
int OnlineUsers,
int TotalChannels,
string RegistrationMode = "open");
public record EncryptionKeyResponse(string Key);
+20
View File
@@ -0,0 +1,20 @@
namespace EchoHub.Core.Models;
/// <summary>
/// A registration invite code. When the server runs with <c>Server:Registration = "invite"</c>,
/// new accounts (REST and IRC alike) require a valid, unexpired, not-fully-used code.
/// </summary>
public class InviteCode
{
public Guid Id { get; set; }
public required string Code { get; set; }
public Guid CreatedByUserId { get; set; }
public string CreatedByUsername { get; set; } = string.Empty;
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
/// <summary>Null means the code never expires.</summary>
public DateTimeOffset? ExpiresAt { get; set; }
public int MaxUses { get; set; } = 1;
public int UseCount { get; set; }
}
+3
View File
@@ -16,6 +16,9 @@ public class Message
public Guid SenderUserId { get; set; }
public required string SenderUsername { get; set; }
/// <summary>Message this one replies to, if any. The target may since have been deleted.</summary>
public Guid? ReplyToMessageId { get; set; }
/// <summary>Files attached to this message. Empty for a plain text message.</summary>
public List<Attachment> Attachments { get; set; } = [];
@@ -0,0 +1,104 @@
using System.Text;
namespace EchoHub.Core.Services;
/// <summary>
/// Renders short text as a 5-row block-character banner (the <c>/banner</c> command).
/// Entirely local — a hand-rolled figlet-style font, no dependencies, no network.
/// Output is plain message content, so it travels (and encrypts) like any other text.
/// </summary>
public static class AsciiBannerService
{
public const int MaxInputLength = 20;
private const int Rows = 5;
/// <summary>
/// Renders <paramref name="text"/> as banner lines joined with '\n'.
/// Returns null when the input is empty or contains no renderable characters.
/// Characters outside the font (letters, digits, and basic punctuation) are skipped.
/// </summary>
public static string? Render(string text)
{
if (string.IsNullOrWhiteSpace(text))
return null;
text = text.Trim();
if (text.Length > MaxInputLength)
text = text[..MaxInputLength];
var glyphs = new List<string[]>();
foreach (var ch in text.ToUpperInvariant())
{
if (Font.TryGetValue(ch, out var glyph))
glyphs.Add(glyph);
}
if (glyphs.Count == 0 || glyphs.All(g => g == Font[' ']))
return null;
var lines = new StringBuilder();
for (var row = 0; row < Rows; row++)
{
if (row > 0)
lines.Append('\n');
for (var i = 0; i < glyphs.Count; i++)
{
if (i > 0)
lines.Append(' ');
lines.Append(glyphs[i][row].Replace('#', '█').Replace('.', ' '));
}
}
// Trim trailing spaces per line — cheaper payload, identical rendering
return string.Join('\n', lines.ToString().Split('\n').Select(l => l.TrimEnd()));
}
// Glyphs are authored with '#' (ink) and '.' (blank); all rows of a glyph share one width.
private static readonly Dictionary<char, string[]> Font = new()
{
[' '] = ["..", "..", "..", "..", ".."],
['A'] = [".##.", "#..#", "####", "#..#", "#..#"],
['B'] = ["###.", "#..#", "###.", "#..#", "###."],
['C'] = [".###", "#...", "#...", "#...", ".###"],
['D'] = ["###.", "#..#", "#..#", "#..#", "###."],
['E'] = ["####", "#...", "###.", "#...", "####"],
['F'] = ["####", "#...", "###.", "#...", "#..."],
['G'] = [".###", "#...", "#.##", "#..#", ".###"],
['H'] = ["#..#", "#..#", "####", "#..#", "#..#"],
['I'] = ["###", ".#.", ".#.", ".#.", "###"],
['J'] = ["..##", "...#", "...#", "#..#", ".##."],
['K'] = ["#..#", "#.#.", "##..", "#.#.", "#..#"],
['L'] = ["#...", "#...", "#...", "#...", "####"],
['M'] = ["#...#", "##.##", "#.#.#", "#...#", "#...#"],
['N'] = ["#...#", "##..#", "#.#.#", "#..##", "#...#"],
['O'] = [".##.", "#..#", "#..#", "#..#", ".##."],
['P'] = ["###.", "#..#", "###.", "#...", "#..."],
['Q'] = [".##.", "#..#", "#..#", "#.##", ".###"],
['R'] = ["###.", "#..#", "###.", "#.#.", "#..#"],
['S'] = [".###", "#...", ".##.", "...#", "###."],
['T'] = ["###", ".#.", ".#.", ".#.", ".#."],
['U'] = ["#..#", "#..#", "#..#", "#..#", ".##."],
['V'] = ["#...#", "#...#", "#...#", ".#.#.", "..#.."],
['W'] = ["#...#", "#...#", "#.#.#", "##.##", "#...#"],
['X'] = ["#...#", ".#.#.", "..#..", ".#.#.", "#...#"],
['Y'] = ["#...#", ".#.#.", "..#..", "..#..", "..#.."],
['Z'] = ["####", "...#", "..#.", ".#..", "####"],
['0'] = [".##.", "#.##", "##.#", "#..#", ".##."],
['1'] = [".#.", "##.", ".#.", ".#.", "###"],
['2'] = [".##.", "#..#", "..#.", ".#..", "####"],
['3'] = ["###.", "...#", ".##.", "...#", "###."],
['4'] = ["#..#", "#..#", "####", "...#", "...#"],
['5'] = ["####", "#...", "###.", "...#", "###."],
['6'] = [".##.", "#...", "###.", "#..#", ".##."],
['7'] = ["####", "...#", "..#.", ".#..", ".#.."],
['8'] = [".##.", "#..#", ".##.", "#..#", ".##."],
['9'] = [".##.", "#..#", ".###", "...#", ".##."],
['!'] = ["#", "#", "#", ".", "#"],
['?'] = [".##.", "#..#", "..#.", "....", "..#."],
['.'] = [".", ".", ".", ".", "#"],
[','] = ["..", "..", "..", ".#", "#."],
['-'] = ["....", "....", "####", "....", "...."],
['\''] = ["#", "#", ".", ".", "."],
[':'] = [".", "#", ".", "#", "."],
};
}
+5 -1
View File
@@ -18,7 +18,11 @@ public class IrcBroadcaster : IChatBroadcaster
{
// Decrypt transport-encrypted content for IRC clients (they can't handle
// app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched.
var decryptedMessage = message with { Content = _encryption.Decrypt(message.Content) };
var decryptedMessage = message with
{
Content = _encryption.Decrypt(message.Content),
ReplyTo = message.ReplyTo is { } reply ? reply with { Content = _encryption.Decrypt(reply.Content) } : null,
};
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl);
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
+5 -1
View File
@@ -432,7 +432,11 @@ public sealed class IrcCommandHandler
// Replay history (decrypt — history is encrypted for SignalR transport)
foreach (var m in history)
{
var decrypted = m with { Content = _encryption.Decrypt(m.Content) };
var decrypted = m with
{
Content = _encryption.Decrypt(m.Content),
ReplyTo = m.ReplyTo is { } reply ? reply with { Content = _encryption.Decrypt(reply.Content) } : null,
};
var lines = IrcMessageFormatter.FormatMessage(decrypted, _options.PublicBaseUrl);
foreach (var line in lines)
await _conn.SendAsync(line);
+48 -2
View File
@@ -1,12 +1,15 @@
using System.Text;
using EchoHub.Core.Constants;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Core.Security;
namespace EchoHub.Server.Irc;
public static class IrcMessageFormatter
{
private const int MaxIrcLineContentBytes = 400;
private const int MaxReplySnippetLength = 80;
/// <summary>
/// Format a MessageDto as one or more IRC PRIVMSG lines.
@@ -20,11 +23,32 @@ public static class IrcMessageFormatter
var ircChannel = $"#{message.ChannelName}";
var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub";
// Reply reference → the "> nick: snippet" quoting convention IRC users know
var replyPrefix = FormatReplyPrefix(message.ReplyTo);
// Caption text first (may be empty when the message is attachments-only)
if (!string.IsNullOrEmpty(message.Content))
{
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
// /me actions arrive as CTCP ACTION content; each chunk must stay a
// well-formed CTCP message (\x01ACTION …\x01) or clients render garbage.
if (MessageConventions.TryParseAction(message.Content, out var actionText))
{
if (replyPrefix is not null)
lines.Add($"{prefix} PRIVMSG {ircChannel} :{replyPrefix.TrimEnd(' ', '|', ' ')}");
foreach (var chunk in SplitMessage(actionText, MaxIrcLineContentBytes))
lines.Add($"{prefix} PRIVMSG {ircChannel} :{MessageConventions.FormatAction(chunk)}");
}
else
{
var content = replyPrefix is not null ? replyPrefix + message.Content : message.Content;
foreach (var chunk in SplitMessage(content, MaxIrcLineContentBytes))
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
}
}
else if (replyPrefix is not null)
{
lines.Add($"{prefix} PRIVMSG {ircChannel} :{replyPrefix.TrimEnd(' ', '|', ' ')}");
}
// One link line per attachment
@@ -53,6 +77,28 @@ public static class IrcMessageFormatter
return lines;
}
/// <summary>
/// "&gt; nick: snippet | " prefix for replies. Room ciphertext can't be rendered
/// (IRC can't join encrypted rooms anyway) and is shown as a placeholder.
/// </summary>
private static string? FormatReplyPrefix(ReplyRefDto? replyTo)
{
if (replyTo is null)
return null;
var snippet = RoomCrypto.IsRoomCiphertext(replyTo.Content)
? "[encrypted]"
: replyTo.Content.Replace('\n', ' ').Replace('\r', ' ');
if (MessageConventions.TryParseAction(snippet, out var actionText))
snippet = $"* {replyTo.SenderUsername} {actionText}";
if (snippet.Length > MaxReplySnippetLength)
snippet = snippet[..MaxReplySnippetLength] + "…";
return $"> {replyTo.SenderUsername}: {snippet} | ";
}
/// <summary>
/// Joins a relative attachment path onto the configured public base URL.
/// Already-absolute URLs and unset base URLs pass through unchanged.
@@ -28,7 +28,7 @@ public class AuthController : ControllerBase
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
var result = await _userService.RegisterUserAsync(request.Username, request.Password, request.DisplayName);
var result = await _userService.RegisterUserAsync(request.Username, request.Password, request.DisplayName, request.InviteCode);
if (!result.IsSuccess)
return MapUserError(result);
@@ -0,0 +1,129 @@
using System.Security.Claims;
using System.Security.Cryptography;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
/// <summary>
/// Invite-code management for invite-gated registration (Admin+ only).
/// Codes live in this server's own database — there is no central service.
/// </summary>
[ApiController]
[Route("api/invites")]
[Authorize]
[EnableRateLimiting("general")]
public class InvitesController : ControllerBase
{
private const int MaxActiveInvites = 200;
private readonly EchoHubDbContext _db;
private readonly ILogger<InvitesController> _logger;
public InvitesController(EchoHubDbContext db, ILogger<InvitesController> logger)
{
_db = db;
_logger = logger;
}
[HttpPost]
public async Task<IActionResult> Create([FromBody] CreateInviteRequest request)
{
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var maxUses = request.MaxUses ?? 1;
if (maxUses is < 1 or > 1000)
return BadRequest(new ErrorResponse("MaxUses must be between 1 and 1000."));
if (request.ExpiresInHours is < 1 or > 24 * 365)
return BadRequest(new ErrorResponse("ExpiresInHours must be between 1 and 8760."));
if (await _db.InviteCodes.CountAsync(i => i.UseCount < i.MaxUses) >= MaxActiveInvites)
return BadRequest(new ErrorResponse($"Too many active invites (max {MaxActiveInvites}). Revoke unused ones first."));
var invite = new InviteCode
{
Id = Guid.NewGuid(),
Code = GenerateCode(),
CreatedByUserId = caller!.Id,
CreatedByUsername = caller.Username,
CreatedAt = DateTimeOffset.UtcNow,
ExpiresAt = request.ExpiresInHours is { } hours ? DateTimeOffset.UtcNow.AddHours(hours) : null,
MaxUses = maxUses,
};
_db.InviteCodes.Add(invite);
await _db.SaveChangesAsync();
_logger.LogInformation("Invite code created by {User} (uses: {MaxUses}, expires: {Expires})",
caller.Username, invite.MaxUses, invite.ExpiresAt?.ToString("u") ?? "never");
return Ok(ToDto(invite));
}
[HttpGet]
public async Task<IActionResult> List()
{
var (_, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var invites = await _db.InviteCodes
.OrderByDescending(i => i.CreatedAt)
.ToListAsync();
return Ok(invites.Select(ToDto).ToList());
}
[HttpDelete("{code}")]
public async Task<IActionResult> Revoke(string code)
{
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
if (error is not null) return error;
var normalized = code.Trim().ToUpperInvariant();
var invite = await _db.InviteCodes.FirstOrDefaultAsync(i => i.Code == normalized);
if (invite is null)
return NotFound(new ErrorResponse("Invite code not found."));
_db.InviteCodes.Remove(invite);
await _db.SaveChangesAsync();
_logger.LogInformation("Invite code revoked by {User}", caller!.Username);
return Ok();
}
/// <summary>Unguessable, unambiguous code like "K7QM-3XPF" (no 0/O/1/I).</summary>
private static string GenerateCode()
{
const string alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
Span<char> chars = stackalloc char[8];
for (var i = 0; i < chars.Length; i++)
chars[i] = alphabet[RandomNumberGenerator.GetInt32(alphabet.Length)];
return $"{new string(chars[..4])}-{new string(chars[4..])}";
}
private static InviteDto ToDto(InviteCode i) =>
new(i.Code, i.CreatedByUsername, i.CreatedAt, i.ExpiresAt, i.MaxUses, i.UseCount);
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);
}
}
@@ -31,11 +31,19 @@ public class ServerController : ControllerBase
var userCount = await _db.Users.CountAsync();
var channelCount = await _db.Channels.CountAsync();
var registrationMode = (_config["Server:Registration"] ?? "open").Trim().ToLowerInvariant() switch
{
"invite" => "invite",
"closed" => "closed",
_ => "open",
};
var status = new ServerStatusDto(
_config["Server:Name"] ?? "EchoHub Server",
_config["Server:Description"],
userCount,
channelCount);
channelCount,
registrationMode);
return Ok(status);
}
@@ -1,13 +1,16 @@
using System.Security.Claims;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.Models;
using EchoHub.Core.Services;
using EchoHub.Core.DTOs;
using EchoHub.Server.Config;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace EchoHub.Server.Controllers;
@@ -17,15 +20,45 @@ namespace EchoHub.Server.Controllers;
[EnableRateLimiting("general")]
public class UsersController : ControllerBase
{
/// <summary>
/// Username tombstoned messages are re-attributed to after account deletion.
/// Reserved — <see cref="UserService"/> refuses to register it.
/// </summary>
public const string DeletedUserName = "deleted-user";
private readonly IUserService _userService;
private readonly ImageToAsciiService _asciiService;
private readonly UploadLimits _uploadLimits;
private readonly EchoHubDbContext _db;
private readonly IMessageEncryptionService _encryption;
private readonly FileStorageService _fileStorage;
private readonly PresenceTracker _presenceTracker;
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
private readonly IConfiguration _config;
private readonly ILogger<UsersController> _logger;
public UsersController(IUserService userService, ImageToAsciiService asciiService, UploadLimits uploadLimits)
public UsersController(
IUserService userService,
ImageToAsciiService asciiService,
UploadLimits uploadLimits,
EchoHubDbContext db,
IMessageEncryptionService encryption,
FileStorageService fileStorage,
PresenceTracker presenceTracker,
IEnumerable<IChatBroadcaster> broadcasters,
IConfiguration config,
ILogger<UsersController> logger)
{
_userService = userService;
_asciiService = asciiService;
_uploadLimits = uploadLimits;
_db = db;
_encryption = encryption;
_fileStorage = fileStorage;
_presenceTracker = presenceTracker;
_broadcasters = broadcasters;
_config = config;
_logger = logger;
}
[HttpGet("{username}/profile")]
@@ -85,6 +118,139 @@ public class UsersController : ControllerBase
return Ok(new AvatarUploadResponse(asciiArt));
}
/// <summary>
/// Everything the server stores about the caller, as stored: profile, their messages
/// (end-to-end encrypted room content stays ciphertext — the server never had plaintext),
/// and metadata of their uploaded attachments. "You own the data" made demonstrable.
/// </summary>
[HttpGet("me/export")]
public async Task<IActionResult> ExportMyData()
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var userId = Guid.Parse(userIdClaim);
var profile = await _userService.GetUserByIdAsync(userId);
if (profile is null)
return Unauthorized(new ErrorResponse("User not found."));
var messages = await _db.Messages
.Where(m => m.SenderUserId == userId)
.OrderBy(m => m.SentAt)
.Join(_db.Channels, m => m.ChannelId, c => c.Id, (m, c) => new { m, ChannelName = c.Name })
.ToListAsync();
var messageIds = messages.Select(x => x.m.Id).ToList();
var attachments = await _db.Attachments
.Where(a => messageIds.Contains(a.MessageId))
.ToListAsync();
var messageById = messages.ToDictionary(x => x.m.Id, x => x);
var export = new UserDataExportDto(
DateTimeOffset.UtcNow,
_config["Server:Name"] ?? "EchoHub Server",
profile,
messages.Select(x => new ExportedMessageDto(
x.m.Id,
x.ChannelName,
x.m.SentAt,
// Strip only the server's at-rest layer; room ciphertext passes through as-is
_encryption.Decrypt(x.m.Content),
x.m.ReplyToMessageId)).ToList(),
attachments.Select(a =>
{
var owner = messageById[a.MessageId];
return new ExportedAttachmentDto(
a.FileName, a.Url, a.FileSize, a.Kind.ToString(),
owner.ChannelName, owner.m.SentAt);
}).ToList());
_logger.LogInformation("{User} exported their data ({Messages} messages, {Attachments} attachments)",
profile.Username, export.Messages.Count, export.Attachments.Count);
return Ok(export);
}
/// <summary>
/// Self-service account deletion (password re-confirmed). Removes the account, its refresh
/// tokens and memberships (FK cascade), and every attachment blob the user uploaded.
/// Their messages are kept but tombstoned to <see cref="DeletedUserName"/> — deleting them
/// outright would silently gut other people's conversations.
/// </summary>
[HttpDelete("me")]
public async Task<IActionResult> DeleteMyAccount([FromBody] DeleteAccountRequest request)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var user = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
if (user is null)
return Unauthorized(new ErrorResponse("User not found."));
if (string.IsNullOrEmpty(request.Password) || !BCrypt.Net.BCrypt.Verify(request.Password, user.PasswordHash))
return Unauthorized(new ErrorResponse("Password is incorrect."));
if (user.Role == ServerRole.Owner
&& !await _db.Users.AnyAsync(u => u.Role == ServerRole.Owner && u.Id != user.Id))
{
return BadRequest(new ErrorResponse(
"You are the only Owner of this server. Promote another Owner (or shut the server down) before deleting this account."));
}
var username = user.Username;
// Their uploaded blobs: attachments hanging off their messages
var attachmentInfo = await _db.Messages
.Where(m => m.SenderUserId == user.Id)
.SelectMany(m => m.Attachments)
.Select(a => new { a.Id, a.Url })
.ToListAsync();
foreach (var fileId in attachmentInfo
.Select(a => a.Url.Split('/').LastOrDefault())
.Where(id => !string.IsNullOrEmpty(id)))
{
try { _fileStorage.DeleteFile(fileId!); }
catch (Exception ex) { _logger.LogWarning(ex, "Failed to delete blob {FileId} during account deletion", fileId); }
}
var attachmentIds = attachmentInfo.Select(a => a.Id).ToList();
await _db.Attachments.Where(a => attachmentIds.Contains(a.Id)).ExecuteDeleteAsync();
// Tombstone their messages, then remove the account (cascades tokens + memberships)
await _db.Messages
.Where(m => m.SenderUserId == user.Id)
.ExecuteUpdateAsync(s => s
.SetProperty(m => m.SenderUserId, Guid.Empty)
.SetProperty(m => m.SenderUsername, DeletedUserName));
_db.Users.Remove(user);
await _db.SaveChangesAsync();
// Kick their live sessions and clear them from user lists everywhere
var (connectionIds, channels) = _presenceTracker.ForceRemoveUser(username);
foreach (var channel in channels)
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channel, username));
if (connectionIds.Count > 0)
await BroadcastToAllAsync(b => b.ForceDisconnectUserAsync(connectionIds, "Account deleted."));
_logger.LogInformation("Account '{User}' self-deleted ({Attachments} attachment blobs removed)",
username, attachmentIds.Count);
return Ok();
}
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
{
foreach (var broadcaster in _broadcasters)
{
try { await action(broadcaster); }
catch { /* logged by broadcaster */ }
}
}
private IActionResult MapUserError(UserOperationResult result) => result.Error switch
{
UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
@@ -13,6 +13,7 @@ public class EchoHubDbContext : DbContext
public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
public DbSet<InviteCode> InviteCodes => Set<InviteCode>();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
@@ -98,6 +99,14 @@ public class EchoHubDbContext : DbContext
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<InviteCode>(entity =>
{
entity.HasKey(i => i.Id);
entity.HasIndex(i => i.Code).IsUnique();
entity.Property(i => i.Code).IsRequired().HasMaxLength(32);
entity.Property(i => i.CreatedByUsername).IsRequired().HasMaxLength(50);
});
modelBuilder.Entity<RefreshToken>(entity =>
{
entity.HasKey(r => r.Id);
@@ -0,0 +1,373 @@
// <auto-generated />
using System;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
[DbContext(typeof(EchoHubDbContext))]
[Migration("20260717165218_AddInvitesAndReplies")]
partial class AddInvitesAndReplies
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AsciiPreview")
.HasMaxLength(64000)
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<Guid>("MessageId")
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MessageId");
b.ToTable("Attachments");
});
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<string>("EncryptionSalt")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("WrappedRoomKey")
.HasMaxLength(200)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<long>("JoinedAt")
.HasColumnType("INTEGER");
b.HasKey("UserId", "ChannelId");
b.HasIndex("ChannelId");
b.HasIndex("UserId");
b.ToTable("ChannelMemberships");
});
modelBuilder.Entity("EchoHub.Core.Models.InviteCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<string>("CreatedByUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long?>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<int>("MaxUses")
.HasColumnType("INTEGER");
b.Property<int>("UseCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("InviteCodes");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AttachmentFileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long?>("AttachmentFileSize")
.HasColumnType("INTEGER");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(16000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(32000)
.HasColumnType("TEXT");
b.Property<Guid?>("ReplyToMessageId")
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
b.Property<string>("SenderUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long>("SentAt")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenHash");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarAscii")
.HasMaxLength(10000)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<bool>("IsMuted")
.HasColumnType("INTEGER");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("MutedUntil")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor")
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("StatusMessage")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
{
b.HasOne("EchoHub.Core.Models.Message", "Message")
.WithMany("Attachments")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Message");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.HasOne("EchoHub.Core.Models.Channel", null)
.WithMany()
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EchoHub.Core.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
.WithMany("Messages")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Channel");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.HasOne("EchoHub.Core.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{
b.Navigation("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Navigation("Attachments");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,56 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddInvitesAndReplies : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "ReplyToMessageId",
table: "Messages",
type: "TEXT",
nullable: true);
migrationBuilder.CreateTable(
name: "InviteCodes",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
Code = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
CreatedByUserId = table.Column<Guid>(type: "TEXT", nullable: false),
CreatedByUsername = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
CreatedAt = table.Column<long>(type: "INTEGER", nullable: false),
ExpiresAt = table.Column<long>(type: "INTEGER", nullable: true),
MaxUses = table.Column<int>(type: "INTEGER", nullable: false),
UseCount = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_InviteCodes", x => x.Id);
});
migrationBuilder.CreateIndex(
name: "IX_InviteCodes_Code",
table: "InviteCodes",
column: "Code",
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "InviteCodes");
migrationBuilder.DropColumn(
name: "ReplyToMessageId",
table: "Messages");
}
}
}
@@ -117,6 +117,45 @@ namespace EchoHub.Server.Data.Migrations
b.ToTable("ChannelMemberships");
});
modelBuilder.Entity("EchoHub.Core.Models.InviteCode", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<string>("CreatedByUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long?>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<int>("MaxUses")
.HasColumnType("INTEGER");
b.Property<int>("UseCount")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("Code")
.IsUnique();
b.ToTable("InviteCodes");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Property<Guid>("Id")
@@ -146,6 +185,9 @@ namespace EchoHub.Server.Data.Migrations
.HasMaxLength(32000)
.HasColumnType("TEXT");
b.Property<Guid?>("ReplyToMessageId")
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
+2 -2
View File
@@ -99,11 +99,11 @@ public class ChatHub : Hub<IEchoHubClient>
}
}
public async Task SendMessage(string channelName, string content)
public async Task SendMessage(string channelName, string content, Guid? replyToMessageId = null)
{
try
{
var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content, Context.ConnectionId);
var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content, Context.ConnectionId, replyToMessageId);
if (error is not null)
await Clients.Caller.Error(error);
}
+55 -5
View File
@@ -3,6 +3,7 @@ using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Core.Security;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
@@ -151,7 +152,7 @@ public class ChatService : IChatService
_logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
}
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null)
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
{
channelName = channelName.ToLowerInvariant().Trim();
@@ -198,6 +199,15 @@ public class ChatService : IChatService
}
}
// Replies must target an existing message in the same channel
Message? replyTarget = null;
if (replyToMessageId is { } replyId)
{
replyTarget = await db.Messages.FirstOrDefaultAsync(m => m.Id == replyId && m.ChannelId == channel.Id);
if (replyTarget is null)
return "The message you're replying to no longer exists.";
}
// Attempt to fetch link embeds for URLs in the plaintext message
List<EmbedDto>? embeds = null;
try
@@ -223,6 +233,7 @@ public class ChatService : IChatService
SenderUserId = userId,
SenderUsername = username,
EmbedJson = dbEmbedJson,
ReplyToMessageId = replyTarget?.Id,
};
db.Messages.Add(message);
@@ -238,7 +249,8 @@ public class ChatService : IChatService
channelName,
message.SentAt,
Embeds: embeds,
SenderDisplayName: sender?.DisplayName);
SenderDisplayName: sender?.DisplayName,
ReplyTo: replyTarget is null ? null : BuildReplyRef(replyTarget));
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto, originConnectionId));
@@ -258,8 +270,29 @@ public class ChatService : IChatService
return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
}
/// <summary>
/// Builds the wire reference for a reply target. Plaintext snippets are truncated
/// server-side; end-to-end room ciphertext must pass through whole (a truncated blob
/// can't be decrypted), so the client truncates those after decrypting.
/// </summary>
private ReplyRefDto BuildReplyRef(Message target)
{
const int maxSnippetLength = 120;
// Strip the at-rest layer; E2E room content stays $RC1$ ciphertext
var plain = _encryption.Decrypt(target.Content);
if (!RoomCrypto.IsRoomCiphertext(plain) && plain.Length > maxSnippetLength)
plain = plain[..maxSnippetLength] + "…";
return new ReplyRefDto(target.Id, target.SenderUsername, _encryption.Encrypt(plain));
}
public async Task<string?> UpdateStatusAsync(Guid userId, string username, UserStatus status, string? statusMessage)
{
// SignalR happily binds any int to the enum parameter — reject undefined values
if (!Enum.IsDefined(status))
return "Invalid status. Use online, away, dnd, or invisible.";
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.";
@@ -382,19 +415,33 @@ public class ChatService : IChatService
if (channel is null)
return [];
// Left join: tombstoned messages (deleted accounts, SenderUserId cleared) must
// still appear in history — an inner join would silently drop them.
var raw = await db.Messages
.Where(m => m.ChannelId == channel.Id)
.OrderByDescending(m => m.SentAt)
.Skip(offset)
.Take(count)
.Join(db.Users,
.GroupJoin(db.Users,
m => m.SenderUserId,
u => u.Id,
(m, u) => new { m, u.NicknameColor, u.DisplayName })
(m, users) => new { m, users })
.SelectMany(x => x.users.DefaultIfEmpty(),
(x, u) => new { x.m, NicknameColor = u != null ? u.NicknameColor : null, DisplayName = u != null ? u.DisplayName : null })
.ToListAsync();
raw.Reverse();
// Reply targets referenced by this batch, for quote snippets
var replyIds = raw
.Where(x => x.m.ReplyToMessageId.HasValue)
.Select(x => x.m.ReplyToMessageId!.Value)
.Distinct()
.ToList();
var replyTargets = replyIds.Count > 0
? (await db.Messages.Where(m => replyIds.Contains(m.Id)).ToListAsync()).ToDictionary(m => m.Id)
: new Dictionary<Guid, Message>();
var messageIds = raw.Select(x => x.m.Id).ToList();
var attachmentsByMessage = (await db.Attachments
.Where(a => messageIds.Contains(a.MessageId))
@@ -459,7 +506,10 @@ public class ChatService : IChatService
x.m.SentAt,
attachments,
embeds,
x.DisplayName));
x.DisplayName,
x.m.ReplyToMessageId is { } rid && replyTargets.TryGetValue(rid, out var replyTarget)
? BuildReplyRef(replyTarget)
: null));
}
// Lazily delete the pruned messages (+ their attachment rows) as they're encountered.
+61 -2
View File
@@ -11,13 +11,24 @@ namespace EchoHub.Server.Services;
public class UserService : IUserService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly IConfiguration _configuration;
public UserService(IServiceScopeFactory scopeFactory)
public UserService(IServiceScopeFactory scopeFactory, IConfiguration configuration)
{
_scopeFactory = scopeFactory;
_configuration = configuration;
}
public async Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null)
/// <summary>Registration mode from config: "open" (default), "invite", or "closed".</summary>
public string RegistrationMode =>
(_configuration["Server:Registration"] ?? "open").Trim().ToLowerInvariant() switch
{
"invite" => "invite",
"closed" => "closed",
_ => "open",
};
public async Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null, string? inviteCode = null)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required.");
@@ -35,6 +46,10 @@ public class UserService : IUserService
var normalizedUsername = username.ToLowerInvariant().Trim();
// Reserved: deleted accounts' messages are re-attributed to this name
if (normalizedUsername == Controllers.UsersController.DeletedUserName)
return UserOperationResult.Fail(UserError.ValidationFailed, "This username is reserved.");
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
@@ -43,6 +58,24 @@ public class UserService : IUserService
var isFirstUser = !await db.Users.AnyAsync();
// Registration gate. The very first account (server owner bootstrap) is always allowed —
// otherwise a fresh invite/closed server could never mint its first admin.
if (!isFirstUser)
{
switch (RegistrationMode)
{
case "closed":
return UserOperationResult.Fail(UserError.ValidationFailed,
"Registration is closed on this server.");
case "invite":
var inviteError = await TryConsumeInviteAsync(db, inviteCode);
if (inviteError is not null)
return UserOperationResult.Fail(UserError.ValidationFailed, inviteError);
break;
}
}
var user = new User
{
Id = Guid.NewGuid(),
@@ -58,6 +91,32 @@ public class UserService : IUserService
return UserOperationResult.Success(ToProfileDto(user));
}
/// <summary>
/// Validates and consumes one use of an invite code. Returns an error message, or null on
/// success. The increment is a guarded UPDATE so two racing registrations can't both take
/// a code's last use.
/// </summary>
private static async Task<string?> TryConsumeInviteAsync(EchoHubDbContext db, string? inviteCode)
{
if (string.IsNullOrWhiteSpace(inviteCode))
return "Registration is invite-only on this server. An invite code is required.";
var code = inviteCode.Trim().ToUpperInvariant();
var invite = await db.InviteCodes.FirstOrDefaultAsync(i => i.Code == code);
if (invite is null || invite.UseCount >= invite.MaxUses)
return "Invalid invite code.";
if (invite.ExpiresAt.HasValue && invite.ExpiresAt.Value <= DateTimeOffset.UtcNow)
return "This invite code has expired.";
var consumed = await db.InviteCodes
.Where(i => i.Id == invite.Id && i.UseCount < i.MaxUses)
.ExecuteUpdateAsync(s => s.SetProperty(i => i.UseCount, i => i.UseCount + 1));
return consumed == 1 ? null : "Invalid invite code.";
}
public async Task<UserOperationResult> AuthenticateUserAsync(string username, string password)
{
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
+2 -1
View File
@@ -14,7 +14,8 @@
"PublicServer": false,
"PublicHosts": [],
"Tags": [],
"Admins": []
"Admins": [],
"Registration": "open"
},
"Storage": {
"CleanupIntervalHours": 1,
@@ -0,0 +1,73 @@
using EchoHub.Core.Services;
using Xunit;
namespace EchoHub.Tests;
public class AsciiBannerServiceTests
{
[Fact]
public void Render_SimpleText_ProducesFiveRows()
{
var banner = AsciiBannerService.Render("hi");
Assert.NotNull(banner);
Assert.Equal(5, banner!.Split('\n').Length);
Assert.Contains("█", banner);
}
[Fact]
public void Render_IsCaseInsensitive()
{
Assert.Equal(AsciiBannerService.Render("abc"), AsciiBannerService.Render("ABC"));
}
[Fact]
public void Render_Empty_ReturnsNull()
{
Assert.Null(AsciiBannerService.Render(""));
Assert.Null(AsciiBannerService.Render(" "));
}
[Fact]
public void Render_OnlyUnsupportedChars_ReturnsNull()
{
Assert.Null(AsciiBannerService.Render("🦆🦆🦆"));
}
[Fact]
public void Render_UnsupportedCharsSkipped_SupportedRemain()
{
var mixed = AsciiBannerService.Render("a🦆b");
var plain = AsciiBannerService.Render("ab");
Assert.Equal(plain, mixed);
}
[Fact]
public void Render_InputLongerThanCap_IsTruncatedNotRejected()
{
var banner = AsciiBannerService.Render(new string('a', AsciiBannerService.MaxInputLength + 30));
Assert.NotNull(banner);
// 20 glyphs of 'A' (4 cols) + 19 separators — sane width, not 50 glyphs
var firstRow = banner!.Split('\n')[0];
Assert.True(firstRow.Length <= AsciiBannerService.MaxInputLength * 6);
}
[Fact]
public void Render_DigitsAndPunctuation_Supported()
{
Assert.NotNull(AsciiBannerService.Render("42!"));
Assert.NotNull(AsciiBannerService.Render("v0.2"));
}
[Fact]
public void Render_FitsMessageLimits()
{
// Worst case must stay under the server's message length cap
var banner = AsciiBannerService.Render(new string('w', AsciiBannerService.MaxInputLength));
Assert.NotNull(banner);
Assert.True(banner!.Length <= EchoHub.Core.Constants.HubConstants.MaxMessageLength);
}
}
+156 -3
View File
@@ -83,17 +83,48 @@ public class CommandHandlerTests
}
[Fact]
public async Task HandleAsync_StatusCustomMessage_SetsStatusMessage()
public async Task HandleAsync_StatusMsg_SetsStatusMessageAndKeepsStatus()
{
var handler = CreateHandler();
UserStatus? capturedStatus = UserStatus.Online;
string? capturedMessage = null;
handler.OnSetStatus += (status, msg) => { capturedMessage = msg; return Task.CompletedTask; };
handler.OnSetStatus += (status, msg) => { capturedStatus = status; capturedMessage = msg; return Task.CompletedTask; };
var result = await handler.HandleAsync("/status brb lunch");
var result = await handler.HandleAsync("/status msg brb lunch");
Assert.True(result.Handled);
Assert.False(result.IsError);
Assert.Contains("brb lunch", result.Message);
Assert.Equal("brb lunch", capturedMessage);
Assert.Null(capturedStatus); // null = keep the current status
}
[Fact]
public async Task HandleAsync_StatusMsgNoText_ClearsMessage()
{
var handler = CreateHandler();
string? capturedMessage = "sentinel";
handler.OnSetStatus += (status, msg) => { capturedMessage = msg; return Task.CompletedTask; };
var result = await handler.HandleAsync("/status msg");
Assert.False(result.IsError);
Assert.Contains("cleared", result.Message, StringComparison.OrdinalIgnoreCase);
Assert.Equal(string.Empty, capturedMessage); // empty = clear
}
[Fact]
public async Task HandleAsync_StatusUnknownValue_IsErrorAndDoesNotFire()
{
var handler = CreateHandler();
var fired = false;
handler.OnSetStatus += (_, _) => { fired = true; return Task.CompletedTask; };
var result = await handler.HandleAsync("/status garbage");
Assert.True(result.IsError);
Assert.Contains("Unknown status", result.Message);
Assert.False(fired);
}
[Fact]
@@ -106,6 +137,128 @@ public class CommandHandlerTests
Assert.Contains("Usage", result.Message);
}
// ── /me ───────────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Me_FiresActionEvent()
{
var handler = CreateHandler();
string? captured = null;
handler.OnSendAction += text => { captured = text; return Task.CompletedTask; };
var result = await handler.HandleAsync("/me waves at everyone");
Assert.True(result.Handled);
Assert.False(result.IsError);
Assert.Equal("waves at everyone", captured);
}
[Fact]
public async Task HandleAsync_MeNoArgs_ReturnsError()
{
var handler = CreateHandler();
var result = await handler.HandleAsync("/me");
Assert.True(result.IsError);
Assert.Contains("Usage", result.Message);
}
// ── /banner ───────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Banner_FiresBannerEvent()
{
var handler = CreateHandler();
string? captured = null;
handler.OnSendBanner += text => { captured = text; return Task.CompletedTask; };
var result = await handler.HandleAsync("/banner hi");
Assert.True(result.Handled);
Assert.Equal("hi", captured);
}
// ── /invite ───────────────────────────────────────────────────────
[Fact]
public async Task HandleAsync_Invite_DefaultsFireCreateWithNulls()
{
var handler = CreateHandler();
(int? Uses, int? Hours)? captured = null;
handler.OnCreateInvite += (uses, hours) => { captured = (uses, hours); return Task.CompletedTask; };
var result = await handler.HandleAsync("/invite");
Assert.True(result.Handled);
Assert.False(result.IsError);
Assert.Equal((null, null), captured);
}
[Fact]
public async Task HandleAsync_InviteWithArgs_ParsesUsesAndHours()
{
var handler = CreateHandler();
(int? Uses, int? Hours)? captured = null;
handler.OnCreateInvite += (uses, hours) => { captured = (uses, hours); return Task.CompletedTask; };
await handler.HandleAsync("/invite 5 48");
Assert.Equal((5, 48), captured);
}
[Fact]
public async Task HandleAsync_InviteRevoke_FiresRevokeWithCode()
{
var handler = CreateHandler();
string? captured = null;
handler.OnRevokeInvite += code => { captured = code; return Task.CompletedTask; };
await handler.HandleAsync("/invite revoke K7QM-3XPF");
Assert.Equal("K7QM-3XPF", captured);
}
[Fact]
public async Task HandleAsync_InviteBadArgs_ReturnsError()
{
var handler = CreateHandler();
var fired = false;
handler.OnCreateInvite += (_, _) => { fired = true; return Task.CompletedTask; };
var result = await handler.HandleAsync("/invite zero");
Assert.True(result.IsError);
Assert.False(fired);
}
// ── /export, /deleteaccount ───────────────────────────────────────
[Fact]
public async Task HandleAsync_Export_FiresExportEvent()
{
var handler = CreateHandler();
var fired = false;
handler.OnExportData += () => { fired = true; return Task.CompletedTask; };
var result = await handler.HandleAsync("/export");
Assert.True(result.Handled);
Assert.True(fired);
}
[Fact]
public async Task HandleAsync_DeleteAccount_FiresDeleteEvent()
{
var handler = CreateHandler();
var fired = false;
handler.OnDeleteAccount += () => { fired = true; return Task.CompletedTask; };
var result = await handler.HandleAsync("/deleteaccount");
Assert.True(result.Handled);
Assert.True(fired);
}
// ── /nick ─────────────────────────────────────────────────────────
[Fact]
+8 -3
View File
@@ -188,7 +188,7 @@ internal sealed class FakeChatService : IChatService
return Task.CompletedTask;
}
public Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null)
public Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
{
SentMessages.Add((channelName, content));
return Task.FromResult(SendMessageError);
@@ -305,9 +305,14 @@ internal sealed class FakeUserService : IUserService
Task.FromResult(AuthResult
?? UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password."));
public Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null) =>
Task.FromResult(RegisterResult
public List<string?> RegisterInviteCodes { get; } = [];
public Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null, string? inviteCode = null)
{
RegisterInviteCodes.Add(inviteCode);
return Task.FromResult(RegisterResult
?? UserOperationResult.Fail(UserError.AlreadyExists, "Username is already taken."));
}
public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
Task.FromResult(ProfileToReturn);
+77 -2
View File
@@ -12,7 +12,8 @@ public class IrcMessageFormatterTests
string sender = "alice",
string channel = "general",
List<AttachmentDto>? attachments = null,
List<EmbedDto>? embeds = null) => new(
List<EmbedDto>? embeds = null,
ReplyRefDto? replyTo = null) => new(
Id: Guid.NewGuid(),
Content: content,
SenderUsername: sender,
@@ -20,7 +21,81 @@ public class IrcMessageFormatterTests
ChannelName: channel,
SentAt: DateTimeOffset.UtcNow,
Attachments: attachments,
Embeds: embeds);
Embeds: embeds,
ReplyTo: replyTo);
// ── CTCP ACTION (/me) ───────────────────────────────────
[Fact]
public void FormatMessage_ActionContent_EmitsCtcpAction()
{
var msg = CreateMessage(content: "\u0001ACTION waves at everyone\u0001");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Single(lines);
Assert.Contains("PRIVMSG #general :\u0001ACTION waves at everyone\u0001", lines[0]);
}
[Fact]
public void FormatMessage_LongActionContent_EachChunkIsWellFormedCtcp()
{
var longText = string.Join(' ', Enumerable.Repeat("wordyword", 80));
var msg = CreateMessage(content: "\u0001ACTION " + longText + "\u0001");
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.True(lines.Count > 1);
foreach (var line in lines)
{
var payload = line[(line.IndexOf(" :", StringComparison.Ordinal) + 2)..];
Assert.StartsWith("\u0001ACTION ", payload);
Assert.EndsWith("\u0001", payload);
}
}
// ── Replies ───────────────────────────────────────────
[Fact]
public void FormatMessage_Reply_PrefixesQuoteConvention()
{
var reply = new ReplyRefDto(Guid.NewGuid(), "bob", "the original text");
var msg = CreateMessage(content: "I agree", replyTo: reply);
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Single(lines);
Assert.Contains("PRIVMSG #general :> bob: the original text | I agree", lines[0]);
}
[Fact]
public void FormatMessage_ReplyToLongMessage_SnippetTruncated()
{
var reply = new ReplyRefDto(Guid.NewGuid(), "bob", new string('x', 300));
var msg = CreateMessage(content: "ok", replyTo: reply);
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Single(lines);
Assert.Contains("… | ok", lines[0]);
Assert.DoesNotContain(new string('x', 100), lines[0]);
}
[Fact]
public void FormatMessage_ReplyToEncryptedContent_ShowsPlaceholder()
{
var reply = new ReplyRefDto(Guid.NewGuid(), "bob", "$RC1$AAAA$BBBB$CCCC");
var msg = CreateMessage(content: "ok", replyTo: reply);
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Contains("> bob: [encrypted] | ok", lines[0]);
}
[Fact]
public void FormatMessage_ReplyToAction_SnippetRendersAsAction()
{
var reply = new ReplyRefDto(Guid.NewGuid(), "bob", "\u0001ACTION waves\u0001");
var msg = CreateMessage(content: "nice wave", replyTo: reply);
var lines = IrcMessageFormatter.FormatMessage(msg);
Assert.Contains("> bob: * bob waves | nice wave", lines[0]);
}
// ── FormatMessage ─────────────────────────────────────────────────
@@ -0,0 +1,44 @@
using EchoHub.Core.Constants;
using Xunit;
namespace EchoHub.Tests;
public class MessageConventionsTests
{
[Fact]
public void FormatAction_RoundTripsThroughTryParse()
{
var wire = MessageConventions.FormatAction("waves at everyone");
Assert.True(MessageConventions.TryParseAction(wire, out var text));
Assert.Equal("waves at everyone", text);
}
[Fact]
public void FormatAction_UsesCtcpDelimiters()
{
var wire = MessageConventions.FormatAction("waves");
// Exact IRC CTCP ACTION wire shape: \x01ACTION waves\x01
Assert.Equal("\u0001ACTION waves\u0001", wire);
}
[Fact]
public void TryParseAction_PlainText_ReturnsFalse()
{
Assert.False(MessageConventions.TryParseAction("hello world", out _));
}
[Fact]
public void TryParseAction_TextStartingWithWordAction_ReturnsFalse()
{
// A user typing "ACTION stations!" is not a /me
Assert.False(MessageConventions.TryParseAction("ACTION stations!", out _));
}
[Fact]
public void TryParseAction_EmptyAction_ReturnsFalse()
{
Assert.False(MessageConventions.TryParseAction("\u0001ACTION \u0001", out _));
}
}
@@ -0,0 +1,242 @@
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace EchoHub.Tests;
/// <summary>
/// Registration gate tests: open/invite/closed modes, invite-code consumption,
/// first-user bootstrap, and the reserved tombstone username. Runs against a real
/// SQLite in-memory database because the gate includes a guarded UPDATE.
/// </summary>
public sealed class UserServiceRegistrationTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
public UserServiceRegistrationTests()
{
_connection = new SqliteConnection("DataSource=:memory:");
_connection.Open();
var services = new ServiceCollection();
services.AddDbContext<EchoHubDbContext>(o => o.UseSqlite(_connection));
_provider = services.BuildServiceProvider();
using var scope = _provider.CreateScope();
scope.ServiceProvider.GetRequiredService<EchoHubDbContext>().Database.EnsureCreated();
}
public void Dispose()
{
_provider.Dispose();
_connection.Dispose();
}
private UserService CreateService(string? registrationMode = null)
{
var settings = new Dictionary<string, string?>();
if (registrationMode is not null)
settings["Server:Registration"] = registrationMode;
var config = new ConfigurationBuilder().AddInMemoryCollection(settings).Build();
return new UserService(_provider.GetRequiredService<IServiceScopeFactory>(), config);
}
private EchoHubDbContext Db()
{
// Root-scope context is fine here: the connection is shared, EnsureCreated ran
return _provider.GetRequiredService<IServiceScopeFactory>()
.CreateScope().ServiceProvider.GetRequiredService<EchoHubDbContext>();
}
private async Task<InviteCode> SeedInviteAsync(int maxUses = 1, DateTimeOffset? expiresAt = null, int useCount = 0)
{
using var scope = _provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var invite = new InviteCode
{
Id = Guid.NewGuid(),
Code = "TEST-CODE",
CreatedByUserId = Guid.NewGuid(),
CreatedByUsername = "admin",
ExpiresAt = expiresAt,
MaxUses = maxUses,
UseCount = useCount,
};
db.InviteCodes.Add(invite);
await db.SaveChangesAsync();
return invite;
}
private async Task SeedOwnerAsync()
{
// First registration is always allowed and becomes Owner
var service = CreateService("open");
var result = await service.RegisterUserAsync("owner", "password123");
Assert.True(result.IsSuccess);
}
// ── Open mode ─────────────────────────────────────────────────────
[Fact]
public async Task OpenMode_RegistersWithoutCode()
{
await SeedOwnerAsync();
var service = CreateService("open");
var result = await service.RegisterUserAsync("alice", "password123");
Assert.True(result.IsSuccess);
Assert.Equal(ServerRole.Member, result.User!.Role);
}
[Fact]
public async Task DefaultMode_IsOpen()
{
await SeedOwnerAsync();
var service = CreateService(registrationMode: null);
var result = await service.RegisterUserAsync("alice", "password123");
Assert.True(result.IsSuccess);
}
// ── Closed mode ───────────────────────────────────────────────────
[Fact]
public async Task ClosedMode_RefusesRegistration()
{
await SeedOwnerAsync();
var service = CreateService("closed");
var result = await service.RegisterUserAsync("alice", "password123");
Assert.False(result.IsSuccess);
Assert.Equal(UserError.ValidationFailed, result.Error);
Assert.Contains("closed", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ClosedMode_FirstUserBootstrap_StillAllowed()
{
var service = CreateService("closed");
var result = await service.RegisterUserAsync("owner", "password123");
Assert.True(result.IsSuccess);
Assert.Equal(ServerRole.Owner, result.User!.Role);
}
// ── Invite mode ───────────────────────────────────────────────────
[Fact]
public async Task InviteMode_NoCode_Refused()
{
await SeedOwnerAsync();
var service = CreateService("invite");
var result = await service.RegisterUserAsync("alice", "password123");
Assert.False(result.IsSuccess);
Assert.Contains("invite", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task InviteMode_ValidCode_RegistersAndConsumesUse()
{
await SeedOwnerAsync();
await SeedInviteAsync(maxUses: 2);
var service = CreateService("invite");
var result = await service.RegisterUserAsync("alice", "password123", inviteCode: "test-code");
Assert.True(result.IsSuccess);
using var db = Db();
Assert.Equal(1, (await db.InviteCodes.SingleAsync()).UseCount);
}
[Fact]
public async Task InviteMode_ExhaustedCode_Refused()
{
await SeedOwnerAsync();
await SeedInviteAsync(maxUses: 1, useCount: 1);
var service = CreateService("invite");
var result = await service.RegisterUserAsync("alice", "password123", inviteCode: "TEST-CODE");
Assert.False(result.IsSuccess);
Assert.Contains("Invalid invite", result.ErrorMessage);
}
[Fact]
public async Task InviteMode_ExpiredCode_Refused()
{
await SeedOwnerAsync();
await SeedInviteAsync(expiresAt: DateTimeOffset.UtcNow.AddHours(-1));
var service = CreateService("invite");
var result = await service.RegisterUserAsync("alice", "password123", inviteCode: "TEST-CODE");
Assert.False(result.IsSuccess);
Assert.Contains("expired", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task InviteMode_WrongCode_Refused()
{
await SeedOwnerAsync();
await SeedInviteAsync();
var service = CreateService("invite");
var result = await service.RegisterUserAsync("alice", "password123", inviteCode: "WRONG-ONE");
Assert.False(result.IsSuccess);
}
[Fact]
public async Task InviteMode_UsernameTaken_DoesNotConsumeCode()
{
await SeedOwnerAsync();
await SeedInviteAsync(maxUses: 1);
var service = CreateService("invite");
var result = await service.RegisterUserAsync("owner", "password123", inviteCode: "TEST-CODE");
Assert.False(result.IsSuccess);
Assert.Equal(UserError.AlreadyExists, result.Error);
using var db = Db();
Assert.Equal(0, (await db.InviteCodes.SingleAsync()).UseCount);
}
[Fact]
public async Task InviteMode_FirstUserBootstrap_NeedsNoCode()
{
var service = CreateService("invite");
var result = await service.RegisterUserAsync("owner", "password123");
Assert.True(result.IsSuccess);
Assert.Equal(ServerRole.Owner, result.User!.Role);
}
// ── Reserved username ─────────────────────────────────────────────
[Fact]
public async Task ReservedTombstoneUsername_Refused()
{
await SeedOwnerAsync();
var service = CreateService("open");
var result = await service.RegisterUserAsync("deleted-user", "password123");
Assert.False(result.IsSuccess);
Assert.Contains("reserved", result.ErrorMessage, StringComparison.OrdinalIgnoreCase);
}
}