mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
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:
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,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);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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'] = [".##.", "#..#", ".###", "...#", ".##."],
|
||||
['!'] = ["#", "#", "#", ".", "#"],
|
||||
['?'] = [".##.", "#..#", "..#.", "....", "..#."],
|
||||
['.'] = [".", ".", ".", ".", "#"],
|
||||
[','] = ["..", "..", "..", ".#", "#."],
|
||||
['-'] = ["....", "....", "####", "....", "...."],
|
||||
['\''] = ["#", "#", ".", ".", "."],
|
||||
[':'] = [".", "#", ".", "#", "."],
|
||||
};
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
/// "> 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);
|
||||
|
||||
+373
@@ -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");
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user