mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +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:
@@ -84,6 +84,7 @@ graph TD
|
||||
- **JWT auth** with short-lived access tokens and 30-day refresh tokens
|
||||
- **Channels** — create, set topics, delete (no 47-step permission wizard required)
|
||||
- **Moderation** — ban, mute (timed or permanent), kick, role assignment
|
||||
- **Registration control** — open, invite-code-gated (`/invite`), or closed; codes live in your own database
|
||||
- **File & image uploads** with actual validation (magic bytes, not just trusting the extension)
|
||||
- **Image-to-ASCII** — because images in a terminal is objectively cool
|
||||
- **Presence tracking** — online/away/DND/invisible with custom status messages
|
||||
@@ -101,6 +102,8 @@ graph TD
|
||||
- **Clickable everything** — usernames, @mentions, #channels — just press Enter
|
||||
- **File/image sharing** — local files or URLs; drag & drop a file onto the terminal to send it; save the original behind any ASCII-art image
|
||||
- **End-to-end encrypted rooms** — password-protected channels are encrypted with a passphrase-derived key that never reaches the server, so not even the server owner can read messages or files (they can still see counts and storage size)
|
||||
- **Replies** — quote a message, jump back to the original; being replied to pings like a mention
|
||||
- **Your data is yours** — `/export` everything the server holds about you; `/deleteaccount` removes it
|
||||
- **Multi-server** — save and switch between servers
|
||||
- **Auto-reconnect** — drops happen, it rejoins your channels automatically
|
||||
- **Auto-updater** — updates in-place with automatic rollback if something goes wrong
|
||||
@@ -198,6 +201,8 @@ Auth works via `PASS`/`NICK`/`USER` or SASL PLAIN. New usernames are auto-regist
|
||||
| Feature | How it maps to IRC |
|
||||
| ------- | ------------------ |
|
||||
| Text messages | Standard `PRIVMSG` (long messages split at ~400 byte chunks) |
|
||||
| `/me` actions | Native CTCP ACTION in both directions |
|
||||
| Replies | TUI replies arrive as `> nick: snippet \| text` |
|
||||
| Images | `[Image: filename]` + download URL + ASCII art line-by-line |
|
||||
| File uploads | `[File: filename] /api/files/{id}` |
|
||||
| Channels | `JOIN`, `PART`, `NAMES`, `TOPIC`, `LIST` |
|
||||
@@ -226,6 +231,8 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
||||
| Command | Description |
|
||||
| ------- | ----------- |
|
||||
| `/join <channel> [password]` | Join a channel (passphrase for encrypted channels) |
|
||||
| `/me <action>` | Action message — `* nick waves` (native CTCP ACTION over IRC) |
|
||||
| `/banner <text>` | Render short text as a big ASCII banner |
|
||||
| `/passwd <old> <new>` | Change the current encrypted channel's passphrase (creator only) |
|
||||
| `/size [s\|m\|l]` | ASCII-art size for attached images (no arg = picker) |
|
||||
| `/downloadpath [path]` | Set the download folder (no path = native folder picker) |
|
||||
@@ -233,17 +240,20 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
||||
| `/topic <text>` | Set channel topic (creator only) |
|
||||
| `/send <file or URL>` | Upload a file or image |
|
||||
| `/status <online\|away\|dnd\|invisible>` | Set your status |
|
||||
| `/status <message>` | Set a status message |
|
||||
| `/status msg <text>` | Set a status message (keeps your status; empty text clears it) |
|
||||
| `/nick <name>` | Set display name |
|
||||
| `/color <#hex>` | Set nickname color |
|
||||
| `/theme <name>` | Switch theme |
|
||||
| `/profile` | Open profile editor |
|
||||
| `/users` | List online users in channel |
|
||||
| `/servers` | Manage saved servers |
|
||||
| `/invite [uses] [hours]` | Create a registration invite code (Admin+); also `list` / `revoke <code>` |
|
||||
| `/export` | Download everything the server stores about you as JSON |
|
||||
| `/deleteaccount` | Permanently delete your account (password re-confirmed) |
|
||||
| `/help` | Show help |
|
||||
| `/quit` | Exit |
|
||||
|
||||
**Message actions:** **right-click a message** for a context menu — delete, save/download/play its attachment, mention the sender, view their profile, or copy the text. (Keyboard alternative: press <kbd>F6</kbd> to focus the message list, select with the arrow keys, and press <kbd>Delete</kbd>; <kbd>F6</kbd> again returns to the input.) You can always delete your own messages; moderators and above can delete others' messages, but only from users below their own role.
|
||||
**Message actions:** **right-click a message** for a context menu — reply (quotes the message; Esc cancels a pending reply), delete, save/download/play its attachment, mention the sender, view their profile, or copy the text. (Keyboard alternative: press <kbd>F6</kbd> to focus the message list, select with the arrow keys, and press <kbd>Delete</kbd>; <kbd>F6</kbd> again returns to the input.) You can always delete your own messages; moderators and above can delete others' messages, but only from users below their own role.
|
||||
|
||||
## Themes
|
||||
|
||||
@@ -277,6 +287,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
||||
| `Jwt:Secret` | *(auto-generated)* | JWT signing key |
|
||||
| `Server:Name` | `My EchoHub Server` | Server display name |
|
||||
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
|
||||
| `Server:Registration` | `open` | `open`, `invite` (codes via `/invite`, Admin+), or `closed` |
|
||||
| `Server:PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) |
|
||||
| `Server:PublicHost` | *(empty)* | Public hostname for directory listing |
|
||||
| `Irc:Enabled` | `false` | Enable the IRC gateway |
|
||||
|
||||
@@ -4,7 +4,7 @@ Release history for EchoHub.
|
||||
|
||||
## Releases
|
||||
|
||||
- [v0.2.15](v0.2.15.md) - Open Images In Browser, IRC Image Links & Dual-Session Echo Fix
|
||||
- [v0.2.15](v0.2.15.md) - Invite Codes, Data Export & Deletion, /me, /banner, Replies, Open Images In Browser & IRC Image Links
|
||||
- [v0.2.14](v0.2.14.md) - Clipboard Image & Multi-File Paste, E2E Room Unlock Fixes, Encrypted Key Cache & IRC Gateway Polish
|
||||
- [v0.2.13](v0.2.13.md) - Chat Visual Overhaul, Auto-Join All Channels & Persistent Read Positions
|
||||
- [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix
|
||||
|
||||
@@ -1,14 +1,67 @@
|
||||
# v0.2.15
|
||||
|
||||
Images become properly shareable: every image gets an **[open]** action that views it in your browser (or, in end-to-end encrypted rooms, decrypts and opens it locally) — no more saving to disk just to look at a picture. The IRC gateway stops painting ANSI art nobody asked for and posts plain image links any IRC client can open or auto-preview. And a long-standing dual-session annoyance is fixed: messages you send from the TUI now reach your own connected IRC client instantly.
|
||||
A big one. Server owners can gate registration behind invite codes, users can export or delete
|
||||
everything the server holds about them, and chat gets three classics — `/me` actions that
|
||||
interop natively with IRC, `/banner` ASCII text, and message replies with quote rendering and
|
||||
jump-to-original. Images become properly shareable: every image gets an **[open]** action that
|
||||
views it in your browser (or, in end-to-end encrypted rooms, decrypts and opens it locally).
|
||||
The IRC gateway stops painting ANSI art nobody asked for and posts plain image links any IRC
|
||||
client can open or auto-preview. And a long-standing dual-session annoyance is fixed: messages
|
||||
you send from the TUI now reach your own connected IRC client instantly.
|
||||
|
||||
## New Features
|
||||
|
||||
- **Invite-gated registration** — set `Server:Registration` to `"invite"` (or `"closed"`) in
|
||||
`appsettings.json` and new accounts require a valid invite code; the default `"open"` keeps
|
||||
today's behavior. Admins mint codes with `/invite [uses] [hours]` (single-use, never-expiring
|
||||
by default), list them with `/invite list`, and revoke with `/invite revoke <code>`. Codes are
|
||||
unguessable (`K7QM-3XPF` style), stored only in your server's own database, and consumed
|
||||
atomically so a code's last use can't be taken twice. The IRC gateway respects the gate too:
|
||||
unknown nicknames connecting to an invite-only server get a clear error instead of a silent
|
||||
auto-registered account. The very first account (server owner bootstrap) is always allowed.
|
||||
- **Data export** — `/export` downloads everything the server stores about you (profile, your
|
||||
messages, attachment metadata) as JSON into your download folder. Content from end-to-end
|
||||
encrypted rooms appears as ciphertext, because the server never had the plaintext — "you own
|
||||
the data", demonstrably.
|
||||
- **Account deletion** — `/deleteaccount` permanently removes your account after a typed
|
||||
confirmation and password re-check: profile, sessions, and every file you uploaded are
|
||||
deleted. Your messages remain (deleting them would gut other people's conversations) but are
|
||||
re-attributed to a reserved `deleted-user` name. The last remaining Owner can't self-delete.
|
||||
- **`/me` actions** — `/me waves` renders as `* nick waves`. On the wire it's a real CTCP
|
||||
ACTION, so irssi/WeeChat users see a native action line, and their `/me` renders properly in
|
||||
the TUI. Works in encrypted rooms (the marker encrypts with the text).
|
||||
- **`/banner <text>`** — renders short text (up to 20 characters) as a 5-row block-letter
|
||||
banner. Rendered locally with a built-in font — no network, no dependencies — and sent as
|
||||
plain message content, so it works over IRC and in encrypted rooms.
|
||||
- **Message replies** — right-click a message (or F6-select) and pick **Reply**: the input
|
||||
shows a "Replying to" strip (Esc cancels), and the sent message renders with a dim
|
||||
`┌ nick: snippet` quote line. Activating the quote line jumps to the original message.
|
||||
Being replied to lights up the channel like an @mention. IRC clients see the familiar
|
||||
`> nick: snippet | text` convention. In encrypted rooms the quoted snippet is decrypted
|
||||
client-side with the room key — the server still never reads room content.
|
||||
- **`[open]` images without saving them** — every image attachment now shows `[open] [↓ save original]` beneath its preview. Open views the image in your default browser straight from the server; in end-to-end encrypted rooms (where a browser would only see ciphertext) the client downloads, decrypts with the room key, and opens the image in your OS viewer from a temp file instead. Both actions are individually clickable, Enter on the line opens, and the right-click menu carries both.
|
||||
- **Attachment links work in a browser** — `GET /api/files/{id}` is now a capability URL: the unguessable GUID in the link is the access token (Discord-CDN style), so attachment links can be opened directly in a browser or shared to IRC without a login token. Images and audio are served inline so the browser displays them instead of forcing a download. Blobs from encrypted rooms remain ciphertext, so their links reveal nothing.
|
||||
- **IRC gets image links instead of terminal art** — the gateway no longer floods IRC clients with truecolor-ANSI ASCII art for images. Each attachment is now a single line — `[Image: photo.png] https://your-server/api/files/…` — the convention every IRC client understands, and ones like TheLounge or IRCCloud auto-preview. Set the new `Irc:PublicBaseUrl` option (e.g. `"https://chat.example.com"`) so those links come out absolute; unset, they fall back to relative paths as before.
|
||||
|
||||
## Improvements
|
||||
|
||||
- **Strict `/status`** — `/status garbage` is now an error instead of silently becoming your
|
||||
status message (and resetting you to Online). Status messages moved to `/status msg <text>`,
|
||||
which preserves your current status; `/status msg` with no text clears it. The server also
|
||||
rejects out-of-range status values sent by misbehaving clients.
|
||||
- **Registration dialog** — gained Invite Code support, and the Display Name field you could
|
||||
already type into is now actually sent with registration.
|
||||
|
||||
## Bug Fixes
|
||||
|
||||
- **Messages sent from the TUI now reach your own IRC session.** With the same account online via both the TUI and an IRC client, messages sent from the TUI never appeared in the IRC client until it reconnected (which replayed history). The gateway suppressed the sender's echo by *nickname*, which swallowed the message for every IRC connection on that account — it now excludes only the exact connection a message originated from, so all your other sessions (a second IRC client included) receive it immediately.
|
||||
- **Channel deleted event** — when a channel is deleted, all connected clients are now immediately notified via the new `ChannelDeleted` SignalR event. The channel is removed from the channel list. Previously only the deleting client was able to delete the channel. Other clients would see the channel linger until the next manual refresh.
|
||||
- **Deleted accounts don't break history** — channel history now left-joins users, so messages
|
||||
from deleted accounts render (as `deleted-user`) instead of silently disappearing.
|
||||
|
||||
## Notes for server operators
|
||||
|
||||
- New config key: `Server:Registration` — `"open"` (default), `"invite"`, or `"closed"`.
|
||||
- New REST endpoints: `POST/GET/DELETE /api/invites` (Admin+), `GET /api/users/me/export`,
|
||||
`DELETE /api/users/me`.
|
||||
- One new database migration (`AddInvitesAndReplies`) applies automatically on startup.
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
// 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}");
|
||||
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 (OnListInvites is not null)
|
||||
await OnListInvites();
|
||||
return new CommandResult(true);
|
||||
}
|
||||
|
||||
// /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,12 +23,33 @@ 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))
|
||||
// /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
|
||||
if (message.Attachments is { Count: > 0 })
|
||||
@@ -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