mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e57247a45 | ||
|
|
fd61d9cb9c | ||
|
|
79e5a1191f | ||
|
|
7525f8b1d8 | ||
|
|
0c2e8eae87 | ||
|
|
75ff10c5fd | ||
|
|
46afd2a195 | ||
|
|
38eca99fb1 | ||
|
|
df1bd0119c | ||
|
|
71d82b45fb | ||
|
|
53d0d326cb | ||
|
|
3281064720 | ||
|
|
bb987dda82 | ||
|
|
e71575d920 | ||
|
|
96736d69df | ||
|
|
953e081123 | ||
|
|
b9d099dd73 | ||
|
|
a9519a52cc |
@@ -6,6 +6,8 @@
|
|||||||
Server__Name=My EchoHub Server
|
Server__Name=My EchoHub Server
|
||||||
Server__Description=A self-hosted EchoHub chat server
|
Server__Description=A self-hosted EchoHub chat server
|
||||||
Server__PublicServer=false
|
Server__PublicServer=false
|
||||||
|
# Registration mode: open (default), invite (codes via /invite, Admin+), or closed.
|
||||||
|
# Server__Registration=open
|
||||||
# Hostnames advertised to the EchoHubSpace directory. Index per entry.
|
# Hostnames advertised to the EchoHubSpace directory. Index per entry.
|
||||||
# Server__PublicHosts__0=echohub.example.com
|
# Server__PublicHosts__0=echohub.example.com
|
||||||
# Server__PublicHosts__1=alias.example.com
|
# Server__PublicHosts__1=alias.example.com
|
||||||
@@ -13,6 +15,8 @@ Server__PublicServer=false
|
|||||||
# Server__Tags__0=community
|
# Server__Tags__0=community
|
||||||
# Server__Tags__1=gaming
|
# Server__Tags__1=gaming
|
||||||
# Server__Admins__0=adminUsername
|
# Server__Admins__0=adminUsername
|
||||||
|
# Override where the EchoHubSpace claim token file is stored (defaults next to the DB).
|
||||||
|
# Server__DirectoryClaimPath=
|
||||||
|
|
||||||
# ── JWT ──────────────────────────────────────────────────────────────
|
# ── JWT ──────────────────────────────────────────────────────────────
|
||||||
# Auto-generated on first run if left empty. Only set if you need a stable secret across containers.
|
# Auto-generated on first run if left empty. Only set if you need a stable secret across containers.
|
||||||
@@ -27,9 +31,32 @@ Server__PublicServer=false
|
|||||||
|
|
||||||
# ── Storage ──────────────────────────────────────────────────────────
|
# ── Storage ──────────────────────────────────────────────────────────
|
||||||
# Defaults are set in the Dockerfile to use /app/data for persistence.
|
# Defaults are set in the Dockerfile to use /app/data for persistence.
|
||||||
|
# Storage__Path=
|
||||||
# Storage__CleanupIntervalHours=1
|
# Storage__CleanupIntervalHours=1
|
||||||
# Storage__RetentionDays=30
|
# Storage__RetentionDays=30
|
||||||
|
|
||||||
|
# ── Uploads ──────────────────────────────────────────────────────────
|
||||||
|
# Uploads__MaxFileSizeMB=100
|
||||||
|
# Uploads__MaxImageSizeMB=10
|
||||||
|
# Uploads__MaxAudioSizeMB=10
|
||||||
|
# Uploads__MaxAvatarSizeMB=2
|
||||||
|
# Uploads__MaxAttachmentsPerMessage=10
|
||||||
|
|
||||||
|
# ── Spam protection ──────────────────────────────────────────────────
|
||||||
|
# Per-user flood/duplicate limits with auto-mute escalation; Mods and above are exempt.
|
||||||
|
# Spam__Enabled=true
|
||||||
|
# Spam__MaxMessagesPerWindow=8
|
||||||
|
# Spam__WindowSeconds=5
|
||||||
|
# Spam__MaxDuplicateMessages=3
|
||||||
|
# Spam__AutoMuteMinutes=5
|
||||||
|
# Spam__ViolationThreshold=5
|
||||||
|
# Spam__ViolationWindowMinutes=5
|
||||||
|
# Only first-time channel joins count — keep this above your public channel count.
|
||||||
|
# Spam__MaxJoinsPerWindow=25
|
||||||
|
# Spam__JoinWindowSeconds=30
|
||||||
|
# Spam__MaxChannelCreatesPerWindow=3
|
||||||
|
# Spam__ChannelCreateWindowMinutes=10
|
||||||
|
|
||||||
# ── IRC Gateway ──────────────────────────────────────────────────────
|
# ── IRC Gateway ──────────────────────────────────────────────────────
|
||||||
Irc__Enabled=false
|
Irc__Enabled=false
|
||||||
# Irc__Port=6667
|
# Irc__Port=6667
|
||||||
@@ -39,6 +66,28 @@ Irc__Enabled=false
|
|||||||
# Irc__TlsCertPassword=
|
# Irc__TlsCertPassword=
|
||||||
# Irc__ServerName=echohub
|
# Irc__ServerName=echohub
|
||||||
# Irc__Motd=Welcome to EchoHub IRC Gateway!
|
# Irc__Motd=Welcome to EchoHub IRC Gateway!
|
||||||
|
# Public base URL used to make attachment links absolute for IRC clients.
|
||||||
|
# Irc__PublicBaseUrl=https://chat.example.com
|
||||||
|
|
||||||
|
# ── CORS ─────────────────────────────────────────────────────────────
|
||||||
|
# Leave unset to allow all origins. Index per entry to whitelist.
|
||||||
|
# Cors__AllowedOrigins__0=https://chat.example.com
|
||||||
|
|
||||||
# ── Logging ──────────────────────────────────────────────────────────
|
# ── Logging ──────────────────────────────────────────────────────────
|
||||||
# Serilog__MinimumLevel__Default=Information
|
# Serilog__MinimumLevel__Default=Information
|
||||||
|
|
||||||
|
# ── Server logs room ─────────────────────────────────────────────────
|
||||||
|
# Read-only system channel that live-streams Serilog events to Mod+ users.
|
||||||
|
# ServerLogs__Enabled=true
|
||||||
|
# ServerLogs__RoomName=server-logs
|
||||||
|
# ServerLogs__MinRole=Mod
|
||||||
|
# ServerLogs__MinLevel=Information
|
||||||
|
# ServerLogs__BacklogLines=100
|
||||||
|
# ServerLogs__LogDirectory=logs
|
||||||
|
# ServerLogs__LogFilePattern=echohub-server-*.log
|
||||||
|
|
||||||
|
# ── Periodic stats report ────────────────────────────────────────────
|
||||||
|
# Aggregate activity snapshot logged as JSON and persisted to the DB.
|
||||||
|
# Stats__Enabled=true
|
||||||
|
# Stats__IntervalHours=6
|
||||||
|
# Stats__RetentionDays=90
|
||||||
|
|||||||
@@ -84,10 +84,12 @@ graph TD
|
|||||||
- **JWT auth** with short-lived access tokens and 30-day refresh tokens
|
- **JWT auth** with short-lived access tokens and 30-day refresh tokens
|
||||||
- **Channels** — create, set topics, delete (no 47-step permission wizard required)
|
- **Channels** — create, set topics, delete (no 47-step permission wizard required)
|
||||||
- **Moderation** — ban, mute (timed or permanent), kick, role assignment
|
- **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)
|
- **File & image uploads** with actual validation (magic bytes, not just trusting the extension)
|
||||||
- **Image-to-ASCII** — because images in a terminal is objectively cool
|
- **Image-to-ASCII** — because images in a terminal is objectively cool
|
||||||
- **Presence tracking** — online/away/DND/invisible with custom status messages
|
- **Presence tracking** — online/away/DND/invisible with custom status messages
|
||||||
- **Rate limiting** — in case someone gets too excited
|
- **Rate limiting** — in case someone gets too excited
|
||||||
|
- **Spam protection** — per-user flood/duplicate limits with auto-mute escalation, covering TUI and IRC alike; configurable under `Spam`, Mods exempt
|
||||||
- **Auto-restart** on crash with exponential backoff — it picks itself back up
|
- **Auto-restart** on crash with exponential backoff — it picks itself back up
|
||||||
- **Serilog logging** — console + rolling file, because `Console.WriteLine` isn't a logging strategy
|
- **Serilog logging** — console + rolling file, because `Console.WriteLine` isn't a logging strategy
|
||||||
- **Zero config first run** — generates its own JWT secret and config on launch
|
- **Zero config first run** — generates its own JWT secret and config on launch
|
||||||
@@ -101,6 +103,8 @@ graph TD
|
|||||||
- **Clickable everything** — usernames, @mentions, #channels — just press Enter
|
- **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
|
- **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)
|
- **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
|
- **Multi-server** — save and switch between servers
|
||||||
- **Auto-reconnect** — drops happen, it rejoins your channels automatically
|
- **Auto-reconnect** — drops happen, it rejoins your channels automatically
|
||||||
- **Auto-updater** — updates in-place with automatic rollback if something goes wrong
|
- **Auto-updater** — updates in-place with automatic rollback if something goes wrong
|
||||||
@@ -198,6 +202,8 @@ Auth works via `PASS`/`NICK`/`USER` or SASL PLAIN. New usernames are auto-regist
|
|||||||
| Feature | How it maps to IRC |
|
| Feature | How it maps to IRC |
|
||||||
| ------- | ------------------ |
|
| ------- | ------------------ |
|
||||||
| Text messages | Standard `PRIVMSG` (long messages split at ~400 byte chunks) |
|
| 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 |
|
| Images | `[Image: filename]` + download URL + ASCII art line-by-line |
|
||||||
| File uploads | `[File: filename] /api/files/{id}` |
|
| File uploads | `[File: filename] /api/files/{id}` |
|
||||||
| Channels | `JOIN`, `PART`, `NAMES`, `TOPIC`, `LIST` |
|
| Channels | `JOIN`, `PART`, `NAMES`, `TOPIC`, `LIST` |
|
||||||
@@ -226,6 +232,8 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
|||||||
| Command | Description |
|
| Command | Description |
|
||||||
| ------- | ----------- |
|
| ------- | ----------- |
|
||||||
| `/join <channel> [password]` | Join a channel (passphrase for encrypted channels) |
|
| `/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) |
|
| `/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) |
|
| `/size [s\|m\|l]` | ASCII-art size for attached images (no arg = picker) |
|
||||||
| `/downloadpath [path]` | Set the download folder (no path = native folder picker) |
|
| `/downloadpath [path]` | Set the download folder (no path = native folder picker) |
|
||||||
@@ -233,17 +241,20 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
|||||||
| `/topic <text>` | Set channel topic (creator only) |
|
| `/topic <text>` | Set channel topic (creator only) |
|
||||||
| `/send <file or URL>` | Upload a file or image |
|
| `/send <file or URL>` | Upload a file or image |
|
||||||
| `/status <online\|away\|dnd\|invisible>` | Set your status |
|
| `/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 |
|
| `/nick <name>` | Set display name |
|
||||||
| `/color <#hex>` | Set nickname color |
|
| `/color <#hex>` | Set nickname color |
|
||||||
| `/theme <name>` | Switch theme |
|
| `/theme <name>` | Switch theme |
|
||||||
| `/profile` | Open profile editor |
|
| `/profile` | Open profile editor |
|
||||||
| `/users` | List online users in channel |
|
| `/users` | List online users in channel |
|
||||||
| `/servers` | Manage saved servers |
|
| `/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 |
|
| `/help` | Show help |
|
||||||
| `/quit` | Exit |
|
| `/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
|
## Themes
|
||||||
|
|
||||||
@@ -277,6 +288,7 @@ For direct TLS without a reverse proxy, the IRC gateway can terminate TLS itself
|
|||||||
| `Jwt:Secret` | *(auto-generated)* | JWT signing key |
|
| `Jwt:Secret` | *(auto-generated)* | JWT signing key |
|
||||||
| `Server:Name` | `My EchoHub Server` | Server display name |
|
| `Server:Name` | `My EchoHub Server` | Server display name |
|
||||||
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
|
| `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:PublicServer` | `false` | List on the [public directory](https://echohub.voidcube.cloud/servers) |
|
||||||
| `Server:PublicHost` | *(empty)* | Public hostname for directory listing |
|
| `Server:PublicHost` | *(empty)* | Public hostname for directory listing |
|
||||||
| `Irc:Enabled` | `false` | Enable the IRC gateway |
|
| `Irc:Enabled` | `false` | Enable the IRC gateway |
|
||||||
|
|||||||
@@ -98,8 +98,11 @@ Access tokens expire after 15 minutes, refresh tokens after 30 days with rotatio
|
|||||||
| `Server:Name` | `My EchoHub Server` | Display name shown to clients |
|
| `Server:Name` | `My EchoHub Server` | Display name shown to clients |
|
||||||
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
|
| `Server:Description` | `A self-hosted EchoHub chat server` | Server description |
|
||||||
| `Server:PublicServer` | `false` | Register on the [public directory](https://echohub.voidcube.cloud/servers) |
|
| `Server:PublicServer` | `false` | Register on the [public directory](https://echohub.voidcube.cloud/servers) |
|
||||||
| `Server:PublicHost` | *(empty)* | Public hostname for the directory listing (e.g. `chat.example.com:5000`) |
|
| `Server:PublicHosts` | `[]` | Hostnames advertised to the directory (array, e.g. `["chat.example.com"]`). Required when `PublicServer` is `true` |
|
||||||
|
| `Server:Tags` | `[]` | Topic tags surfaced in the directory browser (array, e.g. `["community", "gaming"]`) |
|
||||||
| `Server:Admins` | `[]` | Array of admin usernames (e.g. `["alice", "bob"]`) |
|
| `Server:Admins` | `[]` | Array of admin usernames (e.g. `["alice", "bob"]`) |
|
||||||
|
| `Server:Registration` | `open` | Registration mode: `open`, `invite` (codes via `/invite`, Admin+), or `closed` |
|
||||||
|
| `Server:DirectoryClaimPath` | *(empty)* | Overrides where the directory claim token file is stored. Empty = `directory-claim.json` next to the SQLite database. Treat it as a secret and back it up with the database |
|
||||||
|
|
||||||
### Uploads
|
### Uploads
|
||||||
|
|
||||||
@@ -118,6 +121,26 @@ absent or partial `Uploads` section keeps the built-in defaults. See
|
|||||||
The server sizes its request-body limits from these values, so raising a limit here is all
|
The server sizes its request-body limits from these values, so raising a limit here is all
|
||||||
that's needed — no separate Kestrel tuning.
|
that's needed — no separate Kestrel tuning.
|
||||||
|
|
||||||
|
### Spam Protection
|
||||||
|
|
||||||
|
Per-user flood, duplicate, join, and channel-create limits with auto-mute escalation. Mods and
|
||||||
|
above are always exempt, and the defaults are lenient enough that a fast typist never trips them.
|
||||||
|
An absent or partial `Spam` section keeps these defaults.
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Spam:Enabled` | `true` | Master switch for all spam protection |
|
||||||
|
| `Spam:MaxMessagesPerWindow` | `8` | Max messages per `WindowSeconds` before a send is rejected |
|
||||||
|
| `Spam:WindowSeconds` | `5` | Sliding window (seconds) for the message-rate check |
|
||||||
|
| `Spam:MaxDuplicateMessages` | `3` | Identical messages in a row tolerated before rejection (E2E rooms are exempt — ciphertext differs each time) |
|
||||||
|
| `Spam:AutoMuteMinutes` | `5` | Auto-mute duration once a user hits the violation threshold. `0` disables auto-mute (rejections still apply) |
|
||||||
|
| `Spam:ViolationThreshold` | `5` | Rejected sends within `ViolationWindowMinutes` that trigger an auto-mute |
|
||||||
|
| `Spam:ViolationWindowMinutes` | `5` | Window (minutes) over which violations accumulate |
|
||||||
|
| `Spam:MaxJoinsPerWindow` | `25` | Max first-time channel joins per `JoinWindowSeconds` — keep this above your public channel count |
|
||||||
|
| `Spam:JoinWindowSeconds` | `30` | Sliding window (seconds) for the join-rate check |
|
||||||
|
| `Spam:MaxChannelCreatesPerWindow` | `3` | Max channel creations per `ChannelCreateWindowMinutes` |
|
||||||
|
| `Spam:ChannelCreateWindowMinutes` | `10` | Window (minutes) for the channel-create check |
|
||||||
|
|
||||||
### Encryption
|
### Encryption
|
||||||
|
|
||||||
| Key | Default | Description |
|
| Key | Default | Description |
|
||||||
@@ -129,6 +152,7 @@ that's needed — no separate Kestrel tuning.
|
|||||||
|
|
||||||
| Key | Default | Description |
|
| Key | Default | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| `Storage:Path` | *(empty)* | Directory for uploaded file blobs (attachments, avatars). Empty = `uploads/` in the app directory; the Docker image sets it to `/app/data/uploads` on the persistent volume |
|
||||||
| `Storage:CleanupIntervalHours` | `1` | How often the cleanup job runs (hours) |
|
| `Storage:CleanupIntervalHours` | `1` | How often the cleanup job runs (hours) |
|
||||||
| `Storage:RetentionDays` | `30` | Days to keep uploaded files before cleanup |
|
| `Storage:RetentionDays` | `30` | Days to keep uploaded files before cleanup |
|
||||||
|
|
||||||
@@ -145,6 +169,23 @@ that's needed — no separate Kestrel tuning.
|
|||||||
| `Irc:ServerName` | `echohub` | IRC server name in protocol messages |
|
| `Irc:ServerName` | `echohub` | IRC server name in protocol messages |
|
||||||
| `Irc:Motd` | `Welcome to EchoHub IRC Gateway!` | Message of the day |
|
| `Irc:Motd` | `Welcome to EchoHub IRC Gateway!` | Message of the day |
|
||||||
|
|
||||||
|
### Server Logs Room
|
||||||
|
|
||||||
|
When enabled, EchoHub auto-creates a read-only system channel and streams log events to it live,
|
||||||
|
so operators can watch the server from inside the app. Log lines are **never stored as messages** —
|
||||||
|
the rolling Serilog files remain the only persistence, and the room replays recent lines from those
|
||||||
|
files when someone opens it.
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ServerLogs:Enabled` | `true` | Master switch for the live log room |
|
||||||
|
| `ServerLogs:RoomName` | `server-logs` | Name of the auto-created channel (reserved — users can't create a channel with this name) |
|
||||||
|
| `ServerLogs:MinRole` | `Mod` | Minimum server role that can see and join the room (`Member`, `Mod`, `Admin`, `Owner`) |
|
||||||
|
| `ServerLogs:MinLevel` | `Information` | Minimum log level streamed to the room (`Verbose`, `Debug`, `Information`, `Warning`, `Error`, `Fatal`) — affects only the room, not the file/console sinks |
|
||||||
|
| `ServerLogs:BacklogLines` | `100` | Recent log lines replayed from file when someone opens the room |
|
||||||
|
| `ServerLogs:LogDirectory` | `logs` | Directory holding the rolling log files (must match the Serilog file sink path below) |
|
||||||
|
| `ServerLogs:LogFilePattern` | `echohub-server-*.log` | Filename glob for the rolling log files inside `LogDirectory` |
|
||||||
|
|
||||||
### Logging
|
### Logging
|
||||||
|
|
||||||
EchoHub uses [Serilog](https://serilog.net/) for structured logging — console output + daily rolling files with 14-day retention by default.
|
EchoHub uses [Serilog](https://serilog.net/) for structured logging — console output + daily rolling files with 14-day retention by default.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ Release history for EchoHub.
|
|||||||
|
|
||||||
## Releases
|
## Releases
|
||||||
|
|
||||||
|
- [v0.2.16](v0.2.16.md) - Periodic Server-Stats Report, Upload & Moderation Logging & Quieter Connection Logs
|
||||||
|
- [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.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.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
|
- [v0.2.12](v0.2.12.md) - End-to-End Encrypted Channels, IRC Channel Keys, Image Save & Ctrl+W Crash Fix
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
- name: Overview
|
- name: Overview
|
||||||
href: index.md
|
href: index.md
|
||||||
|
- name: v0.2.16
|
||||||
|
href: v0.2.16.md
|
||||||
|
- name: v0.2.15
|
||||||
|
href: v0.2.15.md
|
||||||
- name: v0.2.14
|
- name: v0.2.14
|
||||||
href: v0.2.14.md
|
href: v0.2.14.md
|
||||||
- name: v0.2.13
|
- name: v0.2.13
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# v0.2.15
|
||||||
|
|
||||||
|
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.
|
||||||
|
- **Spam protection** — per-user message flood and duplicate-message limits, join and
|
||||||
|
channel-creation throttles, and automatic escalation: enough rejected sends in a few minutes
|
||||||
|
earns a timed auto-mute through the normal mute system (moderators see it, and it expires on
|
||||||
|
its own). One guard covers every ingress — TUI and IRC clients hit the same limits. Mods and
|
||||||
|
above are exempt, everything is configurable under the new `Spam` section
|
||||||
|
(`Spam:Enabled` master switch, lenient defaults a fast typist won't trip), and the guard only
|
||||||
|
ever sees stored content — encrypted-room messages stay ciphertext.
|
||||||
|
|
||||||
|
## 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 config section: `Spam` — flood/duplicate/join/create thresholds and auto-mute duration;
|
||||||
|
see `appsettings.example.json`. On by default with lenient limits; `Spam:Enabled: false`
|
||||||
|
turns it all off. Note `Spam:MaxJoinsPerWindow` counts only *first-time* channel joins —
|
||||||
|
keep it above your public channel count so a new member's first connect isn't throttled.
|
||||||
|
- 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.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# v0.2.16
|
||||||
|
|
||||||
|
A logging and observability pass built on the new server-logs room. Busy servers stop drowning
|
||||||
|
in connect/disconnect noise, the events operators actually care about — uploads, moderation
|
||||||
|
actions — now get logged, and a new periodic report summarizes server activity as pretty-printed
|
||||||
|
JSON that streams into the logs room and is saved to the database for history.
|
||||||
|
|
||||||
|
## New Features
|
||||||
|
|
||||||
|
- **Periodic server-stats report** — every 6 hours (configurable), the server logs an aggregate
|
||||||
|
activity snapshot as pretty-printed JSON and saves it to the database. Each report covers the
|
||||||
|
window since the previous one: messages sent, files uploaded (and total bytes), new members,
|
||||||
|
active members (distinct senders), session connections/disconnections, kicks, bans, total
|
||||||
|
registered members, users online now, and peak concurrent online. Reports also stream into the
|
||||||
|
live server-logs room, and old ones are pruned past a configurable retention. New `Stats` config
|
||||||
|
section (`Stats:Enabled`, `Stats:IntervalHours` = 6, `Stats:RetentionDays` = 90).
|
||||||
|
- **Upload logging** — every file, image, and audio upload is now logged with its resource URL
|
||||||
|
(`/api/files/{id}`), kind, size, uploader, and channel. In end-to-end encrypted rooms the
|
||||||
|
filename is client-side ciphertext, so it's logged as `[encrypted]` — the server still never
|
||||||
|
sees room content.
|
||||||
|
- **Moderation & user-action logging** — role changes, kicks, bans, unbans, mutes, unmutes,
|
||||||
|
moderator message removals, and channel nukes are now logged with the actor, target, and reason.
|
||||||
|
Bans and channel nukes log at Warning level so they stand out.
|
||||||
|
|
||||||
|
## Improvements
|
||||||
|
|
||||||
|
- **Quieter connection logs** — user connect, disconnect, join-channel, and leave-channel events
|
||||||
|
(over both SignalR and IRC) dropped from Information to Debug. On a busy server these fired
|
||||||
|
constantly and buried everything else; the aggregate counts now live in the periodic stats
|
||||||
|
report instead. They still show in Development, where the default level is Debug.
|
||||||
|
- **Config examples stay in sync** — `.env.example` gained the `ServerLogs` and `Stats` sections
|
||||||
|
(the former had been missing), alongside `appsettings.json` / `appsettings.example.json`.
|
||||||
|
|
||||||
|
## Notes for server operators
|
||||||
|
|
||||||
|
- New config section: `Stats` — `Enabled` (default true), `IntervalHours` (default 6),
|
||||||
|
`RetentionDays` (default 90, `0` keeps reports forever). See `appsettings.example.json` and
|
||||||
|
`.env.example`. A non-positive interval falls back to 6h; positive values are honored down to a
|
||||||
|
1-second floor.
|
||||||
|
- One new database migration (`AddServerStatsReports`) applies automatically on startup.
|
||||||
|
- Connect/disconnect/join/leave now log at Debug — if you relied on those Information lines, lower
|
||||||
|
`Serilog:MinimumLevel:Default` (or `ServerLogs:MinLevel` for the room) to `Debug`, or read the
|
||||||
|
periodic stats report for aggregates.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<Project>
|
<Project>
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<Version>0.2.14</Version>
|
<Version>0.2.16</Version>
|
||||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|||||||
@@ -43,6 +43,9 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
// Cleared on connect/reconnect; an explicit /join or a send attempt re-offers the prompt.
|
// Cleared on connect/reconnect; an explicit /join or a send attempt re-offers the prompt.
|
||||||
private readonly HashSet<string> _declinedUnlocks = new(StringComparer.OrdinalIgnoreCase);
|
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 ClientConfig _config;
|
||||||
private readonly UserSession _session = new();
|
private readonly UserSession _session = new();
|
||||||
|
|
||||||
@@ -110,6 +113,7 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
_mainWindow.OnAudioPlayRequested += HandleAudioPlayRequested;
|
||||||
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
_mainWindow.OnFileDownloadRequested += HandleFileDownloadRequested;
|
||||||
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
|
_mainWindow.OnImageSaveRequested += HandleImageSaveRequested;
|
||||||
|
_mainWindow.OnImageOpenRequested += HandleImageOpenRequested;
|
||||||
_mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested;
|
_mainWindow.OnDeleteMessageRequested += HandleDeleteMessageRequested;
|
||||||
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
_mainWindow.OnCheckForUpdatesRequested += HandleCheckForUpdatesRequested;
|
||||||
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
|
||||||
@@ -117,6 +121,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
|
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
|
||||||
_mainWindow.OnSearchRequested += HandleSearchRequested;
|
_mainWindow.OnSearchRequested += HandleSearchRequested;
|
||||||
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
|
_mainWindow.OnLoadMoreRequested += HandleLoadMoreRequested;
|
||||||
|
_mainWindow.OnReplyRequested += HandleReplyRequested;
|
||||||
|
_mainWindow.OnReplyCancelRequested += HandleReplyCancelRequested;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Command Handler Wiring ─────────────────────────────────────────────
|
// ── Command Handler Wiring ─────────────────────────────────────────────
|
||||||
@@ -149,17 +155,200 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_commandHandler.OnNukeChannel += HandleCmdNukeChannel;
|
_commandHandler.OnNukeChannel += HandleCmdNukeChannel;
|
||||||
_commandHandler.OnTestSound += HandleCmdTestSound;
|
_commandHandler.OnTestSound += HandleCmdTestSound;
|
||||||
_commandHandler.OnQuit += HandleCmdQuit;
|
_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 ──────────────────────────────────────────────────
|
// ── Command Handlers ──────────────────────────────────────────────────
|
||||||
|
|
||||||
private async Task HandleCmdSetStatus(UserStatus status, string? message)
|
private async Task HandleCmdSetStatus(UserStatus? status, string? message)
|
||||||
{
|
{
|
||||||
if (!_conn.IsConnected) return;
|
if (!_conn.IsConnected) return;
|
||||||
|
|
||||||
await _conn.UpdateStatusAsync(status, message);
|
// null status keeps the current one; null message keeps it, empty clears it —
|
||||||
_session.Status = status;
|
// so "/status away" no longer wipes your message and "/status msg brb" keeps Away
|
||||||
_session.StatusMessage = message;
|
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)
|
private async Task HandleCmdSetNick(string displayName)
|
||||||
@@ -1071,6 +1260,15 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
_conn.MessageDeleted += (channelName, messageId) =>
|
_conn.MessageDeleted += (channelName, messageId) =>
|
||||||
InvokeUI(() => _messageManager.RemoveMessage(channelName, messageId));
|
InvokeUI(() => _messageManager.RemoveMessage(channelName, messageId));
|
||||||
|
|
||||||
|
_conn.ChannelDeleted += channelName =>
|
||||||
|
{
|
||||||
|
InvokeUI(() =>
|
||||||
|
{
|
||||||
|
_conn.UntrackChannel(channelName);
|
||||||
|
_mainWindow.RemoveChannel(channelName);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
_conn.ChannelNuked += channelName =>
|
_conn.ChannelNuked += channelName =>
|
||||||
{
|
{
|
||||||
InvokeUI(() =>
|
InvokeUI(() =>
|
||||||
@@ -1183,6 +1381,8 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
Log.Information("Disconnecting from server");
|
Log.Information("Disconnecting from server");
|
||||||
lock (_channelUsersLock) _channelUsers.Clear();
|
lock (_channelUsersLock) _channelUsers.Clear();
|
||||||
|
_pendingReply = null;
|
||||||
|
InvokeUI(() => _mainWindow.SetReplyingTo(null));
|
||||||
PersistLastReads();
|
PersistLastReads();
|
||||||
|
|
||||||
RunAsync(async () =>
|
RunAsync(async () =>
|
||||||
@@ -1252,14 +1452,40 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
return;
|
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 () =>
|
RunAsync(async () =>
|
||||||
{
|
{
|
||||||
if (!await EnsureRoomUnlockedForSendAsync(channelName))
|
if (!await EnsureRoomUnlockedForSendAsync(channelName))
|
||||||
return;
|
return;
|
||||||
await _conn.SendMessageAsync(channelName, content);
|
await _conn.SendMessageAsync(channelName, content, replyTo);
|
||||||
|
if (replyTo is not null)
|
||||||
|
InvokeUI(ClearPendingReply);
|
||||||
}, "Send failed");
|
}, "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)
|
private void HandleDeleteMessageRequested(Guid messageId)
|
||||||
{
|
{
|
||||||
if (!_conn.IsAuthenticated) return;
|
if (!_conn.IsAuthenticated) return;
|
||||||
@@ -1274,6 +1500,10 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
{
|
{
|
||||||
if (!_conn.IsConnected) return;
|
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
|
// Checkpoint read positions — the previous channel was just marked read
|
||||||
PersistLastReads();
|
PersistLastReads();
|
||||||
|
|
||||||
@@ -1695,6 +1925,62 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
return tempPath;
|
return tempPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>File extensions the "[open]" action will hand to the OS image viewer for E2E rooms.</summary>
|
||||||
|
private static readonly HashSet<string> ImageOpenExtensions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp",
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Views an image without saving it to the user's downloads. Plain channels open the
|
||||||
|
/// file's web URL in the default browser (the server serves files by capability URL, so
|
||||||
|
/// no auth token is needed). E2E-encrypted channels would render as ciphertext in a
|
||||||
|
/// browser, so the blob is downloaded, decrypted locally, and opened from a temp file.
|
||||||
|
/// </summary>
|
||||||
|
private void HandleImageOpenRequested(string attachmentUrl, string fileName)
|
||||||
|
{
|
||||||
|
if (!_conn.IsAuthenticated) return;
|
||||||
|
|
||||||
|
var channel = _mainWindow.CurrentChannel;
|
||||||
|
var isEncryptedRoom = !string.IsNullOrEmpty(channel) && _conn.RoomKeys.TryGetKey(channel, out _);
|
||||||
|
|
||||||
|
if (!isEncryptedRoom)
|
||||||
|
{
|
||||||
|
var webUrl = attachmentUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| attachmentUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? attachmentUrl
|
||||||
|
: $"{_conn.Api!.BaseUrl}/{attachmentUrl.TrimStart('/')}";
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
System.Diagnostics.Process.Start(
|
||||||
|
new System.Diagnostics.ProcessStartInfo(webUrl) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
Log.Warning(ex, "Failed to open image URL in browser: {Url}", webUrl);
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Couldn't open a browser — image URL: {webUrl}"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// In E2E rooms the attachment kind is sender-declared, so only hand real image
|
||||||
|
// extensions to the OS viewer; anything else goes through the save path instead.
|
||||||
|
if (!ImageOpenExtensions.Contains(Path.GetExtension(fileName)))
|
||||||
|
{
|
||||||
|
HandleImageSaveRequested(attachmentUrl, fileName);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
RunAsync(async () =>
|
||||||
|
{
|
||||||
|
InvokeUI(() => _messageManager.AddSystemMessage(channel, $"Decrypting {fileName}..."));
|
||||||
|
var tempPath = await DownloadAttachmentAsync(attachmentUrl, fileName);
|
||||||
|
var psi = new System.Diagnostics.ProcessStartInfo(tempPath) { UseShellExecute = true };
|
||||||
|
System.Diagnostics.Process.Start(psi);
|
||||||
|
}, "Failed to open image");
|
||||||
|
}
|
||||||
|
|
||||||
private void HandleImageSaveRequested(string attachmentUrl, string fileName)
|
private void HandleImageSaveRequested(string attachmentUrl, string fileName)
|
||||||
{
|
{
|
||||||
if (!_conn.IsAuthenticated) return;
|
if (!_conn.IsAuthenticated) return;
|
||||||
|
|||||||
@@ -6,7 +6,19 @@ public record CommandResult(bool Handled, string? Message = null, bool IsError =
|
|||||||
|
|
||||||
public class CommandHandler
|
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>? OnSetNick;
|
||||||
public event Func<string, Task>? OnSetColor;
|
public event Func<string, Task>? OnSetColor;
|
||||||
public event Func<string, Task>? OnSetTheme;
|
public event Func<string, Task>? OnSetTheme;
|
||||||
@@ -48,6 +60,11 @@ public class CommandHandler
|
|||||||
return command switch
|
return command switch
|
||||||
{
|
{
|
||||||
"status" => await HandleStatus(args),
|
"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),
|
"nick" => await HandleNick(args),
|
||||||
"color" => await HandleColor(args),
|
"color" => await HandleColor(args),
|
||||||
"theme" => await HandleTheme(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)
|
private async Task<CommandResult> HandleStatus(string args)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(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
|
UserStatus? status = statusArg switch
|
||||||
{
|
{
|
||||||
"online" => UserStatus.Online,
|
"online" => UserStatus.Online,
|
||||||
@@ -93,17 +126,92 @@ public class CommandHandler
|
|||||||
_ => null,
|
_ => null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (status.HasValue)
|
// Strict: anything else is an error — no silent "it became your status message"
|
||||||
|
if (!status.HasValue)
|
||||||
|
return new CommandResult(true, $"Unknown status '{parts[0]}'. {StatusUsage}", IsError: true);
|
||||||
|
|
||||||
|
if (parts.Length > 1)
|
||||||
|
return new CommandResult(true, StatusUsage, IsError: true);
|
||||||
|
|
||||||
|
if (OnSetStatus is not null)
|
||||||
|
await OnSetStatus(status.Value, null);
|
||||||
|
return new CommandResult(true, $"Status set to {status.Value}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleMe(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /me <action> (e.g. /me waves)", IsError: true);
|
||||||
|
|
||||||
|
if (OnSendAction is not null)
|
||||||
|
await OnSendAction(args.Trim());
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleBanner(string args)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(args))
|
||||||
|
return new CommandResult(true, "Usage: /banner <text> (letters, digits, basic punctuation)", IsError: true);
|
||||||
|
|
||||||
|
if (OnSendBanner is not null)
|
||||||
|
await OnSendBanner(args.Trim());
|
||||||
|
return new CommandResult(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleInvite(string args)
|
||||||
|
{
|
||||||
|
var parts = args.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
|
||||||
|
// /invite list
|
||||||
|
if (parts.Length > 0 && parts[0].Equals("list", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
if (OnSetStatus is not null)
|
if (OnListInvites is not null)
|
||||||
await OnSetStatus(status.Value, null);
|
await OnListInvites();
|
||||||
return new CommandResult(true, $"Status set to {status.Value}");
|
return new CommandResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Treat as custom status message (keep current status)
|
// /invite revoke <code>
|
||||||
if (OnSetStatus is not null)
|
if (parts.Length > 0 && parts[0].Equals("revoke", StringComparison.OrdinalIgnoreCase))
|
||||||
await OnSetStatus(UserStatus.Online, args);
|
{
|
||||||
return new CommandResult(true, $"Status message set: {args}");
|
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)
|
private async Task<CommandResult> HandleNick(string args)
|
||||||
@@ -392,7 +500,9 @@ public class CommandHandler
|
|||||||
return new CommandResult(true, """
|
return new CommandResult(true, """
|
||||||
Available commands:
|
Available commands:
|
||||||
/status <online|away|dnd|invisible> - Set your status
|
/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
|
/nick <name> - Set display name
|
||||||
/color <#hex> - Set nickname color
|
/color <#hex> - Set nickname color
|
||||||
/theme <name> - Switch theme
|
/theme <name> - Switch theme
|
||||||
@@ -413,6 +523,9 @@ public class CommandHandler
|
|||||||
/topic <text> - Set channel topic
|
/topic <text> - Set channel topic
|
||||||
/users - List online users
|
/users - List online users
|
||||||
/meta - Show room info (size, messages, users, created, id)
|
/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:
|
Moderation:
|
||||||
/kick <user> [reason] - Kick a user (Mod+)
|
/kick <user> [reason] - Kick a user (Mod+)
|
||||||
/ban <user> [reason] - Ban a user (Admin+)
|
/ban <user> [reason] - Ban a user (Admin+)
|
||||||
@@ -420,6 +533,8 @@ public class CommandHandler
|
|||||||
/mute <user> [minutes] - Mute a user (Mod+)
|
/mute <user> [minutes] - Mute a user (Mod+)
|
||||||
/unmute <user> - Unmute a user (Mod+)
|
/unmute <user> - Unmute a user (Mod+)
|
||||||
/role <user> <admin|mod|member> - Assign role (Admin+)
|
/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+)
|
/nuke - Clear channel history (Mod+)
|
||||||
/test-sound - Play notification sound
|
/test-sound - Play notification sound
|
||||||
/quit - Exit the app
|
/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);
|
using var response = await _http.PostAsJsonAsync("/api/auth/register", request);
|
||||||
await EnsureSuccessAsync(response);
|
await EnsureSuccessAsync(response);
|
||||||
|
|
||||||
@@ -312,6 +312,54 @@ public sealed class ApiClient : IDisposable
|
|||||||
await EnsureSuccessAsync(response);
|
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 ────────────────────────────────────────────────────────
|
// ── Moderation ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
public async Task AssignRoleAsync(string username, ServerRole role)
|
public async Task AssignRoleAsync(string username, ServerRole role)
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
public event Action<string, string?>? UserBanned;
|
public event Action<string, string?>? UserBanned;
|
||||||
public event Action<string>? ForceDisconnected;
|
public event Action<string>? ForceDisconnected;
|
||||||
public event Action<string, Guid>? MessageDeleted;
|
public event Action<string, Guid>? MessageDeleted;
|
||||||
|
public event Action<string>? ChannelDeleted;
|
||||||
public event Action<string>? ChannelNuked;
|
public event Action<string>? ChannelNuked;
|
||||||
public event Action<ChannelDto>? ChannelUpdated;
|
public event Action<ChannelDto>? ChannelUpdated;
|
||||||
public event Action<string>? Error;
|
public event Action<string>? Error;
|
||||||
@@ -77,7 +78,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
else if (info.IsRegister)
|
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
|
else
|
||||||
{
|
{
|
||||||
@@ -243,8 +245,8 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
|
|
||||||
// ── Delegate Operations ───────────────────────────────────────────────
|
// ── Delegate Operations ───────────────────────────────────────────────
|
||||||
|
|
||||||
public Task SendMessageAsync(string channel, string content) =>
|
public Task SendMessageAsync(string channel, string content, Guid? replyToMessageId = null) =>
|
||||||
_connection?.SendMessageAsync(channel, content)
|
_connection?.SendMessageAsync(channel, content, replyToMessageId)
|
||||||
?? throw new InvalidOperationException("Not connected");
|
?? throw new InvalidOperationException("Not connected");
|
||||||
|
|
||||||
public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
|
public Task<List<MessageDto>> GetHistoryAsync(string channel, int count = HubConstants.DefaultHistoryCount, int offset = 0) =>
|
||||||
@@ -300,6 +302,7 @@ internal sealed class ConnectionManager : IAsyncDisposable
|
|||||||
connection.OnUserBanned += (user, reason) => UserBanned?.Invoke(user, reason);
|
connection.OnUserBanned += (user, reason) => UserBanned?.Invoke(user, reason);
|
||||||
connection.OnForceDisconnect += reason => ForceDisconnected?.Invoke(reason);
|
connection.OnForceDisconnect += reason => ForceDisconnected?.Invoke(reason);
|
||||||
connection.OnMessageDeleted += (ch, id) => MessageDeleted?.Invoke(ch, id);
|
connection.OnMessageDeleted += (ch, id) => MessageDeleted?.Invoke(ch, id);
|
||||||
|
connection.OnChannelDeleted += ch => ChannelDeleted?.Invoke(ch);
|
||||||
connection.OnChannelNuked += ch => ChannelNuked?.Invoke(ch);
|
connection.OnChannelNuked += ch => ChannelNuked?.Invoke(ch);
|
||||||
connection.OnChannelUpdated += ch =>
|
connection.OnChannelUpdated += ch =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
public event Action<string, string, string?>? OnUserKicked;
|
public event Action<string, string, string?>? OnUserKicked;
|
||||||
public event Action<string, string?>? OnUserBanned;
|
public event Action<string, string?>? OnUserBanned;
|
||||||
public event Action<string, Guid>? OnMessageDeleted;
|
public event Action<string, Guid>? OnMessageDeleted;
|
||||||
|
public event Action<string>? OnChannelDeleted;
|
||||||
public event Action<string>? OnChannelNuked;
|
public event Action<string>? OnChannelNuked;
|
||||||
public event Action<string>? OnForceDisconnect;
|
public event Action<string>? OnForceDisconnect;
|
||||||
public event Action<string>? OnError;
|
public event Action<string>? OnError;
|
||||||
@@ -144,6 +145,11 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
OnMessageDeleted?.Invoke(channelName, messageId);
|
OnMessageDeleted?.Invoke(channelName, messageId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelDeleted), channelName =>
|
||||||
|
{
|
||||||
|
OnChannelDeleted?.Invoke(channelName);
|
||||||
|
});
|
||||||
|
|
||||||
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName =>
|
_connection.On<string>(nameof(Core.Contracts.IEchoHubClient.ChannelNuked), channelName =>
|
||||||
{
|
{
|
||||||
OnChannelNuked?.Invoke(channelName);
|
OnChannelNuked?.Invoke(channelName);
|
||||||
@@ -192,7 +198,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
await _connection.InvokeAsync("LeaveChannel", channelName);
|
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
|
// Room layer first (end-to-end, server can't read), then transport encryption
|
||||||
if (_roomKeys.TryGetKey(channelName, out var roomKey))
|
if (_roomKeys.TryGetKey(channelName, out var roomKey))
|
||||||
@@ -201,7 +207,7 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
throw new RoomLockedException(channelName); // never fall through to plaintext
|
throw new RoomLockedException(channelName); // never fall through to plaintext
|
||||||
|
|
||||||
var encrypted = _encryption.Encrypt(content);
|
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)
|
public async Task<List<MessageDto>> GetHistoryAsync(string channelName, int count = HubConstants.DefaultHistoryCount, int offset = 0)
|
||||||
@@ -244,7 +250,12 @@ public sealed class EchoHubConnection : IAsyncDisposable
|
|||||||
.ToList();
|
.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>
|
/// <summary>
|
||||||
|
|||||||
@@ -7,6 +7,16 @@ using Attribute = Terminal.Gui.Drawing.Attribute;
|
|||||||
|
|
||||||
namespace EchoHub.Client.UI.Chat;
|
namespace EchoHub.Client.UI.Chat;
|
||||||
|
|
||||||
|
/// <summary>An action a click on an attachment line can trigger.</summary>
|
||||||
|
public enum AttachmentAction
|
||||||
|
{
|
||||||
|
OpenImage,
|
||||||
|
SaveImage,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Inclusive column range on a chat line that triggers an attachment action when clicked.</summary>
|
||||||
|
public readonly record struct AttachmentActionSpan(int StartCol, int EndCol, AttachmentAction Action);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// A single line in the chat, composed of colored segments.
|
/// A single line in the chat, composed of colored segments.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -20,6 +30,20 @@ public partial class ChatLine
|
|||||||
public string? AttachmentFileName { get; set; }
|
public string? AttachmentFileName { get; set; }
|
||||||
public AttachmentKind? AttachmentKind { get; set; }
|
public AttachmentKind? AttachmentKind { get; set; }
|
||||||
public string? SenderUsername { 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
|
||||||
|
/// keeps them. Null means the whole line uses the kind's default action.
|
||||||
|
/// </summary>
|
||||||
|
public List<AttachmentActionSpan>? ActionSpans { get; set; }
|
||||||
|
|
||||||
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
/// <summary>Number of spaces to prepend on continuation lines when this line is word-wrapped.</summary>
|
||||||
public int ContinuationIndent { get; set; }
|
public int ContinuationIndent { get; set; }
|
||||||
|
|
||||||
@@ -158,8 +182,13 @@ public partial class ChatLine
|
|||||||
wrapped.MessageId = MessageId;
|
wrapped.MessageId = MessageId;
|
||||||
wrapped.SenderUsername = SenderUsername;
|
wrapped.SenderUsername = SenderUsername;
|
||||||
wrapped.IsMention = IsMention;
|
wrapped.IsMention = IsMention;
|
||||||
|
wrapped.JumpToMessageId = JumpToMessageId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Span columns only line up with the first wrapped line; later lines fall
|
||||||
|
// back to the kind's default action.
|
||||||
|
results[0].ActionSpans = ActionSpans;
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using EchoHub.Client.UI.Helpers;
|
using EchoHub.Client.UI.Helpers;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using Terminal.Gui.Drawing;
|
using Terminal.Gui.Drawing;
|
||||||
@@ -312,7 +313,9 @@ public sealed class ChatMessageManager
|
|||||||
if (!string.IsNullOrEmpty(_currentUser))
|
if (!string.IsNullOrEmpty(_currentUser))
|
||||||
{
|
{
|
||||||
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
|
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);
|
_mentionChannels.Add(channelName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -391,8 +394,36 @@ public sealed class ChatMessageManager
|
|||||||
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
|
var hasContent = !string.IsNullOrWhiteSpace(message.Content);
|
||||||
var attachments = message.Attachments ?? [];
|
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
|
// 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 displayContent = EmojiHelper.ReplaceEmoji(message.Content);
|
||||||
var contentLines = displayContent.Split('\n');
|
var contentLines = displayContent.Split('\n');
|
||||||
@@ -443,9 +474,7 @@ public sealed class ChatMessageManager
|
|||||||
lines.Add(new ChatLine(segments));
|
lines.Add(new ChatLine(segments));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lines.Add(AttachmentActionLine(
|
lines.Add(ImageActionLine(attachment));
|
||||||
$"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]",
|
|
||||||
ChatColors.FileAttr, attachment));
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case Core.Models.AttachmentKind.Audio:
|
case Core.Models.AttachmentKind.Audio:
|
||||||
@@ -476,10 +505,14 @@ public sealed class ChatMessageManager
|
|||||||
line.SenderUsername = message.SenderUsername;
|
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";
|
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)
|
foreach (var line in lines)
|
||||||
line.IsMention = true;
|
line.IsMention = true;
|
||||||
@@ -489,6 +522,41 @@ public sealed class ChatMessageManager
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the action line below an image preview: "[open] [↓ save original] name [size]".
|
||||||
|
/// Each bracket is an <see cref="AttachmentActionSpan"/> so a mouse click can target it;
|
||||||
|
/// keyboard activation (Enter) uses the default action, open.
|
||||||
|
/// </summary>
|
||||||
|
private static ChatLine ImageActionLine(AttachmentDto attachment)
|
||||||
|
{
|
||||||
|
var segments = RailPrefix();
|
||||||
|
var col = segments.Sum(s => s.Text.GetColumns());
|
||||||
|
var spans = new List<AttachmentActionSpan>();
|
||||||
|
|
||||||
|
void AddAction(string text, AttachmentAction action)
|
||||||
|
{
|
||||||
|
var width = text.GetColumns();
|
||||||
|
spans.Add(new AttachmentActionSpan(col, col + width - 1, action));
|
||||||
|
segments.Add(new(text, ChatColors.FileAttr));
|
||||||
|
col += width;
|
||||||
|
}
|
||||||
|
|
||||||
|
AddAction("[open]", AttachmentAction.OpenImage);
|
||||||
|
segments.Add(new(" ", null));
|
||||||
|
col += 1;
|
||||||
|
AddAction("[↓ save original]", AttachmentAction.SaveImage);
|
||||||
|
segments.Add(new($" {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]", ChatColors.FileAttr));
|
||||||
|
|
||||||
|
return new ChatLine(segments)
|
||||||
|
{
|
||||||
|
AttachmentUrl = attachment.Url,
|
||||||
|
AttachmentFileName = attachment.FileName,
|
||||||
|
AttachmentKind = attachment.Kind,
|
||||||
|
ActionSpans = spans,
|
||||||
|
ContinuationPrefixSegments = RailPrefix(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
/// Builds a clickable attachment line carrying the metadata the message list uses to
|
||||||
/// route activation (play audio, download file, save original image).
|
/// route activation (play audio, download file, save original image).
|
||||||
@@ -525,6 +593,54 @@ public sealed class ChatMessageManager
|
|||||||
new(" │ ", ChatColors.RailAttr),
|
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>
|
/// <summary>
|
||||||
/// Indent segments aligning continuation/attachment/embed lines under the message
|
/// Indent segments aligning continuation/attachment/embed lines under the message
|
||||||
/// text, extending the │ rail. Returns a fresh mutable list each call.
|
/// text, extending the │ rail. Returns a fresh mutable list each call.
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ namespace EchoHub.Client.UI.Dialogs;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public record ConnectDialogResult(
|
public record ConnectDialogResult(
|
||||||
string ServerUrl, string Username, string Password,
|
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>
|
/// <summary>
|
||||||
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
/// A Terminal.Gui dialog for entering server connection and authentication details.
|
||||||
@@ -29,7 +30,7 @@ public sealed class ConnectDialog
|
|||||||
savedServers ??= [];
|
savedServers ??= [];
|
||||||
|
|
||||||
var hasSavedServers = savedServers.Count > 0;
|
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 };
|
var dialog = new Dialog { Title = "Connect to Server", Width = 60, Height = dialogHeight };
|
||||||
|
|
||||||
@@ -153,26 +154,41 @@ public sealed class ConnectDialog
|
|||||||
Width = Dim.Fill(2)
|
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
|
var loginButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Login",
|
Text = "Login",
|
||||||
IsDefault = true,
|
IsDefault = true,
|
||||||
X = Pos.Center() - 20,
|
X = Pos.Center() - 20,
|
||||||
Y = yOffset + 11
|
Y = yOffset + 13
|
||||||
};
|
};
|
||||||
|
|
||||||
var registerButton = new Button
|
var registerButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Register",
|
Text = "Register",
|
||||||
X = Pos.Center() - 5,
|
X = Pos.Center() - 5,
|
||||||
Y = yOffset + 11
|
Y = yOffset + 13
|
||||||
};
|
};
|
||||||
|
|
||||||
var cancelButton = new Button
|
var cancelButton = new Button
|
||||||
{
|
{
|
||||||
Text = "Cancel",
|
Text = "Cancel",
|
||||||
X = Pos.Center() + 10,
|
X = Pos.Center() + 10,
|
||||||
Y = yOffset + 11
|
Y = yOffset + 13
|
||||||
};
|
};
|
||||||
|
|
||||||
// Wire saved server selection to auto-fill fields
|
// Wire saved server selection to auto-fill fields
|
||||||
@@ -262,7 +278,11 @@ public sealed class ConnectDialog
|
|||||||
return;
|
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;
|
e.Handled = true;
|
||||||
app.RequestStop();
|
app.RequestStop();
|
||||||
};
|
};
|
||||||
@@ -276,6 +296,7 @@ public sealed class ConnectDialog
|
|||||||
|
|
||||||
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
|
dialog.Add(urlLabel, urlField, userLabel, userField, passLabel, passField,
|
||||||
tokenHintLabel, rememberMeCheckbox, displayLabel, displayField,
|
tokenHintLabel, rememberMeCheckbox, displayLabel, displayField,
|
||||||
|
inviteLabel, inviteField,
|
||||||
loginButton, registerButton, cancelButton);
|
loginButton, registerButton, cancelButton);
|
||||||
|
|
||||||
if (hasSavedServers && savedServerList is not null)
|
if (hasSavedServers && savedServerList is not null)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public class ChannelListSource : IListDataSource
|
|||||||
private readonly HashSet<string> _protectedChannels = [];
|
private readonly HashSet<string> _protectedChannels = [];
|
||||||
private readonly HashSet<string> _mentionChannels = [];
|
private readonly HashSet<string> _mentionChannels = [];
|
||||||
private readonly HashSet<string> _privateChannels = [];
|
private readonly HashSet<string> _privateChannels = [];
|
||||||
|
private readonly HashSet<string> _systemChannels = [];
|
||||||
private string _activeChannel = string.Empty;
|
private string _activeChannel = string.Empty;
|
||||||
|
|
||||||
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
public event NotifyCollectionChangedEventHandler? CollectionChanged;
|
||||||
@@ -31,10 +32,12 @@ public class ChannelListSource : IListDataSource
|
|||||||
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
|
private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None);
|
||||||
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None);
|
||||||
private static readonly Attribute MentionAttr = new(new Color(230, 140, 60), Color.None);
|
private static readonly Attribute MentionAttr = new(new Color(230, 140, 60), Color.None);
|
||||||
|
// System channels (e.g. the live log room) render in green to set them apart from user rooms.
|
||||||
|
private static readonly Attribute SystemAttr = new(Color.BrightGreen, Color.None);
|
||||||
|
|
||||||
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
|
public void Update(List<string> channels, Dictionary<string, int> unread, string activeChannel,
|
||||||
IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null,
|
IReadOnlySet<string>? protectedChannels = null, IReadOnlySet<string>? mentionChannels = null,
|
||||||
IReadOnlySet<string>? privateChannels = null)
|
IReadOnlySet<string>? privateChannels = null, IReadOnlySet<string>? systemChannels = null)
|
||||||
{
|
{
|
||||||
_channelNames.Clear();
|
_channelNames.Clear();
|
||||||
_channelNames.AddRange(channels);
|
_channelNames.AddRange(channels);
|
||||||
@@ -50,6 +53,9 @@ public class ChannelListSource : IListDataSource
|
|||||||
_privateChannels.Clear();
|
_privateChannels.Clear();
|
||||||
if (privateChannels is not null)
|
if (privateChannels is not null)
|
||||||
_privateChannels.UnionWith(privateChannels);
|
_privateChannels.UnionWith(privateChannels);
|
||||||
|
_systemChannels.Clear();
|
||||||
|
if (systemChannels is not null)
|
||||||
|
_systemChannels.UnionWith(systemChannels);
|
||||||
_activeChannel = activeChannel;
|
_activeChannel = activeChannel;
|
||||||
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0;
|
||||||
if (!SuspendCollectionChangedEvent)
|
if (!SuspendCollectionChangedEvent)
|
||||||
@@ -71,7 +77,9 @@ public class ChannelListSource : IListDataSource
|
|||||||
|
|
||||||
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
var normalAttr = listView.GetAttributeForRole(VisualRole.Normal);
|
||||||
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
var focusAttr = listView.GetAttributeForRole(VisualRole.Focus);
|
||||||
var prefix = isActive ? "> " : " ";
|
var isSystem = _systemChannels.Contains(name);
|
||||||
|
// System channels get a leading rule so they read as a pinned, separate group at the top.
|
||||||
|
var prefix = isSystem ? (isActive ? "▌ " : "▎ ") : (isActive ? "> " : " ");
|
||||||
// Trailing * marks password-protected (+k) channels; ~ marks private (unlisted) ones
|
// Trailing * marks password-protected (+k) channels; ~ marks private (unlisted) ones
|
||||||
var channelText = $"#{name}";
|
var channelText = $"#{name}";
|
||||||
if (_protectedChannels.Contains(name))
|
if (_protectedChannels.Contains(name))
|
||||||
@@ -93,12 +101,15 @@ public class ChannelListSource : IListDataSource
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr));
|
// System channels are green so they stand apart from user rooms; that green also
|
||||||
|
// colors the leading rule and (dimmer) prefix.
|
||||||
|
listView.SetAttribute(Resolve(isSystem ? SystemAttr : isActive ? ActiveAttr : NormalAttr));
|
||||||
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width);
|
||||||
|
|
||||||
// Mentions escalate above plain unread: the whole entry turns orange
|
// Mentions escalate above plain unread: the whole entry turns orange
|
||||||
var hasMention = _mentionChannels.Contains(name);
|
var hasMention = _mentionChannels.Contains(name);
|
||||||
var nameAttr = isActive ? ActiveAttr
|
var nameAttr = isSystem ? SystemAttr
|
||||||
|
: isActive ? ActiveAttr
|
||||||
: hasMention ? MentionAttr
|
: hasMention ? MentionAttr
|
||||||
: hasUnread ? UnreadAttr
|
: hasUnread ? UnreadAttr
|
||||||
: NormalAttr;
|
: NormalAttr;
|
||||||
@@ -107,7 +118,7 @@ public class ChannelListSource : IListDataSource
|
|||||||
|
|
||||||
if (hasUnread)
|
if (hasUnread)
|
||||||
{
|
{
|
||||||
listView.SetAttribute(Resolve(hasMention ? MentionAttr : BadgeAttr));
|
listView.SetAttribute(Resolve(isSystem ? SystemAttr : hasMention ? MentionAttr : BadgeAttr));
|
||||||
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,16 +64,18 @@ public sealed partial class MainWindow : Runnable
|
|||||||
// Available slash commands for Tab autocomplete
|
// Available slash commands for Tab autocomplete
|
||||||
private static readonly string[] SlashCommands =
|
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",
|
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/size", "/downloadpath",
|
||||||
"/topic", "/users", "/kick", "/ban", "/unban",
|
"/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 = [];
|
private readonly List<string> _channelNames = [];
|
||||||
private readonly Dictionary<string, string?> _channelTopics = [];
|
private readonly Dictionary<string, string?> _channelTopics = [];
|
||||||
private readonly Dictionary<string, bool> _channelPublic = [];
|
private readonly Dictionary<string, bool> _channelPublic = [];
|
||||||
private readonly HashSet<string> _channelProtected = [];
|
private readonly HashSet<string> _channelProtected = [];
|
||||||
|
private readonly HashSet<string> _systemChannels = [];
|
||||||
private readonly ChannelListSource _channelListSource;
|
private readonly ChannelListSource _channelListSource;
|
||||||
private readonly ChatMessageManager _messageManager;
|
private readonly ChatMessageManager _messageManager;
|
||||||
private string _connectionStatus = "Disconnected";
|
private string _connectionStatus = "Disconnected";
|
||||||
@@ -176,11 +178,28 @@ public sealed partial class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<string, string>? OnImageSaveRequested;
|
public event Action<string, string>? OnImageSaveRequested;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fired when the user activates an image's "[open]" action to view it without saving.
|
||||||
|
/// Parameters: attachmentUrl, fileName.
|
||||||
|
/// </summary>
|
||||||
|
public event Action<string, string>? OnImageOpenRequested;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
|
/// Fired when the user presses Delete on the selected message. Parameter is the message id.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public event Action<Guid>? OnDeleteMessageRequested;
|
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>
|
/// <summary>
|
||||||
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
|
/// Fired when the user activates a username (in userlist or message). Parameter is the username.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -351,6 +370,9 @@ public sealed partial class MainWindow : Runnable
|
|||||||
KeyDown += OnWindowKeyDown;
|
KeyDown += OnWindowKeyDown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string? _stagedTitleFragment;
|
||||||
|
private string? _replyTitleFragment;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Updates the attachment staging indicator shown on the input frame's title, including the
|
/// 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.
|
/// current ASCII-art size for images. Passing an empty list restores the default hint.
|
||||||
@@ -360,15 +382,46 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_hasStagedAttachments = fileNames.Count > 0;
|
_hasStagedAttachments = fileNames.Count > 0;
|
||||||
if (fileNames.Count == 0)
|
if (fileNames.Count == 0)
|
||||||
{
|
{
|
||||||
_inputFrame.Title = DefaultInputTitle;
|
_stagedTitleFragment = null;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var names = string.Join(", ", fileNames);
|
var names = string.Join(", ", fileNames);
|
||||||
if (names.Length > 45)
|
if (names.Length > 45)
|
||||||
names = names[..42] + "...";
|
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()
|
||||||
|
{
|
||||||
|
// Read-only channels (the live log room) override any reply/staged hint.
|
||||||
|
if (IsCurrentChannelReadOnly)
|
||||||
|
{
|
||||||
|
_inputFrame.Title = "Read-only channel — you cannot type here";
|
||||||
|
_inputFrame.SetNeedsDraw();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_inputFrame.Title = (_replyTitleFragment, _stagedTitleFragment) switch
|
||||||
|
{
|
||||||
|
(null, null) => DefaultInputTitle,
|
||||||
|
({ } reply, null) => reply,
|
||||||
|
(null, { } staged) => staged,
|
||||||
|
({ } reply, { } staged) => $"{reply} │ {staged}",
|
||||||
|
};
|
||||||
_inputFrame.SetNeedsDraw();
|
_inputFrame.SetNeedsDraw();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,6 +566,14 @@ public sealed partial class MainWindow : Runnable
|
|||||||
var line = source.GetLine(index.Value);
|
var line = source.GetLine(index.Value);
|
||||||
if (line is null) return;
|
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
|
// Audio/file attachments take priority
|
||||||
if (line.AttachmentUrl is not null && line.AttachmentFileName is not null)
|
if (line.AttachmentUrl is not null && line.AttachmentFileName is not null)
|
||||||
{
|
{
|
||||||
@@ -532,7 +593,9 @@ public sealed partial class MainWindow : Runnable
|
|||||||
|
|
||||||
if (line.AttachmentKind == AttachmentKind.Image)
|
if (line.AttachmentKind == AttachmentKind.Image)
|
||||||
{
|
{
|
||||||
OnImageSaveRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
// Keyboard/default activation opens the image for viewing;
|
||||||
|
// saving is the mouse span or the context menu.
|
||||||
|
OnImageOpenRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName);
|
||||||
e.Handled = true;
|
e.Handled = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -600,7 +663,8 @@ public sealed partial class MainWindow : Runnable
|
|||||||
|
|
||||||
private void OnMessageListMouseEvent(object? sender, Mouse e)
|
private void OnMessageListMouseEvent(object? sender, Mouse e)
|
||||||
{
|
{
|
||||||
if (!e.Flags.HasFlag(MouseFlags.RightButtonClicked))
|
var leftClick = e.Flags.HasFlag(MouseFlags.LeftButtonClicked);
|
||||||
|
if (!leftClick && !e.Flags.HasFlag(MouseFlags.RightButtonClicked))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos)
|
if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos)
|
||||||
@@ -610,6 +674,30 @@ public sealed partial class MainWindow : Runnable
|
|||||||
if (index < 0 || index >= source.Count)
|
if (index < 0 || index >= source.Count)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
// Left-click only activates the "[open]" / "[save original]" brackets on an
|
||||||
|
// attachment action line; anywhere else it falls through to normal selection.
|
||||||
|
if (leftClick)
|
||||||
|
{
|
||||||
|
var clicked = source.GetLine(index);
|
||||||
|
if (clicked?.ActionSpans is { } spans
|
||||||
|
&& clicked.AttachmentUrl is { } url && clicked.AttachmentFileName is { } name)
|
||||||
|
{
|
||||||
|
foreach (var span in spans)
|
||||||
|
{
|
||||||
|
if (pos.X < span.StartCol || pos.X > span.EndCol)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (span.Action == AttachmentAction.OpenImage)
|
||||||
|
OnImageOpenRequested?.Invoke(url, name);
|
||||||
|
else
|
||||||
|
OnImageSaveRequested?.Invoke(url, name);
|
||||||
|
e.Handled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Select the right-clicked row (so the menu acts on it and it highlights), then show the menu.
|
// Select the right-clicked row (so the menu acts on it and it highlights), then show the menu.
|
||||||
_messageList.SelectedItem = index;
|
_messageList.SelectedItem = index;
|
||||||
_messageList.SetFocus();
|
_messageList.SetFocus();
|
||||||
@@ -636,6 +724,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
switch (kind)
|
switch (kind)
|
||||||
{
|
{
|
||||||
case AttachmentKind.Image:
|
case AttachmentKind.Image:
|
||||||
|
items.Add(new MenuItem("Open image", "", () => OnImageOpenRequested?.Invoke(url, name), Key.Empty));
|
||||||
items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty));
|
items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty));
|
||||||
break;
|
break;
|
||||||
case AttachmentKind.Audio:
|
case AttachmentKind.Audio:
|
||||||
@@ -647,6 +736,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)
|
if (sender is not null)
|
||||||
{
|
{
|
||||||
items.Add(new MenuItem($"Mention @{sender}", "", () => MentionUser(sender), Key.Empty));
|
items.Add(new MenuItem($"Mention @{sender}", "", () => MentionUser(sender), Key.Empty));
|
||||||
@@ -688,6 +791,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)
|
private void ConfirmDeleteMessage(Guid messageId)
|
||||||
{
|
{
|
||||||
var confirm = MessageBox.Query(_app, "Delete Message", "Delete this message?", "Delete", "Cancel");
|
var confirm = MessageBox.Query(_app, "Delete Message", "Delete this message?", "Delete", "Cancel");
|
||||||
@@ -723,11 +850,17 @@ public sealed partial class MainWindow : Runnable
|
|||||||
TryAutocompleteCommand();
|
TryAutocompleteCommand();
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case KeyCode.Esc when HasPendingReplyIndicator:
|
||||||
|
OnReplyCancelRequested?.Invoke();
|
||||||
|
break;
|
||||||
|
|
||||||
case NewlineKey:
|
case NewlineKey:
|
||||||
_inputField.InsertText("\n");
|
_inputField.InsertText("\n");
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case EnterKey:
|
case EnterKey:
|
||||||
|
if (IsCurrentChannelReadOnly)
|
||||||
|
break;
|
||||||
var text = _inputField.Text?.Trim() ?? string.Empty;
|
var text = _inputField.Text?.Trim() ?? string.Empty;
|
||||||
// Send when there's text, or when only attachments are staged (empty caption).
|
// Send when there's text, or when only attachments are staged (empty caption).
|
||||||
if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments)
|
if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments)
|
||||||
@@ -754,6 +887,9 @@ public sealed partial class MainWindow : Runnable
|
|||||||
|
|
||||||
case CtrlVKey:
|
case CtrlVKey:
|
||||||
case CtrlYKey:
|
case CtrlYKey:
|
||||||
|
// Read-only channels can't receive text or attachments.
|
||||||
|
if (IsCurrentChannelReadOnly)
|
||||||
|
break;
|
||||||
// Discord-style paste priority. Copied files in the OS file manager put a file
|
// Discord-style paste priority. Copied files in the OS file manager put a file
|
||||||
// list (not text) on the clipboard — attach them all. Copied image data (browser
|
// list (not text) on the clipboard — attach them all. Copied image data (browser
|
||||||
// right-click copy, screenshot tools) is attached as a PNG. Otherwise paste text.
|
// right-click copy, screenshot tools) is attached as a PNG. Otherwise paste text.
|
||||||
@@ -980,6 +1116,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_channelTopics.Clear();
|
_channelTopics.Clear();
|
||||||
_channelPublic.Clear();
|
_channelPublic.Clear();
|
||||||
_channelProtected.Clear();
|
_channelProtected.Clear();
|
||||||
|
_systemChannels.Clear();
|
||||||
foreach (var ch in channels)
|
foreach (var ch in channels)
|
||||||
{
|
{
|
||||||
_channelNames.Add(ch.Name);
|
_channelNames.Add(ch.Name);
|
||||||
@@ -987,6 +1124,8 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_channelPublic[ch.Name] = ch.IsPublic;
|
_channelPublic[ch.Name] = ch.IsPublic;
|
||||||
if (ch.IsProtected)
|
if (ch.IsProtected)
|
||||||
_channelProtected.Add(ch.Name);
|
_channelProtected.Add(ch.Name);
|
||||||
|
if (ch.IsSystem)
|
||||||
|
_systemChannels.Add(ch.Name);
|
||||||
}
|
}
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
}
|
||||||
@@ -994,7 +1133,8 @@ public sealed partial class MainWindow : Runnable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
|
/// Ensure a channel exists in the left panel list (used for private channels joined via /join).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null)
|
public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null,
|
||||||
|
bool? isSystem = null)
|
||||||
{
|
{
|
||||||
if (isPublic.HasValue)
|
if (isPublic.HasValue)
|
||||||
_channelPublic[channelName] = isPublic.Value;
|
_channelPublic[channelName] = isPublic.Value;
|
||||||
@@ -1005,9 +1145,15 @@ public sealed partial class MainWindow : Runnable
|
|||||||
else _channelProtected.Remove(channelName);
|
else _channelProtected.Remove(channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSystem.HasValue)
|
||||||
|
{
|
||||||
|
if (isSystem.Value) _systemChannels.Add(channelName);
|
||||||
|
else _systemChannels.Remove(channelName);
|
||||||
|
}
|
||||||
|
|
||||||
if (_channelNames.Contains(channelName))
|
if (_channelNames.Contains(channelName))
|
||||||
{
|
{
|
||||||
if (isProtected.HasValue)
|
if (isProtected.HasValue || isSystem.HasValue)
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1025,6 +1171,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_channelTopics.Remove(channelName);
|
_channelTopics.Remove(channelName);
|
||||||
_channelPublic.Remove(channelName);
|
_channelPublic.Remove(channelName);
|
||||||
_channelProtected.Remove(channelName);
|
_channelProtected.Remove(channelName);
|
||||||
|
_systemChannels.Remove(channelName);
|
||||||
RefreshChannelList();
|
RefreshChannelList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1215,6 +1362,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
|
|
||||||
RefreshMessages();
|
RefreshMessages();
|
||||||
UpdateTopicBar();
|
UpdateTopicBar();
|
||||||
|
UpdateInputReadOnly();
|
||||||
_statusLabel.SetNeedsDraw();
|
_statusLabel.SetNeedsDraw();
|
||||||
|
|
||||||
// Update channel list selection
|
// Update channel list selection
|
||||||
@@ -1223,6 +1371,19 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_channelList.SelectedItem = idx;
|
_channelList.SelectedItem = idx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Whether the active channel is read-only (a system channel like the log room).</summary>
|
||||||
|
private bool IsCurrentChannelReadOnly => _systemChannels.Contains(_messageManager.CurrentChannel);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Disables the input for read-only (system) channels so nothing can be typed there, and
|
||||||
|
/// reflects the state in the input frame title.
|
||||||
|
/// </summary>
|
||||||
|
private void UpdateInputReadOnly()
|
||||||
|
{
|
||||||
|
_inputField.ReadOnly = IsCurrentChannelReadOnly;
|
||||||
|
UpdateInputTitle();
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Clear all messages and channels (used on disconnect).
|
/// Clear all messages and channels (used on disconnect).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -1233,6 +1394,7 @@ public sealed partial class MainWindow : Runnable
|
|||||||
_channelTopics.Clear();
|
_channelTopics.Clear();
|
||||||
_channelPublic.Clear();
|
_channelPublic.Clear();
|
||||||
_channelProtected.Clear();
|
_channelProtected.Clear();
|
||||||
|
_systemChannels.Clear();
|
||||||
_channelListSource.Update([], [], string.Empty);
|
_channelListSource.Update([], [], string.Empty);
|
||||||
_channelList.Source = _channelListSource;
|
_channelList.Source = _channelListSource;
|
||||||
_chatFrame.Title = "Chat";
|
_chatFrame.Title = "Chat";
|
||||||
@@ -1337,11 +1499,21 @@ public sealed partial class MainWindow : Runnable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private void RefreshChannelList()
|
private void RefreshChannelList()
|
||||||
{
|
{
|
||||||
|
// Pin system channels (e.g. the live log room) to the very top, keeping the server's
|
||||||
|
// relative order otherwise. OrderBy is stable, so alphabetical order is preserved within
|
||||||
|
// each group. Reordering in place keeps _channelNames the source of truth for selection
|
||||||
|
// lookups. System channels are private by nature but shouldn't get the private (~) glyph,
|
||||||
|
// so exclude them from the private set.
|
||||||
|
var ordered = _channelNames.OrderBy(n => _systemChannels.Contains(n) ? 0 : 1).ToList();
|
||||||
|
_channelNames.Clear();
|
||||||
|
_channelNames.AddRange(ordered);
|
||||||
|
|
||||||
var privateChannels = _channelNames
|
var privateChannels = _channelNames
|
||||||
.Where(n => _channelPublic.TryGetValue(n, out var isPublic) && !isPublic)
|
.Where(n => !_systemChannels.Contains(n)
|
||||||
|
&& _channelPublic.TryGetValue(n, out var isPublic) && !isPublic)
|
||||||
.ToHashSet();
|
.ToHashSet();
|
||||||
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel,
|
_channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel,
|
||||||
_channelProtected, _messageManager.MentionChannels, privateChannels);
|
_channelProtected, _messageManager.MentionChannels, privateChannels, _systemChannels);
|
||||||
_channelList.Source = _channelListSource;
|
_channelList.Source = _channelListSource;
|
||||||
|
|
||||||
// Restore selection to current channel
|
// Restore selection to current channel
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,11 @@ public interface IChannelService
|
|||||||
|
|
||||||
// Membership
|
// Membership
|
||||||
Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null);
|
Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null);
|
||||||
|
|
||||||
|
// System channels (server-managed, e.g. the live server-log room). Creates the channel
|
||||||
|
// if missing and reclaims a same-named regular channel so server content never lands in
|
||||||
|
// a user-owned room.
|
||||||
|
Task<ChannelDto> EnsureSystemChannelAsync(string channelName, string? topic = null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public record ChannelListItem(string Name, string? Topic, int OnlineCount, bool IsPublic = true, bool IsProtected = false);
|
public record ChannelListItem(string Name, string? Topic, int OnlineCount, bool IsPublic = true, bool IsProtected = false);
|
||||||
|
|||||||
@@ -4,7 +4,14 @@ namespace EchoHub.Core.Contracts;
|
|||||||
|
|
||||||
public interface IChatBroadcaster
|
public interface IChatBroadcaster
|
||||||
{
|
{
|
||||||
Task SendMessageToChannelAsync(string channelName, MessageDto message);
|
/// <summary>
|
||||||
|
/// Broadcast a chat message to a channel. <paramref name="excludeConnectionId"/> is the
|
||||||
|
/// connection the message originated from (IRC convention: never echo a message back to
|
||||||
|
/// the connection that sent it — its client already displayed it locally). Other
|
||||||
|
/// connections of the same user (e.g. an IRC session alongside a TUI session) still
|
||||||
|
/// receive the message.
|
||||||
|
/// </summary>
|
||||||
|
Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null);
|
||||||
Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null);
|
Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null);
|
||||||
Task SendUserLeftAsync(string channelName, string username);
|
Task SendUserLeftAsync(string channelName, string username);
|
||||||
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
Task SendChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||||
@@ -12,6 +19,7 @@ public interface IChatBroadcaster
|
|||||||
Task SendUserKickedAsync(string channelName, string username, string? reason);
|
Task SendUserKickedAsync(string channelName, string username, string? reason);
|
||||||
Task SendUserBannedAsync(string username, string? reason);
|
Task SendUserBannedAsync(string username, string? reason);
|
||||||
Task SendMessageDeletedAsync(string channelName, Guid messageId);
|
Task SendMessageDeletedAsync(string channelName, Guid messageId);
|
||||||
|
Task SendChannelDeletedAsync(string channelName);
|
||||||
Task SendChannelNukedAsync(string channelName);
|
Task SendChannelNukedAsync(string channelName);
|
||||||
Task SendErrorAsync(string connectionId, string message);
|
Task SendErrorAsync(string connectionId, string message);
|
||||||
Task ForceDisconnectUserAsync(List<string> connectionIds, string reason);
|
Task ForceDisconnectUserAsync(List<string> connectionIds, string reason);
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ public interface IChatService
|
|||||||
Task<(List<MessageDto> History, string? Error, bool PasswordRequired)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName, string? password = null);
|
Task<(List<MessageDto> History, string? Error, bool PasswordRequired)> JoinChannelAsync(string connectionId, Guid userId, string username, string channelName, string? password = null);
|
||||||
Task LeaveChannelAsync(string connectionId, string username, string channelName);
|
Task LeaveChannelAsync(string connectionId, string username, string channelName);
|
||||||
|
|
||||||
// Messaging
|
// Messaging. originConnectionId identifies the connection the message came from so
|
||||||
Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content);
|
// broadcasters can avoid echoing it back to that one connection (IRC convention);
|
||||||
|
// 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);
|
Task<List<MessageDto>> GetChannelHistoryAsync(string channelName, int count, int offset = 0);
|
||||||
|
|
||||||
// Presence
|
// Presence
|
||||||
@@ -24,6 +27,7 @@ public interface IChatService
|
|||||||
// Broadcasting (used by controllers and IRC gateway)
|
// Broadcasting (used by controllers and IRC gateway)
|
||||||
Task BroadcastMessageAsync(string channelName, MessageDto message);
|
Task BroadcastMessageAsync(string channelName, MessageDto message);
|
||||||
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null);
|
||||||
|
Task BroadcastChannelDeletedAsync(string channelName);
|
||||||
|
|
||||||
// Query operations (used by IRC gateway for WHOIS)
|
// Query operations (used by IRC gateway for WHOIS)
|
||||||
Task<List<string>> GetChannelsForUserAsync(string username);
|
Task<List<string>> GetChannelsForUserAsync(string username);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ public interface IEchoHubClient
|
|||||||
Task UserKicked(string channelName, string username, string? reason);
|
Task UserKicked(string channelName, string username, string? reason);
|
||||||
Task UserBanned(string username, string? reason);
|
Task UserBanned(string username, string? reason);
|
||||||
Task MessageDeleted(string channelName, Guid messageId);
|
Task MessageDeleted(string channelName, Guid messageId);
|
||||||
|
Task ChannelDeleted(string channelName);
|
||||||
Task ChannelNuked(string channelName);
|
Task ChannelNuked(string channelName);
|
||||||
Task ForceDisconnect(string reason);
|
Task ForceDisconnect(string reason);
|
||||||
Task Error(string message);
|
Task Error(string message);
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ namespace EchoHub.Core.Contracts;
|
|||||||
|
|
||||||
public interface IUserService
|
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<UserOperationResult> AuthenticateUserAsync(string username, string password);
|
||||||
Task<UserProfileDto?> GetUserProfileAsync(string username);
|
Task<UserProfileDto?> GetUserProfileAsync(string username);
|
||||||
Task<UserProfileDto?> GetUserByIdAsync(Guid userId);
|
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;
|
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);
|
public record LoginRequest(string Username, string Password);
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,19 @@ public record MessageDto(
|
|||||||
DateTimeOffset SentAt,
|
DateTimeOffset SentAt,
|
||||||
List<AttachmentDto>? Attachments = null,
|
List<AttachmentDto>? Attachments = null,
|
||||||
List<EmbedDto>? Embeds = 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>
|
/// <summary>
|
||||||
/// A file attached to a message. <see cref="AsciiPreview"/> holds the color-tag art for
|
/// A file attached to a message. <see cref="AsciiPreview"/> holds the color-tag art for
|
||||||
@@ -33,7 +45,8 @@ public record ChannelDto(
|
|||||||
int MessageCount,
|
int MessageCount,
|
||||||
DateTimeOffset CreatedAt,
|
DateTimeOffset CreatedAt,
|
||||||
bool IsProtected = false,
|
bool IsProtected = false,
|
||||||
bool IsEncrypted = false);
|
bool IsEncrypted = false,
|
||||||
|
bool IsSystem = false);
|
||||||
|
|
||||||
public record UserDto(
|
public record UserDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
|
|||||||
@@ -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;
|
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);
|
public record EncryptionKeyResponse(string Key);
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ public class Channel
|
|||||||
public required string Name { get; set; }
|
public required string Name { get; set; }
|
||||||
public string? Topic { get; set; }
|
public string? Topic { get; set; }
|
||||||
public bool IsPublic { get; set; } = true;
|
public bool IsPublic { get; set; } = true;
|
||||||
|
|
||||||
|
// Server-managed channel (e.g. the live server-log room): auto-created, read-only for
|
||||||
|
// every role, visible only to roles the owning feature allows. Users can never create one.
|
||||||
|
public bool IsSystem { get; set; }
|
||||||
public string? PasswordHash { get; set; }
|
public string? PasswordHash { get; set; }
|
||||||
|
|
||||||
// End-to-end encryption envelope (client-generated; server cannot decrypt room content).
|
// End-to-end encryption envelope (client-generated; server cannot decrypt room content).
|
||||||
|
|||||||
@@ -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 Guid SenderUserId { get; set; }
|
||||||
public required string SenderUsername { 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>
|
/// <summary>Files attached to this message. Empty for a plain text message.</summary>
|
||||||
public List<Attachment> Attachments { get; set; } = [];
|
public List<Attachment> Attachments { get; set; } = [];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
namespace EchoHub.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A snapshot of server activity over one reporting window, produced periodically by the
|
||||||
|
/// stats-report background job. Each report is logged as pretty-printed JSON and persisted
|
||||||
|
/// for historical trend analysis. The window is "since the previous report" (or since startup
|
||||||
|
/// for the first report of a run).
|
||||||
|
/// </summary>
|
||||||
|
public class ServerStatsReport
|
||||||
|
{
|
||||||
|
public Guid Id { get; set; }
|
||||||
|
|
||||||
|
/// <summary>When this report was generated (equals <see cref="PeriodEnd"/>).</summary>
|
||||||
|
public DateTimeOffset GeneratedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
/// <summary>Start of the reporting window.</summary>
|
||||||
|
public DateTimeOffset PeriodStart { get; set; }
|
||||||
|
|
||||||
|
/// <summary>End of the reporting window.</summary>
|
||||||
|
public DateTimeOffset PeriodEnd { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Length of the reporting window in hours.</summary>
|
||||||
|
public double WindowHours { get; set; }
|
||||||
|
|
||||||
|
// ── Activity during the window ──────────────────────────────────────────
|
||||||
|
/// <summary>Messages sent during the window.</summary>
|
||||||
|
public int MessagesSent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Attachments (files/images/audio) uploaded during the window.</summary>
|
||||||
|
public int FilesUploaded { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Total bytes across all attachments uploaded during the window.</summary>
|
||||||
|
public long BytesUploaded { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Accounts registered during the window ("new members joined").</summary>
|
||||||
|
public int NewMembers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Distinct users who sent at least one message during the window.</summary>
|
||||||
|
public int ActiveMembers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Session connects during the window (per-connection, across SignalR + IRC).</summary>
|
||||||
|
public int Connections { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Session disconnects during the window ("members left" sessions).</summary>
|
||||||
|
public int Disconnections { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Users kicked during the window.</summary>
|
||||||
|
public int Kicks { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Users banned during the window.</summary>
|
||||||
|
public int Bans { get; set; }
|
||||||
|
|
||||||
|
// ── Point-in-time totals at window end ──────────────────────────────────
|
||||||
|
/// <summary>Total registered (unique) members at window end.</summary>
|
||||||
|
public int TotalMembers { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Distinct users online at window end.</summary>
|
||||||
|
public int OnlineNow { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Peak distinct users online observed during the window.</summary>
|
||||||
|
public int PeakOnline { 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'] = [".##.", "#..#", ".###", "...#", ".##."],
|
||||||
|
['!'] = ["#", "#", "#", ".", "#"],
|
||||||
|
['?'] = [".##.", "#..#", "..#.", "....", "..#."],
|
||||||
|
['.'] = [".", ".", ".", ".", "#"],
|
||||||
|
[','] = ["..", "..", "..", ".#", "#."],
|
||||||
|
['-'] = ["....", "....", "####", "....", "...."],
|
||||||
|
['\''] = ["#", "#", ".", ".", "."],
|
||||||
|
[':'] = [".", "#", ".", "#", "."],
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -14,24 +14,24 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
public async Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null)
|
||||||
{
|
{
|
||||||
// Decrypt content and attachment previews for IRC clients (they can't handle
|
// Decrypt transport-encrypted content for IRC clients (they can't handle
|
||||||
// app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched;
|
// app-layer encryption). E2E room ciphertext ($RC1$) passes through untouched.
|
||||||
// the formatter drops previews that are still ciphertext.
|
|
||||||
var decryptedMessage = message with
|
var decryptedMessage = message with
|
||||||
{
|
{
|
||||||
Content = _encryption.Decrypt(message.Content),
|
Content = _encryption.Decrypt(message.Content),
|
||||||
Attachments = message.Attachments?
|
ReplyTo = message.ReplyTo is { } reply ? reply with { Content = _encryption.Decrypt(reply.Content) } : null,
|
||||||
.Select(a => a with { AsciiPreview = _encryption.DecryptNullable(a.AsciiPreview) })
|
|
||||||
.ToList(),
|
|
||||||
};
|
};
|
||||||
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage);
|
var lines = IrcMessageFormatter.FormatMessage(decryptedMessage, _gateway.Options.PublicBaseUrl);
|
||||||
|
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
{
|
{
|
||||||
// IRC convention: don't echo sender's own message
|
// IRC convention: don't echo a message back to the connection that sent it
|
||||||
if (conn.Nickname == message.SenderUsername)
|
// (its client already displayed it locally). Match by connection id, not
|
||||||
|
// nickname — the same account may also be online via the TUI or a second
|
||||||
|
// IRC client, and those sessions must still receive the message.
|
||||||
|
if (conn.ConnectionId == excludeConnectionId)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
foreach (var line in lines)
|
foreach (var line in lines)
|
||||||
@@ -101,6 +101,14 @@ public class IrcBroadcaster : IChatBroadcaster
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task SendChannelDeletedAsync(string channelName)
|
||||||
|
{
|
||||||
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
{
|
||||||
|
await conn.SendAsync($":{_gateway.Options.ServerName} NOTICE {conn.Nickname ?? "*"} :Channel #{channelName} has been deleted");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task SendChannelNukedAsync(string channelName)
|
public async Task SendChannelNukedAsync(string channelName)
|
||||||
{
|
{
|
||||||
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
foreach (var conn in _gateway.GetConnectionsInChannel(channelName))
|
||||||
|
|||||||
@@ -400,6 +400,16 @@ public sealed class IrcCommandHandler
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// System channels (e.g. the live log room) stream over SignalR only — the IRC
|
||||||
|
// gateway never carries their content, so block joins outright.
|
||||||
|
var channelInfo = await _channelService.GetChannelByNameAsync(channelName);
|
||||||
|
if (channelInfo?.IsSystem == true)
|
||||||
|
{
|
||||||
|
await _conn.SendNumericAsync(ServerName, IrcNumericReply.ERR_NOSUCHCHANNEL,
|
||||||
|
$"#{channelName} :Cannot join channel — server-managed, use the EchoHub client");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
var (history, error, passwordRequired) = await _chatService.JoinChannelAsync(
|
var (history, error, passwordRequired) = await _chatService.JoinChannelAsync(
|
||||||
_conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName, key);
|
_conn.ConnectionId, _conn.UserId!.Value, _conn.Nickname!, channelName, key);
|
||||||
|
|
||||||
@@ -432,8 +442,12 @@ public sealed class IrcCommandHandler
|
|||||||
// Replay history (decrypt — history is encrypted for SignalR transport)
|
// Replay history (decrypt — history is encrypted for SignalR transport)
|
||||||
foreach (var m in history)
|
foreach (var m in history)
|
||||||
{
|
{
|
||||||
var decrypted = m with { Content = _encryption.Decrypt(m.Content) };
|
var decrypted = m with
|
||||||
var lines = IrcMessageFormatter.FormatMessage(decrypted);
|
{
|
||||||
|
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)
|
foreach (var line in lines)
|
||||||
await _conn.SendAsync(line);
|
await _conn.SendAsync(line);
|
||||||
}
|
}
|
||||||
@@ -486,7 +500,7 @@ public sealed class IrcCommandHandler
|
|||||||
if (channelName is null) return;
|
if (channelName is null) return;
|
||||||
|
|
||||||
var error = await _chatService.SendMessageAsync(
|
var error = await _chatService.SendMessageAsync(
|
||||||
_conn.UserId!.Value, _conn.Nickname!, channelName, content);
|
_conn.UserId!.Value, _conn.Nickname!, channelName, content, _conn.ConnectionId);
|
||||||
|
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
var connection = new IrcClientConnection(tcpClient, stream);
|
var connection = new IrcClientConnection(tcpClient, stream);
|
||||||
_connections[connection.ConnectionId] = connection;
|
_connections[connection.ConnectionId] = connection;
|
||||||
|
|
||||||
_logger.LogInformation("IRC client connected: {Id}", connection.ConnectionId);
|
_logger.LogDebug("IRC client connected: {Id}", connection.ConnectionId);
|
||||||
|
|
||||||
IChatService? chatService = null;
|
IChatService? chatService = null;
|
||||||
|
|
||||||
@@ -148,7 +148,7 @@ public sealed class IrcGatewayService : BackgroundService
|
|||||||
|
|
||||||
_connections.TryRemove(connection.ConnectionId, out _);
|
_connections.TryRemove(connection.ConnectionId, out _);
|
||||||
await connection.DisposeAsync();
|
await connection.DisposeAsync();
|
||||||
_logger.LogInformation("IRC client {Id} ({Nick}) disconnected",
|
_logger.LogDebug("IRC client {Id} ({Nick}) disconnected",
|
||||||
connection.ConnectionId, connection.Nickname ?? "unregistered");
|
connection.ConnectionId, connection.Nickname ?? "unregistered");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,69 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.RegularExpressions;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.Contracts;
|
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Core.Security;
|
using EchoHub.Core.Security;
|
||||||
|
|
||||||
namespace EchoHub.Server.Irc;
|
namespace EchoHub.Server.Irc;
|
||||||
|
|
||||||
public static partial class IrcMessageFormatter
|
public static class IrcMessageFormatter
|
||||||
{
|
{
|
||||||
private const int MaxIrcLineContentBytes = 400;
|
private const int MaxIrcLineContentBytes = 400;
|
||||||
|
private const int MaxReplySnippetLength = 80;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Format a MessageDto as one or more IRC PRIVMSG lines.
|
/// Format a MessageDto as one or more IRC PRIVMSG lines.
|
||||||
|
/// Attachments are rendered as single link lines \u2014 the widely-supported IRC convention
|
||||||
|
/// (clients auto-preview or open plain http(s) URLs) \u2014 never as terminal color art.
|
||||||
|
/// <paramref name="publicBaseUrl"/> makes the links absolute so any IRC client can open them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<string> FormatMessage(MessageDto message)
|
public static List<string> FormatMessage(MessageDto message, string? publicBaseUrl = null)
|
||||||
{
|
{
|
||||||
var lines = new List<string>();
|
var lines = new List<string>();
|
||||||
var ircChannel = $"#{message.ChannelName}";
|
var ircChannel = $"#{message.ChannelName}";
|
||||||
var prefix = $":{message.SenderUsername}!{message.SenderUsername}@echohub";
|
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)
|
// Caption text first (may be empty when the message is attachments-only)
|
||||||
if (!string.IsNullOrEmpty(message.Content))
|
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
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
// 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 block per attachment
|
// One link line per attachment
|
||||||
if (message.Attachments is { Count: > 0 })
|
if (message.Attachments is { Count: > 0 })
|
||||||
{
|
{
|
||||||
foreach (var attachment in message.Attachments)
|
foreach (var attachment in message.Attachments)
|
||||||
{
|
{
|
||||||
switch (attachment.Kind)
|
var url = ToAbsoluteUrl(attachment.Url, publicBaseUrl);
|
||||||
|
var tag = attachment.Kind switch
|
||||||
{
|
{
|
||||||
case AttachmentKind.Image:
|
AttachmentKind.Image => $"[Image: {attachment.FileName}]",
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[Image: {attachment.FileName}] {attachment.Url}");
|
AttachmentKind.Audio => $"\u266a [Audio: {attachment.FileName}]",
|
||||||
if (attachment.AsciiPreview is not null && !IsCiphertext(attachment.AsciiPreview))
|
_ => $"[File: {attachment.FileName}]",
|
||||||
{
|
};
|
||||||
foreach (var line in attachment.AsciiPreview.Split('\n'))
|
lines.Add($"{prefix} PRIVMSG {ircChannel} :{tag} {url}");
|
||||||
{
|
|
||||||
var trimmed = line.TrimEnd('\r');
|
|
||||||
if (trimmed.Length > 0)
|
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{ColorTagsToAnsi(trimmed)}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case AttachmentKind.Audio:
|
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :\u266a [Audio: {attachment.FileName}] {attachment.Url}");
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :[File: {attachment.FileName}] {attachment.Url}");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,13 +78,37 @@ public static partial class IrcMessageFormatter
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// True when a preview is still encrypted — transport ($ENC$v1$) if a broadcast path
|
/// "> nick: snippet | " prefix for replies. Room ciphertext can't be rendered
|
||||||
/// forgot to decrypt it, or E2E room ciphertext ($RC1$) the server cannot decrypt.
|
/// (IRC can't join encrypted rooms anyway) and is shown as a placeholder.
|
||||||
/// Emitting it would flood IRC clients with a multi-KB base64 blob.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static bool IsCiphertext(string text) =>
|
private static string? FormatReplyPrefix(ReplyRefDto? replyTo)
|
||||||
text.StartsWith(IMessageEncryptionService.CiphertextPrefix, StringComparison.Ordinal)
|
{
|
||||||
|| text.StartsWith(RoomCrypto.CiphertextPrefix, StringComparison.Ordinal);
|
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.
|
||||||
|
/// </summary>
|
||||||
|
public static string ToAbsoluteUrl(string url, string? publicBaseUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(publicBaseUrl) || Uri.IsWellFormedUriString(url, UriKind.Absolute))
|
||||||
|
return url;
|
||||||
|
return $"{publicBaseUrl.TrimEnd('/')}/{url.TrimStart('/')}";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
|
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
|
||||||
@@ -104,35 +137,6 @@ public static partial class IrcMessageFormatter
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients.
|
|
||||||
/// Also passes through content that already uses ANSI codes unchanged.
|
|
||||||
/// </summary>
|
|
||||||
public static string ColorTagsToAnsi(string text)
|
|
||||||
{
|
|
||||||
if (!text.Contains('{'))
|
|
||||||
return text;
|
|
||||||
|
|
||||||
return ColorTagRegex().Replace(text, match =>
|
|
||||||
{
|
|
||||||
if (match.Groups[1].Success) // {X} reset
|
|
||||||
return "\x1b[0m";
|
|
||||||
if (match.Groups[2].Success) // {F:RRGGBB} or {B:RRGGBB}
|
|
||||||
{
|
|
||||||
var hex = match.Groups[3].Value;
|
|
||||||
var r = Convert.ToInt32(hex[..2], 16);
|
|
||||||
var g = Convert.ToInt32(hex[2..4], 16);
|
|
||||||
var b = Convert.ToInt32(hex[4..6], 16);
|
|
||||||
var code = match.Groups[2].Value == "F" ? "38" : "48";
|
|
||||||
return $"\x1b[{code};2;{r};{g};{b}m";
|
|
||||||
}
|
|
||||||
return match.Value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
|
|
||||||
private static partial Regex ColorTagRegex();
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
|
/// Split a message into chunks of approximately maxBytes (UTF-8), at word boundaries.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -12,4 +12,11 @@ public sealed class IrcOptions
|
|||||||
public string? TlsCertPassword { get; set; }
|
public string? TlsCertPassword { get; set; }
|
||||||
public string ServerName { get; set; } = "echohub";
|
public string ServerName { get; set; } = "echohub";
|
||||||
public string? Motd { get; set; }
|
public string? Motd { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Public HTTP(S) base of this EchoHub server (e.g. "https://chat.example.com"),
|
||||||
|
/// used to turn relative attachment URLs into absolute links IRC clients can open.
|
||||||
|
/// When unset, attachment lines fall back to the relative path.
|
||||||
|
/// </summary>
|
||||||
|
public string? PublicBaseUrl { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Live server-log room settings, bound from the "ServerLogs" config section (env override:
|
||||||
|
/// <c>ServerLogs__Enabled</c> etc.). When enabled, a read-only system channel is auto-created
|
||||||
|
/// and log events are streamed to it live — log lines are never stored as messages in the
|
||||||
|
/// database; the rolling Serilog log files remain the only persistence.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerLogsOptions
|
||||||
|
{
|
||||||
|
/// <summary>Master switch for the live log room.</summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Name of the auto-created system channel. Must satisfy the normal channel-name rules;
|
||||||
|
/// the name is reserved — users cannot create a channel with it.
|
||||||
|
/// </summary>
|
||||||
|
public string RoomName { get; set; } = "server-logs";
|
||||||
|
|
||||||
|
/// <summary>Minimum server role allowed to see and join the log room (Member/Mod/Admin/Owner).</summary>
|
||||||
|
public ServerRole MinRole { get; set; } = ServerRole.Mod;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Minimum level of log events streamed to the room (Verbose/Debug/Information/Warning/
|
||||||
|
/// Error/Fatal). Only affects the room — file and console sinks keep their own levels.
|
||||||
|
/// </summary>
|
||||||
|
public LogEventLevel MinLevel { get; set; } = LogEventLevel.Information;
|
||||||
|
|
||||||
|
/// <summary>How many recent log entries are replayed from the log file when someone opens the room.</summary>
|
||||||
|
public int BacklogLines { get; set; } = 100;
|
||||||
|
|
||||||
|
/// <summary>Directory holding the rolling log files. Must match the Serilog file sink's path.</summary>
|
||||||
|
public string LogDirectory { get; set; } = "logs";
|
||||||
|
|
||||||
|
/// <summary>Filename glob for the rolling log files inside <see cref="LogDirectory"/>.</summary>
|
||||||
|
public string LogFilePattern { get; set; } = "echohub-server-*.log";
|
||||||
|
|
||||||
|
/// <summary>Channel names are stored lowercased; compare against this form.</summary>
|
||||||
|
public string NormalizedRoomName => RoomName.ToLowerInvariant().Trim();
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
namespace EchoHub.Server.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Anti-spam thresholds, bound from the "Spam" config section. Defaults are lenient enough
|
||||||
|
/// that a fast typist never trips them; Mods and above are always exempt.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpamOptions
|
||||||
|
{
|
||||||
|
/// <summary>Master switch for all spam protection.</summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Max messages a user may send per <see cref="WindowSeconds"/> window.</summary>
|
||||||
|
public int MaxMessagesPerWindow { get; set; } = 8;
|
||||||
|
|
||||||
|
public int WindowSeconds { get; set; } = 5;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How many identical messages in a row are tolerated; the next one is rejected.
|
||||||
|
/// (End-to-end encrypted rooms are naturally exempt — identical plaintext produces
|
||||||
|
/// different ciphertext per message, so repeats never look identical to the server.)
|
||||||
|
/// </summary>
|
||||||
|
public int MaxDuplicateMessages { get; set; } = 3;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Auto-mute duration once a user accumulates <see cref="ViolationThreshold"/> rejected
|
||||||
|
/// sends within <see cref="ViolationWindowMinutes"/>. 0 disables auto-mute (rejections
|
||||||
|
/// still apply). The mute uses the normal timed-mute machinery, so moderators see it
|
||||||
|
/// and <c>MuteExpirationService</c> lifts it.
|
||||||
|
/// </summary>
|
||||||
|
public int AutoMuteMinutes { get; set; } = 5;
|
||||||
|
|
||||||
|
public int ViolationThreshold { get; set; } = 5;
|
||||||
|
|
||||||
|
public int ViolationWindowMinutes { get; set; } = 5;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Max *first-time* channel joins per <see cref="JoinWindowSeconds"/> window. Joins of
|
||||||
|
/// channels the user already belongs to (reconnect/auto-join) never count, but a brand-new
|
||||||
|
/// user's first connect joins every public channel at once — keep this above your public
|
||||||
|
/// channel count.
|
||||||
|
/// </summary>
|
||||||
|
public int MaxJoinsPerWindow { get; set; } = 25;
|
||||||
|
|
||||||
|
public int JoinWindowSeconds { get; set; } = 30;
|
||||||
|
|
||||||
|
/// <summary>Max channel creations per <see cref="ChannelCreateWindowMinutes"/> window.</summary>
|
||||||
|
public int MaxChannelCreatesPerWindow { get; set; } = 3;
|
||||||
|
|
||||||
|
public int ChannelCreateWindowMinutes { get; set; } = 10;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace EchoHub.Server.Config;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Periodic server-stats report settings, bound from the "Stats" config section (env override:
|
||||||
|
/// <c>Stats__Enabled</c> etc.). When enabled, a background job periodically snapshots server
|
||||||
|
/// activity, logs it as pretty-printed JSON, and persists it to the database.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StatsOptions
|
||||||
|
{
|
||||||
|
/// <summary>Master switch for the periodic stats report job.</summary>
|
||||||
|
public bool Enabled { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>How often a report is generated, in hours. Default: every 6 hours.</summary>
|
||||||
|
public double IntervalHours { get; set; } = 6;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How long persisted reports are kept before being pruned, in days. Set to 0 to keep
|
||||||
|
/// reports indefinitely. Default: 90 days.
|
||||||
|
/// </summary>
|
||||||
|
public int RetentionDays { get; set; } = 90;
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ public class AuthController : ControllerBase
|
|||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
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)
|
if (!result.IsSuccess)
|
||||||
return MapUserError(result);
|
return MapUserError(result);
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ public class ChannelsController : ControllerBase
|
|||||||
private readonly IChatService _chatService;
|
private readonly IChatService _chatService;
|
||||||
private readonly IMessageEncryptionService _encryption;
|
private readonly IMessageEncryptionService _encryption;
|
||||||
private readonly UploadLimits _uploadLimits;
|
private readonly UploadLimits _uploadLimits;
|
||||||
|
private readonly ILogger<ChannelsController> _logger;
|
||||||
|
|
||||||
public ChannelsController(
|
public ChannelsController(
|
||||||
IChannelService channelService,
|
IChannelService channelService,
|
||||||
@@ -38,7 +39,8 @@ public class ChannelsController : ControllerBase
|
|||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IChatService chatService,
|
IChatService chatService,
|
||||||
IMessageEncryptionService encryption,
|
IMessageEncryptionService encryption,
|
||||||
UploadLimits uploadLimits)
|
UploadLimits uploadLimits,
|
||||||
|
ILogger<ChannelsController> logger)
|
||||||
{
|
{
|
||||||
_channelService = channelService;
|
_channelService = channelService;
|
||||||
_db = db;
|
_db = db;
|
||||||
@@ -48,6 +50,7 @@ public class ChannelsController : ControllerBase
|
|||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
_uploadLimits = uploadLimits;
|
_uploadLimits = uploadLimits;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
@@ -162,6 +165,8 @@ public class ChannelsController : ControllerBase
|
|||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
return MapChannelError(result);
|
return MapChannelError(result);
|
||||||
|
|
||||||
|
await _chatService.BroadcastChannelDeletedAsync(channel.ToLowerInvariant().Trim());
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,6 +204,9 @@ public class ChannelsController : ControllerBase
|
|||||||
if (channelDto is null)
|
if (channelDto is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
|
if (channelDto.IsSystem)
|
||||||
|
return StatusCode(403, new ErrorResponse("This channel is read-only."));
|
||||||
|
|
||||||
if (!Request.HasFormContentType)
|
if (!Request.HasFormContentType)
|
||||||
return BadRequest(new ErrorResponse("Expected multipart form data."));
|
return BadRequest(new ErrorResponse("Expected multipart form data."));
|
||||||
|
|
||||||
@@ -284,6 +292,12 @@ public class ChannelsController : ControllerBase
|
|||||||
});
|
});
|
||||||
attachmentDtos.Add(new AttachmentDto(kind, url, file.FileName, file.Length,
|
attachmentDtos.Add(new AttachmentDto(kind, url, file.FileName, file.Length,
|
||||||
_encryption.EncryptNullable(previewPlain)));
|
_encryption.EncryptNullable(previewPlain)));
|
||||||
|
|
||||||
|
// Filename is client-encrypted ciphertext in E2E rooms — never log it there.
|
||||||
|
var loggedName = channelDto.IsEncrypted ? "[encrypted]" : file.FileName;
|
||||||
|
_logger.LogInformation(
|
||||||
|
"{User} uploaded {Kind} '{FileName}' ({Size} bytes) to '{Channel}': {Url}",
|
||||||
|
usernameClaim, kind, loggedName, file.Length, channelName, url);
|
||||||
}
|
}
|
||||||
|
|
||||||
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
|
||||||
@@ -342,6 +356,9 @@ public class ChannelsController : ControllerBase
|
|||||||
if (channelDto is null)
|
if (channelDto is null)
|
||||||
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
|
||||||
|
|
||||||
|
if (channelDto.IsSystem)
|
||||||
|
return StatusCode(403, new ErrorResponse("This channel is read-only."));
|
||||||
|
|
||||||
if (channelDto.IsEncrypted)
|
if (channelDto.IsEncrypted)
|
||||||
return BadRequest(new ErrorResponse(
|
return BadRequest(new ErrorResponse(
|
||||||
"Sending images by URL is not available in end-to-end encrypted channels — download the image and /send the file instead."));
|
"Sending images by URL is not available in end-to-end encrypted channels — download the image and /send the file instead."));
|
||||||
@@ -438,6 +455,10 @@ public class ChannelsController : ControllerBase
|
|||||||
_db.Messages.Add(message);
|
_db.Messages.Add(message);
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"{User} uploaded Image '{FileName}' ({Size} bytes) from URL to '{Channel}': {Url} (source: {Source})",
|
||||||
|
usernameClaim, fileName, imageBytes.Length, channelName, attachmentUrl, request.Url);
|
||||||
|
|
||||||
var messageDto = new MessageDto(
|
var messageDto = new MessageDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
_encryption.Encrypt(string.Empty),
|
_encryption.Encrypt(string.Empty),
|
||||||
|
|||||||
@@ -19,7 +19,14 @@ public class FilesController : ControllerBase
|
|||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serves an uploaded file. Anonymous by design: the unguessable GUID in the URL is the
|
||||||
|
/// access token (Discord-CDN-style capability URL), so attachment links can be opened
|
||||||
|
/// directly in a browser and shared to IRC clients. E2E-encrypted room blobs are
|
||||||
|
/// ciphertext at rest, so anonymous access reveals nothing for those channels.
|
||||||
|
/// </summary>
|
||||||
[HttpGet("{fileId}")]
|
[HttpGet("{fileId}")]
|
||||||
|
[AllowAnonymous]
|
||||||
public IActionResult GetFile(string fileId)
|
public IActionResult GetFile(string fileId)
|
||||||
{
|
{
|
||||||
if (!Guid.TryParse(fileId, out _))
|
if (!Guid.TryParse(fileId, out _))
|
||||||
@@ -48,6 +55,11 @@ public class FilesController : ControllerBase
|
|||||||
_ => "application/octet-stream"
|
_ => "application/octet-stream"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Images and audio render inline so a browser displays them instead of
|
||||||
|
// downloading; everything else keeps the attachment disposition.
|
||||||
|
if (contentType.StartsWith("image/") || contentType.StartsWith("audio/"))
|
||||||
|
return PhysicalFile(filePath, contentType);
|
||||||
|
|
||||||
var fileName = Path.GetFileName(filePath);
|
var fileName = Path.GetFileName(filePath);
|
||||||
return PhysicalFile(filePath, contentType, fileName);
|
return PhysicalFile(filePath, contentType, fileName);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ using EchoHub.Core.DTOs;
|
|||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
|
using EchoHub.Server.Services.Stats;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
@@ -22,19 +23,25 @@ public class ModerationController : ControllerBase
|
|||||||
private readonly PresenceTracker _presenceTracker;
|
private readonly PresenceTracker _presenceTracker;
|
||||||
private readonly FileStorageService _fileStorage;
|
private readonly FileStorageService _fileStorage;
|
||||||
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
|
private readonly ServerStatsCollector _statsCollector;
|
||||||
|
private readonly ILogger<ModerationController> _logger;
|
||||||
|
|
||||||
public ModerationController(
|
public ModerationController(
|
||||||
EchoHubDbContext db,
|
EchoHubDbContext db,
|
||||||
IChatService chatService,
|
IChatService chatService,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
FileStorageService fileStorage,
|
FileStorageService fileStorage,
|
||||||
IEnumerable<IChatBroadcaster> broadcasters)
|
IEnumerable<IChatBroadcaster> broadcasters,
|
||||||
|
ServerStatsCollector statsCollector,
|
||||||
|
ILogger<ModerationController> logger)
|
||||||
{
|
{
|
||||||
_db = db;
|
_db = db;
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
_broadcasters = broadcasters;
|
_broadcasters = broadcasters;
|
||||||
|
_statsCollector = statsCollector;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("role")]
|
[HttpPost("role")]
|
||||||
@@ -56,9 +63,14 @@ public class ModerationController : ControllerBase
|
|||||||
if (request.Role >= caller!.Role)
|
if (request.Role >= caller!.Role)
|
||||||
return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own."));
|
return BadRequest(new ErrorResponse("Cannot assign a role equal to or above your own."));
|
||||||
|
|
||||||
|
var previousRole = target.Role;
|
||||||
target.Role = request.Role;
|
target.Role = request.Role;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Role change: {Actor} set {Target} from {OldRole} to {NewRole}",
|
||||||
|
caller!.Username, target.Username, previousRole, request.Role);
|
||||||
|
|
||||||
return Ok(new { Message = $"{target.Username} is now {request.Role}." });
|
return Ok(new { Message = $"{target.Username} is now {request.Role}." });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +98,11 @@ public class ModerationController : ControllerBase
|
|||||||
var reason = request?.Reason ?? "You have been kicked from the server.";
|
var reason = request?.Reason ?? "You have been kicked from the server.";
|
||||||
await ForceDisconnectAndCleanupAsync(target.Username, reason);
|
await ForceDisconnectAndCleanupAsync(target.Username, reason);
|
||||||
|
|
||||||
|
_statsCollector.RecordKick();
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Kick: {Actor} kicked {Target} (reason: {Reason})",
|
||||||
|
caller!.Username, target.Username, request?.Reason ?? "none");
|
||||||
|
|
||||||
return Ok(new { Message = $"{target.Username} has been kicked." });
|
return Ok(new { Message = $"{target.Username} has been kicked." });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,13 +128,18 @@ public class ModerationController : ControllerBase
|
|||||||
var reason = request?.Reason ?? "You have been banned from this server.";
|
var reason = request?.Reason ?? "You have been banned from this server.";
|
||||||
await ForceDisconnectAndCleanupAsync(target.Username, reason);
|
await ForceDisconnectAndCleanupAsync(target.Username, reason);
|
||||||
|
|
||||||
|
_statsCollector.RecordBan();
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Ban: {Actor} banned {Target} (reason: {Reason})",
|
||||||
|
caller!.Username, target.Username, request?.Reason ?? "none");
|
||||||
|
|
||||||
return Ok(new { Message = $"{target.Username} has been banned." });
|
return Ok(new { Message = $"{target.Username} has been banned." });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("unban/{username}")]
|
[HttpPost("unban/{username}")]
|
||||||
public async Task<IActionResult> UnbanUser(string username)
|
public async Task<IActionResult> UnbanUser(string username)
|
||||||
{
|
{
|
||||||
var (_, error) = await GetCallerAsync(ServerRole.Admin);
|
var (caller, error) = await GetCallerAsync(ServerRole.Admin);
|
||||||
if (error is not null) return error;
|
if (error is not null) return error;
|
||||||
|
|
||||||
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
@@ -127,6 +149,8 @@ public class ModerationController : ControllerBase
|
|||||||
target.IsBanned = false;
|
target.IsBanned = false;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Unban: {Actor} unbanned {Target}", caller!.Username, target.Username);
|
||||||
|
|
||||||
return Ok(new { Message = $"{target.Username} has been unbanned." });
|
return Ok(new { Message = $"{target.Username} has been unbanned." });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +173,12 @@ public class ModerationController : ControllerBase
|
|||||||
: null;
|
: null;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Mute: {Actor} muted {Target} ({Duration}, reason: {Reason})",
|
||||||
|
caller!.Username, target.Username,
|
||||||
|
request?.DurationMinutes is > 0 ? $"{request.DurationMinutes}m" : "indefinite",
|
||||||
|
request?.Reason ?? "none");
|
||||||
|
|
||||||
var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : "";
|
var durationText = request?.DurationMinutes is > 0 ? $" for {request.DurationMinutes} minutes" : "";
|
||||||
return Ok(new { Message = $"{target.Username} has been muted{durationText}." });
|
return Ok(new { Message = $"{target.Username} has been muted{durationText}." });
|
||||||
}
|
}
|
||||||
@@ -156,7 +186,7 @@ public class ModerationController : ControllerBase
|
|||||||
[HttpPost("unmute/{username}")]
|
[HttpPost("unmute/{username}")]
|
||||||
public async Task<IActionResult> UnmuteUser(string username)
|
public async Task<IActionResult> UnmuteUser(string username)
|
||||||
{
|
{
|
||||||
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
if (error is not null) return error;
|
if (error is not null) return error;
|
||||||
|
|
||||||
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
var target = await _db.Users.FirstOrDefaultAsync(u => u.Username == username.ToLowerInvariant());
|
||||||
@@ -167,6 +197,8 @@ public class ModerationController : ControllerBase
|
|||||||
target.MutedUntil = null;
|
target.MutedUntil = null;
|
||||||
await _db.SaveChangesAsync();
|
await _db.SaveChangesAsync();
|
||||||
|
|
||||||
|
_logger.LogInformation("Unmute: {Actor} unmuted {Target}", caller!.Username, target.Username);
|
||||||
|
|
||||||
return Ok(new { Message = $"{target.Username} has been unmuted." });
|
return Ok(new { Message = $"{target.Username} has been unmuted." });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,13 +251,19 @@ public class ModerationController : ControllerBase
|
|||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId));
|
await BroadcastToAllAsync(b => b.SendMessageDeletedAsync(channelName, messageId));
|
||||||
|
|
||||||
|
// Only moderator removals of another user's message are noteworthy; self-deletes are routine.
|
||||||
|
if (!isOwnMessage)
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Message removed: {Actor} deleted {Author}'s message {MessageId} in '{Channel}'",
|
||||||
|
caller.Username, message.SenderUsername, messageId, channelName);
|
||||||
|
|
||||||
return Ok(new { Message = "Message deleted." });
|
return Ok(new { Message = "Message deleted." });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("channels/{channel}/nuke")]
|
[HttpDelete("channels/{channel}/nuke")]
|
||||||
public async Task<IActionResult> NukeChannel(string channel)
|
public async Task<IActionResult> NukeChannel(string channel)
|
||||||
{
|
{
|
||||||
var (_, error) = await GetCallerAsync(ServerRole.Mod);
|
var (caller, error) = await GetCallerAsync(ServerRole.Mod);
|
||||||
if (error is not null) return error;
|
if (error is not null) return error;
|
||||||
|
|
||||||
var channelName = channel.ToLowerInvariant().Trim();
|
var channelName = channel.ToLowerInvariant().Trim();
|
||||||
@@ -251,6 +289,10 @@ public class ModerationController : ControllerBase
|
|||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName));
|
await BroadcastToAllAsync(b => b.SendChannelNukedAsync(channelName));
|
||||||
|
|
||||||
|
_logger.LogWarning(
|
||||||
|
"Channel nuked: {Actor} cleared {Count} messages from '{Channel}'",
|
||||||
|
caller!.Username, messages.Count, channelName);
|
||||||
|
|
||||||
return Ok(new { Message = $"All messages in #{channelName} have been cleared." });
|
return Ok(new { Message = $"All messages in #{channelName} have been cleared." });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,11 +31,19 @@ public class ServerController : ControllerBase
|
|||||||
var userCount = await _db.Users.CountAsync();
|
var userCount = await _db.Users.CountAsync();
|
||||||
var channelCount = await _db.Channels.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(
|
var status = new ServerStatusDto(
|
||||||
_config["Server:Name"] ?? "EchoHub Server",
|
_config["Server:Name"] ?? "EchoHub Server",
|
||||||
_config["Server:Description"],
|
_config["Server:Description"],
|
||||||
userCount,
|
userCount,
|
||||||
channelCount);
|
channelCount,
|
||||||
|
registrationMode);
|
||||||
|
|
||||||
return Ok(status);
|
return Ok(status);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Core.Services;
|
using EchoHub.Core.Services;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Server.Config;
|
using EchoHub.Server.Config;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace EchoHub.Server.Controllers;
|
namespace EchoHub.Server.Controllers;
|
||||||
|
|
||||||
@@ -17,15 +20,45 @@ namespace EchoHub.Server.Controllers;
|
|||||||
[EnableRateLimiting("general")]
|
[EnableRateLimiting("general")]
|
||||||
public class UsersController : ControllerBase
|
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 IUserService _userService;
|
||||||
private readonly ImageToAsciiService _asciiService;
|
private readonly ImageToAsciiService _asciiService;
|
||||||
private readonly UploadLimits _uploadLimits;
|
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;
|
_userService = userService;
|
||||||
_asciiService = asciiService;
|
_asciiService = asciiService;
|
||||||
_uploadLimits = uploadLimits;
|
_uploadLimits = uploadLimits;
|
||||||
|
_db = db;
|
||||||
|
_encryption = encryption;
|
||||||
|
_fileStorage = fileStorage;
|
||||||
|
_presenceTracker = presenceTracker;
|
||||||
|
_broadcasters = broadcasters;
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{username}/profile")]
|
[HttpGet("{username}/profile")]
|
||||||
@@ -85,6 +118,139 @@ public class UsersController : ControllerBase
|
|||||||
return Ok(new AvatarUploadResponse(asciiArt));
|
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
|
private IActionResult MapUserError(UserOperationResult result) => result.Error switch
|
||||||
{
|
{
|
||||||
UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
|
UserError.ValidationFailed => BadRequest(new ErrorResponse(result.ErrorMessage!)),
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ public class EchoHubDbContext : DbContext
|
|||||||
public DbSet<Attachment> Attachments => Set<Attachment>();
|
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||||
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
|
||||||
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
|
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
|
||||||
|
public DbSet<InviteCode> InviteCodes => Set<InviteCode>();
|
||||||
|
public DbSet<ServerStatsReport> ServerStatsReports => Set<ServerStatsReport>();
|
||||||
|
|
||||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||||
{
|
{
|
||||||
@@ -98,6 +100,20 @@ public class EchoHubDbContext : DbContext
|
|||||||
.OnDelete(DeleteBehavior.Cascade);
|
.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<ServerStatsReport>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasKey(r => r.Id);
|
||||||
|
entity.HasIndex(r => r.GeneratedAt);
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<RefreshToken>(entity =>
|
modelBuilder.Entity<RefreshToken>(entity =>
|
||||||
{
|
{
|
||||||
entity.HasKey(r => r.Id);
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+376
@@ -0,0 +1,376 @@
|
|||||||
|
// <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("20260717182450_AddChannelIsSystem")]
|
||||||
|
partial class AddChannelIsSystem
|
||||||
|
{
|
||||||
|
/// <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<bool>("IsSystem")
|
||||||
|
.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,29 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddChannelIsSystem : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsSystem",
|
||||||
|
table: "Channels",
|
||||||
|
type: "INTEGER",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsSystem",
|
||||||
|
table: "Channels");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+437
@@ -0,0 +1,437 @@
|
|||||||
|
// <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("20260717200332_AddServerStatsReports")]
|
||||||
|
partial class AddServerStatsReports
|
||||||
|
{
|
||||||
|
/// <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<bool>("IsSystem")
|
||||||
|
.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.ServerStatsReport", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("ActiveMembers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Bans")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("BytesUploaded")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Connections")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Disconnections")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("FilesUploaded")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("GeneratedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Kicks")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MessagesSent")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("NewMembers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("OnlineNow")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PeakOnline")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("PeriodEnd")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("PeriodStart")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TotalMembers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<double>("WindowHours")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GeneratedAt");
|
||||||
|
|
||||||
|
b.ToTable("ServerStatsReports");
|
||||||
|
});
|
||||||
|
|
||||||
|
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,54 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddServerStatsReports : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "ServerStatsReports",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||||
|
GeneratedAt = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
PeriodStart = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
PeriodEnd = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
WindowHours = table.Column<double>(type: "REAL", nullable: false),
|
||||||
|
MessagesSent = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
FilesUploaded = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
BytesUploaded = table.Column<long>(type: "INTEGER", nullable: false),
|
||||||
|
NewMembers = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
ActiveMembers = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Connections = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Disconnections = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Kicks = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
Bans = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
TotalMembers = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
OnlineNow = table.Column<int>(type: "INTEGER", nullable: false),
|
||||||
|
PeakOnline = table.Column<int>(type: "INTEGER", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_ServerStatsReports", x => x.Id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_ServerStatsReports_GeneratedAt",
|
||||||
|
table: "ServerStatsReports",
|
||||||
|
column: "GeneratedAt");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "ServerStatsReports");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -72,6 +72,9 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.Property<bool>("IsPublic")
|
b.Property<bool>("IsPublic")
|
||||||
.HasColumnType("INTEGER");
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSystem")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
@@ -117,6 +120,45 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.ToTable("ChannelMemberships");
|
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 =>
|
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -146,6 +188,9 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
.HasMaxLength(32000)
|
.HasMaxLength(32000)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ReplyToMessageId")
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<Guid>("SenderUserId")
|
b.Property<Guid>("SenderUserId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
@@ -201,6 +246,67 @@ namespace EchoHub.Server.Data.Migrations
|
|||||||
b.ToTable("RefreshTokens");
|
b.ToTable("RefreshTokens");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("EchoHub.Core.Models.ServerStatsReport", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<int>("ActiveMembers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Bans")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("BytesUploaded")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Connections")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Disconnections")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("FilesUploaded")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("GeneratedAt")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("Kicks")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("MessagesSent")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("NewMembers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("OnlineNow")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("PeakOnline")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("PeriodEnd")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<long>("PeriodStart")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<int>("TotalMembers")
|
||||||
|
.HasColumnType("INTEGER");
|
||||||
|
|
||||||
|
b.Property<double>("WindowHours")
|
||||||
|
.HasColumnType("REAL");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("GeneratedAt");
|
||||||
|
|
||||||
|
b.ToTable("ServerStatsReports");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
|
|||||||
@@ -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
|
try
|
||||||
{
|
{
|
||||||
var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content);
|
var error = await _chatService.SendMessageAsync(CurrentUserId, CurrentUsername, channelName, content, Context.ConnectionId, replyToMessageId);
|
||||||
if (error is not null)
|
if (error is not null)
|
||||||
await Clients.Caller.Error(error);
|
await Clients.Caller.Error(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ using EchoHub.Server.Data;
|
|||||||
using EchoHub.Server.Hubs;
|
using EchoHub.Server.Hubs;
|
||||||
using EchoHub.Server.Irc;
|
using EchoHub.Server.Irc;
|
||||||
using EchoHub.Server.Services;
|
using EchoHub.Server.Services;
|
||||||
|
using EchoHub.Server.Services.ServerLogs;
|
||||||
|
using EchoHub.Server.Services.Stats;
|
||||||
using EchoHub.Server.Setup;
|
using EchoHub.Server.Setup;
|
||||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||||
using Microsoft.AspNetCore.RateLimiting;
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
@@ -41,9 +43,23 @@ while (true)
|
|||||||
builder.Services.Configure<HostOptions>(options =>
|
builder.Services.Configure<HostOptions>(options =>
|
||||||
options.ShutdownTimeout = TimeSpan.FromSeconds(5));
|
options.ShutdownTimeout = TimeSpan.FromSeconds(5));
|
||||||
|
|
||||||
|
// ── Server log room (admin-configurable via the "ServerLogs" section) ─
|
||||||
|
// Bound before Serilog so the live-log sink can be wired into the pipeline. The sink
|
||||||
|
// is a singleton shared between Serilog (producer) and the stream service (consumer).
|
||||||
|
var serverLogsOptions = builder.Configuration.GetSection("ServerLogs").Get<ServerLogsOptions>() ?? new ServerLogsOptions();
|
||||||
|
builder.Services.AddSingleton(serverLogsOptions);
|
||||||
|
builder.Services.AddSingleton<ServerLogsService>();
|
||||||
|
var serverLogsSink = serverLogsOptions.Enabled ? new ServerLogsSink(serverLogsOptions) : null;
|
||||||
|
if (serverLogsSink is not null)
|
||||||
|
builder.Services.AddSingleton(serverLogsSink);
|
||||||
|
|
||||||
// ── Serilog ──────────────────────────────────────────────────────────
|
// ── Serilog ──────────────────────────────────────────────────────────
|
||||||
builder.Host.UseSerilog((context, config) =>
|
builder.Host.UseSerilog((context, config) =>
|
||||||
config.ReadFrom.Configuration(context.Configuration));
|
{
|
||||||
|
config.ReadFrom.Configuration(context.Configuration);
|
||||||
|
if (serverLogsSink is not null)
|
||||||
|
config.WriteTo.Sink(serverLogsSink);
|
||||||
|
});
|
||||||
|
|
||||||
// ── SQLite + EF Core ─────────────────────────────────────────────────
|
// ── SQLite + EF Core ─────────────────────────────────────────────────
|
||||||
var defaultDbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db");
|
var defaultDbPath = Path.Combine(AppContext.BaseDirectory, "echohub.db");
|
||||||
@@ -106,6 +122,11 @@ while (true)
|
|||||||
// ── Upload limits (admin-configurable via the "Uploads" section) ─────
|
// ── Upload limits (admin-configurable via the "Uploads" section) ─────
|
||||||
var uploadLimits = builder.Configuration.GetSection("Uploads").Get<UploadLimits>() ?? new UploadLimits();
|
var uploadLimits = builder.Configuration.GetSection("Uploads").Get<UploadLimits>() ?? new UploadLimits();
|
||||||
builder.Services.AddSingleton(uploadLimits);
|
builder.Services.AddSingleton(uploadLimits);
|
||||||
|
|
||||||
|
// ── Spam protection (admin-configurable via the "Spam" section) ──────
|
||||||
|
var spamOptions = builder.Configuration.GetSection("Spam").Get<SpamOptions>() ?? new SpamOptions();
|
||||||
|
builder.Services.AddSingleton(spamOptions);
|
||||||
|
builder.Services.AddSingleton<SpamGuard>();
|
||||||
// Raise the multipart form ceiling to match the configured limits; per-endpoint
|
// Raise the multipart form ceiling to match the configured limits; per-endpoint
|
||||||
// request-body limits are applied at the action from the same values.
|
// request-body limits are applied at the action from the same values.
|
||||||
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
|
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
|
||||||
@@ -122,6 +143,17 @@ while (true)
|
|||||||
builder.Services.AddHostedService<FileCleanupService>();
|
builder.Services.AddHostedService<FileCleanupService>();
|
||||||
builder.Services.AddHostedService<MuteExpirationService>();
|
builder.Services.AddHostedService<MuteExpirationService>();
|
||||||
|
|
||||||
|
// ── Periodic stats report (admin-configurable via the "Stats" section) ─
|
||||||
|
var statsOptions = builder.Configuration.GetSection("Stats").Get<StatsOptions>() ?? new StatsOptions();
|
||||||
|
builder.Services.AddSingleton(statsOptions);
|
||||||
|
builder.Services.AddSingleton<ServerStatsCollector>();
|
||||||
|
if (statsOptions.Enabled)
|
||||||
|
builder.Services.AddHostedService<ServerStatsReportService>();
|
||||||
|
|
||||||
|
// Live server-log streaming (only when the sink is active)
|
||||||
|
if (serverLogsSink is not null)
|
||||||
|
builder.Services.AddHostedService<ServerLogsStreamService>();
|
||||||
|
|
||||||
// ── Encryption ─────────────────────────────────────────────────────
|
// ── Encryption ─────────────────────────────────────────────────────
|
||||||
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
|
builder.Services.AddSingleton<IMessageEncryptionService, MessageEncryptionService>();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using EchoHub.Core.Contracts;
|
|||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
|
using EchoHub.Server.Services.ServerLogs;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -13,15 +14,21 @@ public class ChannelService : IChannelService
|
|||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly PresenceTracker _presenceTracker;
|
private readonly PresenceTracker _presenceTracker;
|
||||||
|
private readonly SpamGuard _spamGuard;
|
||||||
|
private readonly ServerLogsService _serverLogs;
|
||||||
private readonly ILogger<ChannelService> _logger;
|
private readonly ILogger<ChannelService> _logger;
|
||||||
|
|
||||||
public ChannelService(
|
public ChannelService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
|
SpamGuard spamGuard,
|
||||||
|
ServerLogsService serverLogs,
|
||||||
ILogger<ChannelService> logger)
|
ILogger<ChannelService> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
|
_spamGuard = spamGuard;
|
||||||
|
_serverLogs = serverLogs;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,17 +39,24 @@ public class ChannelService : IChannelService
|
|||||||
|
|
||||||
await EnsureDefaultChannelAsync(db);
|
await EnsureDefaultChannelAsync(db);
|
||||||
|
|
||||||
var query = db.Channels.Where(c =>
|
// System channels (the live log room) are visible only to the configured roles,
|
||||||
c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
|
// regardless of membership; the room sorts above everything else.
|
||||||
|
var caller = await db.Users.FindAsync(userId);
|
||||||
|
var canViewSystem = _serverLogs.CanView(caller?.Role ?? ServerRole.Member);
|
||||||
|
|
||||||
|
var query = db.Channels.Where(c => c.IsSystem
|
||||||
|
? canViewSystem
|
||||||
|
: c.IsPublic || db.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
|
||||||
var total = await query.CountAsync();
|
var total = await query.CountAsync();
|
||||||
|
|
||||||
var channels = await query
|
var channels = await query
|
||||||
.OrderBy(c => c.Name)
|
.OrderByDescending(c => c.IsSystem)
|
||||||
|
.ThenBy(c => c.Name)
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(limit)
|
.Take(limit)
|
||||||
.Select(c => new ChannelDto(
|
.Select(c => new ChannelDto(
|
||||||
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt,
|
c.Id, c.Name, c.Topic, c.IsPublic, c.Messages.Count, c.CreatedAt,
|
||||||
c.PasswordHash != null, c.WrappedRoomKey != null))
|
c.PasswordHash != null, c.WrappedRoomKey != null, c.IsSystem))
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
return new PaginatedResponse<ChannelDto>(channels, total, offset, limit);
|
return new PaginatedResponse<ChannelDto>(channels, total, offset, limit);
|
||||||
@@ -61,6 +75,12 @@ public class ChannelService : IChannelService
|
|||||||
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
||||||
"Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.");
|
"Channel name must be 2-100 characters and contain only letters, digits, underscores, or hyphens.");
|
||||||
|
|
||||||
|
// The log room's name is reserved even while the feature is disabled, so enabling it
|
||||||
|
// later never turns a user-owned channel into the stream target.
|
||||||
|
if (channelName == _serverLogs.Options.NormalizedRoomName)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
||||||
|
$"Channel name '{channelName}' is reserved.");
|
||||||
|
|
||||||
var passwordError = ValidateChannelPassword(ref password);
|
var passwordError = ValidateChannelPassword(ref password);
|
||||||
if (passwordError is not null)
|
if (passwordError is not null)
|
||||||
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError);
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed, passwordError);
|
||||||
@@ -74,6 +94,16 @@ public class ChannelService : IChannelService
|
|||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
// Channel-creation throttle (spam guard; Mods and above are exempt)
|
||||||
|
if (_spamGuard.Enabled)
|
||||||
|
{
|
||||||
|
var creator = await db.Users.FindAsync(creatorUserId);
|
||||||
|
var verdict = _spamGuard.CheckChannelCreate(creatorUserId, creator?.Role ?? ServerRole.Member);
|
||||||
|
if (verdict.Kind != SpamVerdictKind.Allowed)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.ValidationFailed,
|
||||||
|
verdict.Reason ?? "You're creating channels too fast.");
|
||||||
|
}
|
||||||
|
|
||||||
if (await db.Channels.AnyAsync(c => c.Name == channelName))
|
if (await db.Channels.AnyAsync(c => c.Name == channelName))
|
||||||
return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists.");
|
return ChannelOperationResult.Fail(ChannelError.AlreadyExists, $"Channel '{channelName}' already exists.");
|
||||||
|
|
||||||
@@ -153,6 +183,10 @@ public class ChannelService : IChannelService
|
|||||||
if (dbChannel is null)
|
if (dbChannel is null)
|
||||||
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
|
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
|
||||||
|
|
||||||
|
if (dbChannel.IsSystem)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.Protected,
|
||||||
|
"System channels cannot be password protected.");
|
||||||
|
|
||||||
if (dbChannel.WrappedRoomKey is not null)
|
if (dbChannel.WrappedRoomKey is not null)
|
||||||
return ChannelOperationResult.Fail(ChannelError.Protected,
|
return ChannelOperationResult.Fail(ChannelError.Protected,
|
||||||
"This channel is end-to-end encrypted — change its passphrase from the EchoHub client (/passwd).");
|
"This channel is end-to-end encrypted — change its passphrase from the EchoHub client (/passwd).");
|
||||||
@@ -234,6 +268,10 @@ public class ChannelService : IChannelService
|
|||||||
if (dbChannel is null)
|
if (dbChannel is null)
|
||||||
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
|
return ChannelOperationResult.Fail(ChannelError.NotFound, $"Channel '{channelName}' does not exist.");
|
||||||
|
|
||||||
|
if (dbChannel.IsSystem)
|
||||||
|
return ChannelOperationResult.Fail(ChannelError.Protected,
|
||||||
|
"System channels cannot be deleted.");
|
||||||
|
|
||||||
var caller = await db.Users.FindAsync(callerUserId);
|
var caller = await db.Users.FindAsync(callerUserId);
|
||||||
if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin))
|
if (dbChannel.CreatedByUserId != callerUserId && (caller is null || caller.Role < ServerRole.Admin))
|
||||||
return ChannelOperationResult.Fail(ChannelError.Forbidden,
|
return ChannelOperationResult.Fail(ChannelError.Forbidden,
|
||||||
@@ -285,7 +323,7 @@ public class ChannelService : IChannelService
|
|||||||
|
|
||||||
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
|
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
|
||||||
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt,
|
return new ChannelDto(c.Id, c.Name, c.Topic, c.IsPublic, messageCount, c.CreatedAt,
|
||||||
c.PasswordHash != null, c.WrappedRoomKey != null);
|
c.PasswordHash != null, c.WrappedRoomKey != null, c.IsSystem);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
|
public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
|
||||||
@@ -359,6 +397,16 @@ public class ChannelService : IChannelService
|
|||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
// The live log room is role-gated: only the configured roles may join, no matter
|
||||||
|
// how the join arrives (TUI, REST, IRC).
|
||||||
|
var isLogsChannel = _serverLogs.IsLogsChannel(channelName);
|
||||||
|
if (isLogsChannel)
|
||||||
|
{
|
||||||
|
var joiner = await db.Users.FindAsync(userId);
|
||||||
|
if (!_serverLogs.CanView(joiner?.Role ?? ServerRole.Member))
|
||||||
|
return (false, $"Channel '{channelName}' is restricted to server staff.", false);
|
||||||
|
}
|
||||||
|
|
||||||
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
if (channel is null)
|
if (channel is null)
|
||||||
{
|
{
|
||||||
@@ -376,11 +424,31 @@ public class ChannelService : IChannelService
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
_logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel);
|
_logger.LogWarning("Default channel '{Channel}' was missing and has been recreated", HubConstants.DefaultChannel);
|
||||||
}
|
}
|
||||||
|
else if (isLogsChannel)
|
||||||
|
{
|
||||||
|
channel = new Channel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = channelName,
|
||||||
|
Topic = ServerLogsService.RoomTopic,
|
||||||
|
IsPublic = false,
|
||||||
|
IsSystem = true,
|
||||||
|
CreatedByUserId = Guid.Empty,
|
||||||
|
};
|
||||||
|
db.Channels.Add(channel);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
_logger.LogWarning("Log channel '{Channel}' was missing and has been recreated", channelName);
|
||||||
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.", false);
|
return (false, $"Channel '{channelName}' does not exist. Create it first via the channel list.", false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (channel.IsSystem && !isLogsChannel)
|
||||||
|
{
|
||||||
|
// A system channel left behind while its feature is disabled stays inaccessible.
|
||||||
|
return (false, $"Channel '{channelName}' is not available.", false);
|
||||||
|
}
|
||||||
|
|
||||||
var hasMembership = await db.ChannelMemberships
|
var hasMembership = await db.ChannelMemberships
|
||||||
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
|
.AnyAsync(m => m.UserId == userId && m.ChannelId == channel.Id);
|
||||||
@@ -429,6 +497,44 @@ public class ChannelService : IChannelService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ChannelDto> EnsureSystemChannelAsync(string channelName, string? topic = null)
|
||||||
|
{
|
||||||
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var channel = await db.Channels.FirstOrDefaultAsync(c => c.Name == channelName);
|
||||||
|
if (channel is null)
|
||||||
|
{
|
||||||
|
channel = new Channel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = channelName,
|
||||||
|
Topic = topic,
|
||||||
|
IsPublic = false,
|
||||||
|
IsSystem = true,
|
||||||
|
CreatedByUserId = Guid.Empty,
|
||||||
|
};
|
||||||
|
db.Channels.Add(channel);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
_logger.LogInformation("System channel '{Channel}' created", channelName);
|
||||||
|
}
|
||||||
|
else if (!channel.IsSystem)
|
||||||
|
{
|
||||||
|
// A regular channel squatting on the system name (created while the feature was
|
||||||
|
// off) is claimed, so server content never streams into a user-owned room.
|
||||||
|
channel.IsSystem = true;
|
||||||
|
channel.IsPublic = false;
|
||||||
|
channel.PasswordHash = null;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
_logger.LogWarning("Existing channel '{Channel}' was claimed as a system channel", channelName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ChannelDto(channel.Id, channel.Name, channel.Topic, channel.IsPublic, 0, channel.CreatedAt,
|
||||||
|
false, channel.WrappedRoomKey != null, true);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
|
private static async Task EnsureDefaultChannelAsync(EchoHubDbContext db)
|
||||||
{
|
{
|
||||||
if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
|
if (!await db.Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel))
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ using EchoHub.Core.Constants;
|
|||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
using EchoHub.Core.Models;
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Core.Security;
|
||||||
using EchoHub.Server.Data;
|
using EchoHub.Server.Data;
|
||||||
|
using EchoHub.Server.Services.ServerLogs;
|
||||||
|
using EchoHub.Server.Services.Stats;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -19,6 +22,9 @@ public class ChatService : IChatService
|
|||||||
private readonly IMessageEncryptionService _encryption;
|
private readonly IMessageEncryptionService _encryption;
|
||||||
private readonly IChannelService _channelService;
|
private readonly IChannelService _channelService;
|
||||||
private readonly FileStorageService _fileStorage;
|
private readonly FileStorageService _fileStorage;
|
||||||
|
private readonly SpamGuard _spamGuard;
|
||||||
|
private readonly ServerLogsService _serverLogs;
|
||||||
|
private readonly ServerStatsCollector _statsCollector;
|
||||||
private readonly ILogger<ChatService> _logger;
|
private readonly ILogger<ChatService> _logger;
|
||||||
|
|
||||||
public ChatService(
|
public ChatService(
|
||||||
@@ -29,6 +35,9 @@ public class ChatService : IChatService
|
|||||||
IMessageEncryptionService encryption,
|
IMessageEncryptionService encryption,
|
||||||
IChannelService channelService,
|
IChannelService channelService,
|
||||||
FileStorageService fileStorage,
|
FileStorageService fileStorage,
|
||||||
|
SpamGuard spamGuard,
|
||||||
|
ServerLogsService serverLogs,
|
||||||
|
ServerStatsCollector statsCollector,
|
||||||
ILogger<ChatService> logger)
|
ILogger<ChatService> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
@@ -38,12 +47,16 @@ public class ChatService : IChatService
|
|||||||
_encryption = encryption;
|
_encryption = encryption;
|
||||||
_channelService = channelService;
|
_channelService = channelService;
|
||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
|
_spamGuard = spamGuard;
|
||||||
|
_serverLogs = serverLogs;
|
||||||
|
_statsCollector = statsCollector;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
|
public async Task UserConnectedAsync(string connectionId, Guid userId, string username)
|
||||||
{
|
{
|
||||||
_presenceTracker.UserConnected(connectionId, userId, username);
|
_presenceTracker.UserConnected(connectionId, userId, username);
|
||||||
|
_statsCollector.RecordConnection(_presenceTracker.GetOnlineUserCount());
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
@@ -56,7 +69,9 @@ public class ChatService : IChatService
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
|
// Debug-level: connect/disconnect churn is high-volume on a busy server. Aggregate
|
||||||
|
// counts land in the periodic stats report instead.
|
||||||
|
_logger.LogDebug("{User} connected (ConnectionId: {ConnectionId})", username, connectionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> UserDisconnectedAsync(string connectionId)
|
public async Task<string?> UserDisconnectedAsync(string connectionId)
|
||||||
@@ -67,6 +82,7 @@ public class ChatService : IChatService
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
var username = _presenceTracker.UserDisconnected(connectionId);
|
var username = _presenceTracker.UserDisconnected(connectionId);
|
||||||
|
_statsCollector.RecordDisconnection(_presenceTracker.GetOnlineUserCount());
|
||||||
|
|
||||||
if (username is not null && !_presenceTracker.IsOnline(username))
|
if (username is not null && !_presenceTracker.IsOnline(username))
|
||||||
{
|
{
|
||||||
@@ -92,7 +108,7 @@ public class ChatService : IChatService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
|
_logger.LogDebug("{User} disconnected (ConnectionId: {ConnectionId})", username ?? "Unknown", connectionId);
|
||||||
return username;
|
return username;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +117,27 @@ public class ChatService : IChatService
|
|||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
|
// Join throttle — stops channel-cycling spam from any protocol. Only *first-time*
|
||||||
|
// joins (no membership yet) count: the client auto-joins every known channel on
|
||||||
|
// connect/reconnect, and that burst must never trip the guard.
|
||||||
|
if (_spamGuard.Enabled)
|
||||||
|
{
|
||||||
|
using var guardScope = _scopeFactory.CreateScope();
|
||||||
|
var guardDb = guardScope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var isExistingMember = await guardDb.Channels
|
||||||
|
.Where(c => c.Name == channelName)
|
||||||
|
.AnyAsync(c => guardDb.ChannelMemberships.Any(m => m.ChannelId == c.Id && m.UserId == userId));
|
||||||
|
|
||||||
|
if (!isExistingMember)
|
||||||
|
{
|
||||||
|
var joiner = await guardDb.Users.FindAsync(userId);
|
||||||
|
var joinVerdict = _spamGuard.CheckJoin(userId, joiner?.Role ?? ServerRole.Member);
|
||||||
|
if (joinVerdict.Kind != SpamVerdictKind.Allowed)
|
||||||
|
return ([], joinVerdict.Reason, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Delegate channel validation + membership (incl. password gate) to ChannelService
|
// Delegate channel validation + membership (incl. password gate) to ChannelService
|
||||||
var (success, error, passwordRequired) = await _channelService.EnsureChannelMembershipAsync(userId, channelName, password);
|
var (success, error, passwordRequired) = await _channelService.EnsureChannelMembershipAsync(userId, channelName, password);
|
||||||
if (!success)
|
if (!success)
|
||||||
@@ -136,7 +173,7 @@ public class ChatService : IChatService
|
|||||||
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId));
|
await BroadcastToAllAsync(b => b.SendUserJoinedAsync(channelName, username, presence, connectionId));
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("{User} joined channel '{Channel}'", username, channelName);
|
_logger.LogDebug("{User} joined channel '{Channel}'", username, channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
|
var history = await GetChannelHistoryAsync(channelName, HubConstants.DefaultHistoryCount);
|
||||||
@@ -148,16 +185,21 @@ public class ChatService : IChatService
|
|||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
_presenceTracker.LeaveChannel(username, channelName);
|
_presenceTracker.LeaveChannel(username, channelName);
|
||||||
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username));
|
await BroadcastToAllAsync(b => b.SendUserLeftAsync(channelName, username));
|
||||||
_logger.LogInformation("{User} left channel '{Channel}'", username, channelName);
|
_logger.LogDebug("{User} left channel '{Channel}'", username, channelName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
|
public async Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
|
||||||
{
|
{
|
||||||
channelName = channelName.ToLowerInvariant().Trim();
|
channelName = channelName.ToLowerInvariant().Trim();
|
||||||
|
|
||||||
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
if (!ValidationConstants.ChannelNameRegex().IsMatch(channelName))
|
||||||
return "Invalid channel name.";
|
return "Invalid channel name.";
|
||||||
|
|
||||||
|
// The live log room is read-only for everyone, every role. Reject by name before any
|
||||||
|
// work so a streamed log line can never provoke a DB write or another log event.
|
||||||
|
if (_serverLogs.IsLogsChannel(channelName))
|
||||||
|
return "This channel is read-only.";
|
||||||
|
|
||||||
// Decrypt content (client sends encrypted; IRC sends plaintext — Decrypt handles both)
|
// Decrypt content (client sends encrypted; IRC sends plaintext — Decrypt handles both)
|
||||||
var plaintext = _encryption.Decrypt(content);
|
var plaintext = _encryption.Decrypt(content);
|
||||||
|
|
||||||
@@ -181,6 +223,9 @@ public class ChatService : IChatService
|
|||||||
if (channel is null)
|
if (channel is null)
|
||||||
return $"Channel '{channelName}' does not exist.";
|
return $"Channel '{channelName}' does not exist.";
|
||||||
|
|
||||||
|
if (channel.IsSystem)
|
||||||
|
return "This channel is read-only.";
|
||||||
|
|
||||||
var sender = await db.Users.FindAsync(userId);
|
var sender = await db.Users.FindAsync(userId);
|
||||||
|
|
||||||
// Check mute status
|
// Check mute status
|
||||||
@@ -198,6 +243,36 @@ public class ChatService : IChatService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spam guard — operates on the stored content (ciphertext for E2E rooms), never decrypts
|
||||||
|
if (sender is not null)
|
||||||
|
{
|
||||||
|
var verdict = _spamGuard.CheckMessage(userId, sender.Role, plaintext);
|
||||||
|
switch (verdict.Kind)
|
||||||
|
{
|
||||||
|
case SpamVerdictKind.AutoMute:
|
||||||
|
// Escalate through the normal timed-mute machinery: moderation endpoints
|
||||||
|
// list it and MuteExpirationService lifts it. Issued by the server itself.
|
||||||
|
sender.IsMuted = true;
|
||||||
|
sender.MutedUntil = DateTimeOffset.UtcNow.Add(verdict.MuteDuration);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
_logger.LogWarning("Spam protection auto-muted {User} for {Minutes} minutes in '{Channel}'",
|
||||||
|
username, (int)verdict.MuteDuration.TotalMinutes, channelName);
|
||||||
|
return $"You have been automatically muted for {(int)verdict.MuteDuration.TotalMinutes} minutes (spam protection).";
|
||||||
|
|
||||||
|
case SpamVerdictKind.Rejected:
|
||||||
|
return verdict.Reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
// Attempt to fetch link embeds for URLs in the plaintext message
|
||||||
List<EmbedDto>? embeds = null;
|
List<EmbedDto>? embeds = null;
|
||||||
try
|
try
|
||||||
@@ -223,6 +298,7 @@ public class ChatService : IChatService
|
|||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
SenderUsername = username,
|
SenderUsername = username,
|
||||||
EmbedJson = dbEmbedJson,
|
EmbedJson = dbEmbedJson,
|
||||||
|
ReplyToMessageId = replyTarget?.Id,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Messages.Add(message);
|
db.Messages.Add(message);
|
||||||
@@ -238,9 +314,10 @@ public class ChatService : IChatService
|
|||||||
channelName,
|
channelName,
|
||||||
message.SentAt,
|
message.SentAt,
|
||||||
Embeds: embeds,
|
Embeds: embeds,
|
||||||
SenderDisplayName: sender?.DisplayName);
|
SenderDisplayName: sender?.DisplayName,
|
||||||
|
ReplyTo: replyTarget is null ? null : BuildReplyRef(replyTarget));
|
||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto, originConnectionId));
|
||||||
|
|
||||||
_logger.LogDebug("{User} sent message in '{Channel}'", username, channelName);
|
_logger.LogDebug("{User} sent message in '{Channel}'", username, channelName);
|
||||||
return null;
|
return null;
|
||||||
@@ -252,14 +329,57 @@ public class ChatService : IChatService
|
|||||||
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
count = Math.Clamp(count, 1, ValidationConstants.MaxHistoryCount);
|
||||||
offset = Math.Max(offset, 0);
|
offset = Math.Max(offset, 0);
|
||||||
|
|
||||||
|
// The log room has no DB messages — its backlog is the tail of the rolling log file.
|
||||||
|
// Only the first page carries the backlog; older pages are empty (files are the archive).
|
||||||
|
if (_serverLogs.IsLogsChannel(channelName))
|
||||||
|
return offset > 0 ? [] : BuildLogBacklog(channelName);
|
||||||
|
|
||||||
using var scope = _scopeFactory.CreateScope();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
|
return await GetChannelHistoryInternalAsync(db, channelName, count, offset);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Turns the log-file backlog into transport-encrypted <see cref="MessageDto"/>s so clients
|
||||||
|
/// render past log lines exactly like streamed ones. Never touches the database.
|
||||||
|
/// </summary>
|
||||||
|
private List<MessageDto> BuildLogBacklog(string channelName)
|
||||||
|
{
|
||||||
|
return _serverLogs.ReadBacklog()
|
||||||
|
.Select(entry => new MessageDto(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
_encryption.Encrypt(entry.Content),
|
||||||
|
ServerLogsService.SenderName,
|
||||||
|
null,
|
||||||
|
channelName,
|
||||||
|
entry.Timestamp))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <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)
|
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)
|
if (statusMessage is not null && statusMessage.Length > ValidationConstants.MaxStatusMessageLength)
|
||||||
return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.";
|
return $"Status message must not exceed {ValidationConstants.MaxStatusMessageLength} characters.";
|
||||||
|
|
||||||
@@ -320,6 +440,9 @@ public class ChatService : IChatService
|
|||||||
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
|
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null)
|
||||||
=> BroadcastToAllAsync(b => b.SendChannelUpdatedAsync(channel, channelName));
|
=> BroadcastToAllAsync(b => b.SendChannelUpdatedAsync(channel, channelName));
|
||||||
|
|
||||||
|
public Task BroadcastChannelDeletedAsync(string channelName)
|
||||||
|
=> BroadcastToAllAsync(b => b.SendChannelDeletedAsync(channelName));
|
||||||
|
|
||||||
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
|
private async Task BroadcastToAllAsync(Func<IChatBroadcaster, Task> action)
|
||||||
{
|
{
|
||||||
foreach (var broadcaster in _broadcasters)
|
foreach (var broadcaster in _broadcasters)
|
||||||
@@ -379,19 +502,33 @@ public class ChatService : IChatService
|
|||||||
if (channel is null)
|
if (channel is null)
|
||||||
return [];
|
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
|
var raw = await db.Messages
|
||||||
.Where(m => m.ChannelId == channel.Id)
|
.Where(m => m.ChannelId == channel.Id)
|
||||||
.OrderByDescending(m => m.SentAt)
|
.OrderByDescending(m => m.SentAt)
|
||||||
.Skip(offset)
|
.Skip(offset)
|
||||||
.Take(count)
|
.Take(count)
|
||||||
.Join(db.Users,
|
.GroupJoin(db.Users,
|
||||||
m => m.SenderUserId,
|
m => m.SenderUserId,
|
||||||
u => u.Id,
|
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();
|
.ToListAsync();
|
||||||
|
|
||||||
raw.Reverse();
|
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 messageIds = raw.Select(x => x.m.Id).ToList();
|
||||||
var attachmentsByMessage = (await db.Attachments
|
var attachmentsByMessage = (await db.Attachments
|
||||||
.Where(a => messageIds.Contains(a.MessageId))
|
.Where(a => messageIds.Contains(a.MessageId))
|
||||||
@@ -456,7 +593,10 @@ public class ChatService : IChatService
|
|||||||
x.m.SentAt,
|
x.m.SentAt,
|
||||||
attachments,
|
attachments,
|
||||||
embeds,
|
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.
|
// Lazily delete the pruned messages (+ their attachment rows) as they're encountered.
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ public sealed class FileCleanupService : BackgroundService
|
|||||||
{
|
{
|
||||||
var intervalHours = _configuration.GetValue("Storage:CleanupIntervalHours", 1);
|
var intervalHours = _configuration.GetValue("Storage:CleanupIntervalHours", 1);
|
||||||
var retentionDays = _configuration.GetValue("Storage:RetentionDays", 30);
|
var retentionDays = _configuration.GetValue("Storage:RetentionDays", 30);
|
||||||
var storagePath = _configuration["Storage:Path"]
|
var configuredPath = _configuration["Storage:Path"];
|
||||||
?? Path.Combine(AppContext.BaseDirectory, "uploads");
|
var storagePath = !string.IsNullOrWhiteSpace(configuredPath)
|
||||||
|
? configuredPath
|
||||||
|
: Path.Combine(AppContext.BaseDirectory, "uploads");
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"File cleanup service started — interval: {Hours}h, retention: {Days}d, path: {Path}",
|
"File cleanup service started — interval: {Hours}h, retention: {Days}d, path: {Path}",
|
||||||
|
|||||||
@@ -6,8 +6,11 @@ public class FileStorageService
|
|||||||
|
|
||||||
public FileStorageService(IConfiguration configuration)
|
public FileStorageService(IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_storagePath = configuration["Storage:Path"]
|
// Empty string (example config placeholder) means "unset", same as missing
|
||||||
?? Path.Combine(AppContext.BaseDirectory, "uploads");
|
var configured = configuration["Storage:Path"];
|
||||||
|
_storagePath = !string.IsNullOrWhiteSpace(configured)
|
||||||
|
? configured
|
||||||
|
: Path.Combine(AppContext.BaseDirectory, "uploads");
|
||||||
|
|
||||||
if (!Directory.Exists(_storagePath))
|
if (!Directory.Exists(_storagePath))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services.ServerLogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A backlog entry read from the log file: one timestamped log line plus any continuation
|
||||||
|
/// lines (exception stack traces) that followed it.
|
||||||
|
/// </summary>
|
||||||
|
public record LogBacklogEntry(DateTimeOffset Timestamp, string Content);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared logic for the live log room: room identity, the role gate, and reading the backlog
|
||||||
|
/// tail from the current rolling log file. The file stays the only persistence — log lines
|
||||||
|
/// are never stored as messages.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerLogsService
|
||||||
|
{
|
||||||
|
/// <summary>Username shown as the sender of streamed log messages.</summary>
|
||||||
|
public const string SenderName = "server";
|
||||||
|
|
||||||
|
public const string RoomTopic = "Live server logs — read-only";
|
||||||
|
|
||||||
|
/// <summary>Timestamp prefix of the file sink's output template.</summary>
|
||||||
|
private const string TimestampFormat = "yyyy-MM-dd HH:mm:ss.fff";
|
||||||
|
|
||||||
|
/// <summary>How far back into the log file the backlog read reaches, at most.</summary>
|
||||||
|
private const int TailReadBytes = 256 * 1024;
|
||||||
|
|
||||||
|
private readonly ServerLogsOptions _options;
|
||||||
|
|
||||||
|
public ServerLogsService(ServerLogsOptions options) => _options = options;
|
||||||
|
|
||||||
|
public ServerLogsOptions Options => _options;
|
||||||
|
|
||||||
|
/// <summary>Whether the given channel is the (enabled) live log room.</summary>
|
||||||
|
public bool IsLogsChannel(string channelName) =>
|
||||||
|
_options.Enabled
|
||||||
|
&& string.Equals(channelName.Trim(), _options.NormalizedRoomName, StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>Whether a user with this role may see and join the log room.</summary>
|
||||||
|
public bool CanView(ServerRole role) => _options.Enabled && role >= _options.MinRole;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Reads the last <see cref="ServerLogsOptions.BacklogLines"/> entries from the newest
|
||||||
|
/// log file. A line starting with a timestamp begins a new entry; continuation lines
|
||||||
|
/// (stack traces) stay attached to the entry above them. Best-effort: any I/O problem
|
||||||
|
/// yields an empty backlog rather than failing the join.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<LogBacklogEntry> ReadBacklog()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var directory = Path.GetFullPath(_options.LogDirectory);
|
||||||
|
if (!Directory.Exists(directory))
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var newest = new DirectoryInfo(directory)
|
||||||
|
.GetFiles(_options.LogFilePattern)
|
||||||
|
.OrderByDescending(f => f.LastWriteTimeUtc)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (newest is null)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
// Shared read: Serilog keeps the file open for writing (and rolls it daily).
|
||||||
|
using var stream = new FileStream(newest.FullName, FileMode.Open, FileAccess.Read,
|
||||||
|
FileShare.ReadWrite | FileShare.Delete);
|
||||||
|
var seeked = stream.Length > TailReadBytes;
|
||||||
|
if (seeked)
|
||||||
|
stream.Seek(-TailReadBytes, SeekOrigin.End);
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var lines = reader.ReadToEnd().Split('\n');
|
||||||
|
|
||||||
|
return GroupIntoEntries(lines, skipLeadingContinuations: seeked, _options.BacklogLines);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Groups raw file lines into entries by their timestamp prefix. Public for tests.
|
||||||
|
/// </summary>
|
||||||
|
public static IReadOnlyList<LogBacklogEntry> GroupIntoEntries(
|
||||||
|
IReadOnlyList<string> lines, bool skipLeadingContinuations, int maxEntries)
|
||||||
|
{
|
||||||
|
var entries = new List<LogBacklogEntry>();
|
||||||
|
LogBacklogEntry? current = null;
|
||||||
|
|
||||||
|
foreach (var rawLine in lines)
|
||||||
|
{
|
||||||
|
var line = rawLine.TrimEnd('\r');
|
||||||
|
if (line.Length == 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (TryParseTimestamp(line, out var timestamp, out var rest))
|
||||||
|
{
|
||||||
|
if (current is not null)
|
||||||
|
entries.Add(current);
|
||||||
|
current = new LogBacklogEntry(timestamp, rest);
|
||||||
|
}
|
||||||
|
else if (current is not null)
|
||||||
|
{
|
||||||
|
current = current with { Content = current.Content + "\n" + line };
|
||||||
|
}
|
||||||
|
else if (!skipLeadingContinuations)
|
||||||
|
{
|
||||||
|
// File starts mid-entry only when we seeked into it; otherwise keep the line.
|
||||||
|
current = new LogBacklogEntry(DateTimeOffset.UtcNow, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current is not null)
|
||||||
|
entries.Add(current);
|
||||||
|
|
||||||
|
if (entries.Count > maxEntries)
|
||||||
|
entries.RemoveRange(0, entries.Count - maxEntries);
|
||||||
|
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool TryParseTimestamp(string line, out DateTimeOffset timestamp, out string rest)
|
||||||
|
{
|
||||||
|
timestamp = default;
|
||||||
|
rest = string.Empty;
|
||||||
|
|
||||||
|
if (line.Length <= TimestampFormat.Length
|
||||||
|
|| !DateTime.TryParseExact(line[..TimestampFormat.Length], TimestampFormat,
|
||||||
|
CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal, out var parsed))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
timestamp = new DateTimeOffset(parsed);
|
||||||
|
rest = line[TimestampFormat.Length..].TrimStart();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System.Threading.Channels;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services.ServerLogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serilog sink feeding the live log room. Events are queued in a bounded drop-oldest buffer
|
||||||
|
/// and consumed by <see cref="ServerLogsStreamService"/>; nothing is written to the database.
|
||||||
|
/// Events emitted by the streaming pipeline itself (the stream service and SignalR transport
|
||||||
|
/// internals) are dropped here so a broadcast that logs — e.g. a transport warning on a dead
|
||||||
|
/// connection — can never enter a log → broadcast → log feedback loop.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerLogsSink : ILogEventSink
|
||||||
|
{
|
||||||
|
private const int QueueCapacity = 512;
|
||||||
|
|
||||||
|
private static readonly string[] ExcludedSourcePrefixes =
|
||||||
|
[
|
||||||
|
"EchoHub.Server.Services.ServerLogs",
|
||||||
|
"Microsoft.AspNetCore.SignalR",
|
||||||
|
"Microsoft.AspNetCore.Http.Connections",
|
||||||
|
];
|
||||||
|
|
||||||
|
private readonly Channel<LogEvent> _queue = Channel.CreateBounded<LogEvent>(
|
||||||
|
new BoundedChannelOptions(QueueCapacity)
|
||||||
|
{
|
||||||
|
FullMode = BoundedChannelFullMode.DropOldest,
|
||||||
|
SingleReader = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
private readonly LogEventLevel _minLevel;
|
||||||
|
|
||||||
|
public ServerLogsSink(ServerLogsOptions options) => _minLevel = options.MinLevel;
|
||||||
|
|
||||||
|
public ChannelReader<LogEvent> Reader => _queue.Reader;
|
||||||
|
|
||||||
|
public void Emit(LogEvent logEvent)
|
||||||
|
{
|
||||||
|
if (logEvent.Level < _minLevel)
|
||||||
|
return;
|
||||||
|
|
||||||
|
if (logEvent.Properties.TryGetValue(Constants.SourceContextPropertyName, out var sourceProperty)
|
||||||
|
&& sourceProperty is ScalarValue { Value: string source }
|
||||||
|
&& ExcludedSourcePrefixes.Any(source.StartsWith))
|
||||||
|
return;
|
||||||
|
|
||||||
|
_queue.Writer.TryWrite(logEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Text;
|
||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.Contracts;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
using EchoHub.Server.Hubs;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using Serilog.Events;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services.ServerLogs;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Streams queued log events to the live log room as ephemeral messages: SignalR only (the
|
||||||
|
/// IRC gateway never sees them) and no database rows. Ensures the room exists before
|
||||||
|
/// streaming, recreating it on the fly if it was deleted.
|
||||||
|
///
|
||||||
|
/// This class must never log from its streaming path — its namespace is excluded by
|
||||||
|
/// <see cref="ServerLogsSink"/> as a second line of defense, but the primary rule is simply
|
||||||
|
/// not to log per event, otherwise every streamed line would spawn another.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerLogsStreamService : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly TimeSpan EnsureInterval = TimeSpan.FromSeconds(15);
|
||||||
|
|
||||||
|
private readonly ServerLogsSink _sink;
|
||||||
|
private readonly ServerLogsOptions _options;
|
||||||
|
private readonly IChannelService _channelService;
|
||||||
|
private readonly IMessageEncryptionService _encryption;
|
||||||
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private IHubContext<ChatHub, IEchoHubClient>? _hubContext;
|
||||||
|
private DateTimeOffset _lastEnsure = DateTimeOffset.MinValue;
|
||||||
|
|
||||||
|
// Resolved lazily: the hub context isn't available while hosted services are constructed.
|
||||||
|
private IHubContext<ChatHub, IEchoHubClient> HubContext
|
||||||
|
=> _hubContext ??= _serviceProvider.GetRequiredService<IHubContext<ChatHub, IEchoHubClient>>();
|
||||||
|
|
||||||
|
public ServerLogsStreamService(
|
||||||
|
ServerLogsSink sink,
|
||||||
|
ServerLogsOptions options,
|
||||||
|
IChannelService channelService,
|
||||||
|
IMessageEncryptionService encryption,
|
||||||
|
IServiceProvider serviceProvider)
|
||||||
|
{
|
||||||
|
_sink = sink;
|
||||||
|
_options = options;
|
||||||
|
_channelService = channelService;
|
||||||
|
_encryption = encryption;
|
||||||
|
_serviceProvider = serviceProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
// Make the room exist from server start, not only once the first event arrives.
|
||||||
|
await TryEnsureRoomAsync();
|
||||||
|
|
||||||
|
await foreach (var logEvent in _sink.Reader.ReadAllAsync(stoppingToken))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TryEnsureRoomAsync();
|
||||||
|
|
||||||
|
var roomName = _options.NormalizedRoomName;
|
||||||
|
var message = new MessageDto(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
_encryption.Encrypt(Format(logEvent)),
|
||||||
|
ServerLogsService.SenderName,
|
||||||
|
null,
|
||||||
|
roomName,
|
||||||
|
logEvent.Timestamp);
|
||||||
|
|
||||||
|
await HubContext.Clients.Group(roomName).ReceiveMessage(message);
|
||||||
|
}
|
||||||
|
catch when (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
// Swallow: logging here would re-enter the pipeline.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recreates the log room if it disappeared, at most once per <see cref="EnsureInterval"/>.
|
||||||
|
/// </summary>
|
||||||
|
private async Task TryEnsureRoomAsync()
|
||||||
|
{
|
||||||
|
if (DateTimeOffset.UtcNow - _lastEnsure < EnsureInterval)
|
||||||
|
return;
|
||||||
|
|
||||||
|
_lastEnsure = DateTimeOffset.UtcNow;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _channelService.EnsureSystemChannelAsync(_options.NormalizedRoomName, ServerLogsService.RoomTopic);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Retried on the next interval; events streamed meanwhile just go to no group members.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Formats an event like the file sink's template, minus the timestamp (clients render their own). Public for tests.</summary>
|
||||||
|
public static string Format(LogEvent logEvent)
|
||||||
|
{
|
||||||
|
var builder = new StringBuilder("[").Append(ShortLevel(logEvent.Level)).Append("] ")
|
||||||
|
.Append(logEvent.RenderMessage(CultureInfo.InvariantCulture));
|
||||||
|
|
||||||
|
if (logEvent.Exception is not null)
|
||||||
|
builder.Append('\n').Append(logEvent.Exception);
|
||||||
|
|
||||||
|
if (builder.Length > HubConstants.MaxMessageLength)
|
||||||
|
{
|
||||||
|
builder.Length = HubConstants.MaxMessageLength - 1;
|
||||||
|
builder.Append('…');
|
||||||
|
}
|
||||||
|
|
||||||
|
return builder.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ShortLevel(LogEventLevel level) => level switch
|
||||||
|
{
|
||||||
|
LogEventLevel.Verbose => "VRB",
|
||||||
|
LogEventLevel.Debug => "DBG",
|
||||||
|
LogEventLevel.Information => "INF",
|
||||||
|
LogEventLevel.Warning => "WRN",
|
||||||
|
LogEventLevel.Error => "ERR",
|
||||||
|
LogEventLevel.Fatal => "FTL",
|
||||||
|
_ => level.ToString().ToUpperInvariant(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -20,7 +20,9 @@ public class SignalRBroadcaster : IChatBroadcaster
|
|||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task SendMessageToChannelAsync(string channelName, MessageDto message)
|
// The exclusion only applies to the IRC gateway (SignalR clients render their own
|
||||||
|
// message from the broadcast echo), so the id is ignored here.
|
||||||
|
public Task SendMessageToChannelAsync(string channelName, MessageDto message, string? excludeConnectionId = null)
|
||||||
=> HubContext.Clients.Group(channelName).ReceiveMessage(message);
|
=> HubContext.Clients.Group(channelName).ReceiveMessage(message);
|
||||||
|
|
||||||
public Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
|
public Task SendUserJoinedAsync(string channelName, string username, UserPresenceDto? presence, string? excludeConnectionId = null)
|
||||||
@@ -63,6 +65,9 @@ public class SignalRBroadcaster : IChatBroadcaster
|
|||||||
public Task SendMessageDeletedAsync(string channelName, Guid messageId)
|
public Task SendMessageDeletedAsync(string channelName, Guid messageId)
|
||||||
=> HubContext.Clients.Group(channelName).MessageDeleted(channelName, messageId);
|
=> HubContext.Clients.Group(channelName).MessageDeleted(channelName, messageId);
|
||||||
|
|
||||||
|
public Task SendChannelDeletedAsync(string channelName)
|
||||||
|
=> HubContext.Clients.All.ChannelDeleted(channelName);
|
||||||
|
|
||||||
public Task SendChannelNukedAsync(string channelName)
|
public Task SendChannelNukedAsync(string channelName)
|
||||||
=> HubContext.Clients.Group(channelName).ChannelNuked(channelName);
|
=> HubContext.Clients.Group(channelName).ChannelNuked(channelName);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,187 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services;
|
||||||
|
|
||||||
|
public enum SpamVerdictKind
|
||||||
|
{
|
||||||
|
Allowed,
|
||||||
|
Rejected,
|
||||||
|
|
||||||
|
/// <summary>The user crossed the violation threshold — the caller should apply a timed mute.</summary>
|
||||||
|
AutoMute,
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly record struct SpamVerdict(SpamVerdictKind Kind, string? Reason = null, TimeSpan MuteDuration = default)
|
||||||
|
{
|
||||||
|
public static readonly SpamVerdict Allowed = new(SpamVerdictKind.Allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// In-memory, per-user spam protection consulted from <see cref="ChatService"/> (messages,
|
||||||
|
/// joins) and <see cref="ChannelService"/> (channel creation), so every ingress protocol —
|
||||||
|
/// SignalR and IRC alike — shares the same limits. State is per-process and never persisted;
|
||||||
|
/// auto-mutes land in the normal mute store. Operates only on content the server already
|
||||||
|
/// stores (for end-to-end encrypted rooms that is ciphertext) — nothing is decrypted.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class SpamGuard
|
||||||
|
{
|
||||||
|
private readonly SpamOptions _options;
|
||||||
|
private readonly Lock _lock = new();
|
||||||
|
private readonly Dictionary<Guid, UserState> _users = [];
|
||||||
|
|
||||||
|
// Lazy stale-state pruning so the dictionary can't grow unbounded on busy servers
|
||||||
|
private const int PruneThreshold = 512;
|
||||||
|
private static readonly TimeSpan StaleAfter = TimeSpan.FromMinutes(30);
|
||||||
|
|
||||||
|
public SpamGuard(SpamOptions options)
|
||||||
|
{
|
||||||
|
_options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool Enabled => _options.Enabled;
|
||||||
|
|
||||||
|
private sealed class UserState
|
||||||
|
{
|
||||||
|
public readonly Queue<DateTimeOffset> MessageTimes = new();
|
||||||
|
public readonly Queue<DateTimeOffset> JoinTimes = new();
|
||||||
|
public readonly Queue<DateTimeOffset> CreateTimes = new();
|
||||||
|
public readonly Queue<DateTimeOffset> Violations = new();
|
||||||
|
public string? LastContent;
|
||||||
|
public int RepeatCount;
|
||||||
|
public DateTimeOffset LastSeen;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Checks a message send. Rejections count as violations; enough violations inside the
|
||||||
|
/// violation window escalate to <see cref="SpamVerdictKind.AutoMute"/> (evaluated only on
|
||||||
|
/// rejected messages, so a clean message never mutes anyone).
|
||||||
|
/// </summary>
|
||||||
|
public SpamVerdict CheckMessage(Guid userId, ServerRole role, string content, DateTimeOffset? nowOverride = null)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled || role >= ServerRole.Mod)
|
||||||
|
return SpamVerdict.Allowed;
|
||||||
|
|
||||||
|
var now = nowOverride ?? DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var state = GetState(userId, now);
|
||||||
|
|
||||||
|
// Flood: attempts count too — a client hammering retries must actually pause
|
||||||
|
Prune(state.MessageTimes, now - TimeSpan.FromSeconds(_options.WindowSeconds));
|
||||||
|
state.MessageTimes.Enqueue(now);
|
||||||
|
if (state.MessageTimes.Count > _options.MaxMessagesPerWindow)
|
||||||
|
return Reject(state, now, "You're sending messages too fast — slow down.");
|
||||||
|
|
||||||
|
// Duplicates: identical (trimmed, case-insensitive) content repeated back-to-back
|
||||||
|
var normalized = content.Trim().ToLowerInvariant();
|
||||||
|
if (normalized == state.LastContent)
|
||||||
|
{
|
||||||
|
if (state.RepeatCount >= _options.MaxDuplicateMessages)
|
||||||
|
return Reject(state, now, "Duplicate message — say something new.");
|
||||||
|
state.RepeatCount++;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
state.LastContent = normalized;
|
||||||
|
state.RepeatCount = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SpamVerdict.Allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Checks a channel join. Rejections count as violations but never auto-mute.</summary>
|
||||||
|
public SpamVerdict CheckJoin(Guid userId, ServerRole role, DateTimeOffset? nowOverride = null)
|
||||||
|
{
|
||||||
|
return CheckWindow(userId, role, nowOverride,
|
||||||
|
s => s.JoinTimes,
|
||||||
|
TimeSpan.FromSeconds(_options.JoinWindowSeconds),
|
||||||
|
_options.MaxJoinsPerWindow,
|
||||||
|
"You're joining channels too fast — slow down.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Checks a channel creation. Rejections count as violations but never auto-mute.</summary>
|
||||||
|
public SpamVerdict CheckChannelCreate(Guid userId, ServerRole role, DateTimeOffset? nowOverride = null)
|
||||||
|
{
|
||||||
|
return CheckWindow(userId, role, nowOverride,
|
||||||
|
s => s.CreateTimes,
|
||||||
|
TimeSpan.FromMinutes(_options.ChannelCreateWindowMinutes),
|
||||||
|
_options.MaxChannelCreatesPerWindow,
|
||||||
|
"You're creating channels too fast — try again later.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private SpamVerdict CheckWindow(Guid userId, ServerRole role, DateTimeOffset? nowOverride,
|
||||||
|
Func<UserState, Queue<DateTimeOffset>> queueSelector, TimeSpan window, int max, string reason)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled || role >= ServerRole.Mod)
|
||||||
|
return SpamVerdict.Allowed;
|
||||||
|
|
||||||
|
var now = nowOverride ?? DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var state = GetState(userId, now);
|
||||||
|
var queue = queueSelector(state);
|
||||||
|
|
||||||
|
Prune(queue, now - window);
|
||||||
|
queue.Enqueue(now);
|
||||||
|
if (queue.Count > max)
|
||||||
|
{
|
||||||
|
RecordViolation(state, now);
|
||||||
|
return new SpamVerdict(SpamVerdictKind.Rejected, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SpamVerdict.Allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Records a violation and decides between plain rejection and auto-mute escalation.
|
||||||
|
/// On escalation the violation history is cleared so the user starts fresh post-mute.
|
||||||
|
/// </summary>
|
||||||
|
private SpamVerdict Reject(UserState state, DateTimeOffset now, string reason)
|
||||||
|
{
|
||||||
|
RecordViolation(state, now);
|
||||||
|
|
||||||
|
if (_options.AutoMuteMinutes > 0 && state.Violations.Count >= _options.ViolationThreshold)
|
||||||
|
{
|
||||||
|
state.Violations.Clear();
|
||||||
|
return new SpamVerdict(SpamVerdictKind.AutoMute, reason,
|
||||||
|
TimeSpan.FromMinutes(_options.AutoMuteMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SpamVerdict(SpamVerdictKind.Rejected, reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RecordViolation(UserState state, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
Prune(state.Violations, now - TimeSpan.FromMinutes(_options.ViolationWindowMinutes));
|
||||||
|
state.Violations.Enqueue(now);
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserState GetState(Guid userId, DateTimeOffset now)
|
||||||
|
{
|
||||||
|
if (_users.Count > PruneThreshold)
|
||||||
|
{
|
||||||
|
foreach (var stale in _users.Where(kv => now - kv.Value.LastSeen > StaleAfter).Select(kv => kv.Key).ToList())
|
||||||
|
_users.Remove(stale);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_users.TryGetValue(userId, out var state))
|
||||||
|
{
|
||||||
|
state = new UserState();
|
||||||
|
_users[userId] = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.LastSeen = now;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Prune(Queue<DateTimeOffset> queue, DateTimeOffset cutoff)
|
||||||
|
{
|
||||||
|
while (queue.Count > 0 && queue.Peek() < cutoff)
|
||||||
|
queue.Dequeue();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
namespace EchoHub.Server.Services.Stats;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Thread-safe, in-memory accumulator for server-activity counters that have no natural
|
||||||
|
/// database timestamp to query after the fact — session connects/disconnects, moderation
|
||||||
|
/// actions, and peak concurrency. The periodic stats-report job snapshots and resets these
|
||||||
|
/// once per reporting window. Registered as a singleton; every increment is lock-free so it
|
||||||
|
/// is safe to call from hot paths (connect/disconnect).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerStatsCollector
|
||||||
|
{
|
||||||
|
private long _connections;
|
||||||
|
private long _disconnections;
|
||||||
|
private long _kicks;
|
||||||
|
private long _bans;
|
||||||
|
private int _peakOnline;
|
||||||
|
|
||||||
|
/// <summary>Record a session coming online, updating the running peak.</summary>
|
||||||
|
public void RecordConnection(int onlineNow)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _connections);
|
||||||
|
RecordOnline(onlineNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Record a session going offline, updating the running peak.</summary>
|
||||||
|
public void RecordDisconnection(int onlineNow)
|
||||||
|
{
|
||||||
|
Interlocked.Increment(ref _disconnections);
|
||||||
|
RecordOnline(onlineNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Record a kick action.</summary>
|
||||||
|
public void RecordKick() => Interlocked.Increment(ref _kicks);
|
||||||
|
|
||||||
|
/// <summary>Record a ban action.</summary>
|
||||||
|
public void RecordBan() => Interlocked.Increment(ref _bans);
|
||||||
|
|
||||||
|
/// <summary>Update the running maximum of concurrent online users (lock-free).</summary>
|
||||||
|
public void RecordOnline(int onlineNow)
|
||||||
|
{
|
||||||
|
int current;
|
||||||
|
while (onlineNow > (current = Volatile.Read(ref _peakOnline)))
|
||||||
|
{
|
||||||
|
if (Interlocked.CompareExchange(ref _peakOnline, onlineNow, current) == current)
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Atomically read all counters and reset them for the next reporting window. The peak is
|
||||||
|
/// reset to <paramref name="onlineNow"/> so the next window's peak starts from the current
|
||||||
|
/// concurrency rather than zero.
|
||||||
|
/// </summary>
|
||||||
|
public StatsCounters SnapshotAndReset(int onlineNow) => new(
|
||||||
|
Connections: Interlocked.Exchange(ref _connections, 0),
|
||||||
|
Disconnections: Interlocked.Exchange(ref _disconnections, 0),
|
||||||
|
Kicks: Interlocked.Exchange(ref _kicks, 0),
|
||||||
|
Bans: Interlocked.Exchange(ref _bans, 0),
|
||||||
|
PeakOnline: Interlocked.Exchange(ref _peakOnline, onlineNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Immutable snapshot of the counters held by <see cref="ServerStatsCollector"/>.</summary>
|
||||||
|
public readonly record struct StatsCounters(
|
||||||
|
long Connections,
|
||||||
|
long Disconnections,
|
||||||
|
long Kicks,
|
||||||
|
long Bans,
|
||||||
|
int PeakOnline);
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace EchoHub.Server.Services.Stats;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Background job that periodically snapshots server activity over a window, logs it as
|
||||||
|
/// pretty-printed JSON (which also surfaces in the live server-logs room), and persists it to
|
||||||
|
/// the database for historical trends. Interval and retention are configurable via the "Stats"
|
||||||
|
/// section; the default cadence is every 6 hours.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ServerStatsReportService : BackgroundService
|
||||||
|
{
|
||||||
|
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||||
|
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly PresenceTracker _presence;
|
||||||
|
private readonly ServerStatsCollector _collector;
|
||||||
|
private readonly StatsOptions _options;
|
||||||
|
private readonly ILogger<ServerStatsReportService> _logger;
|
||||||
|
|
||||||
|
// Start of the current reporting window. Advances to PeriodEnd after each report so that
|
||||||
|
// the DB-derived counts and the in-memory collector counters cover the same span.
|
||||||
|
private DateTimeOffset _periodStart;
|
||||||
|
|
||||||
|
public ServerStatsReportService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
PresenceTracker presence,
|
||||||
|
ServerStatsCollector collector,
|
||||||
|
StatsOptions options,
|
||||||
|
ILogger<ServerStatsReportService> logger)
|
||||||
|
{
|
||||||
|
_scopeFactory = scopeFactory;
|
||||||
|
_presence = presence;
|
||||||
|
_collector = collector;
|
||||||
|
_options = options;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
if (!_options.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// A non-positive interval falls back to the 6h default; anything positive is honoured
|
||||||
|
// but floored at 1s so a mis-set tiny value can't spin the loop.
|
||||||
|
var interval = _options.IntervalHours > 0
|
||||||
|
? TimeSpan.FromHours(_options.IntervalHours)
|
||||||
|
: TimeSpan.FromHours(6);
|
||||||
|
if (interval < TimeSpan.FromSeconds(1))
|
||||||
|
interval = TimeSpan.FromSeconds(1);
|
||||||
|
_periodStart = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
_logger.LogInformation(
|
||||||
|
"Server stats report job started — reporting every {Hours}h, retention {Days}d",
|
||||||
|
_options.IntervalHours, _options.RetentionDays);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(interval, stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await GenerateReportAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to generate periodic server stats report");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task GenerateReportAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var periodStart = _periodStart;
|
||||||
|
var periodEnd = DateTimeOffset.UtcNow;
|
||||||
|
var onlineNow = _presence.GetOnlineUserCount();
|
||||||
|
var counters = _collector.SnapshotAndReset(onlineNow);
|
||||||
|
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
var messagesSent = await db.Messages.CountAsync(m => m.SentAt >= periodStart, ct);
|
||||||
|
|
||||||
|
var activeMembers = await db.Messages
|
||||||
|
.Where(m => m.SentAt >= periodStart)
|
||||||
|
.Select(m => m.SenderUserId)
|
||||||
|
.Distinct()
|
||||||
|
.CountAsync(ct);
|
||||||
|
|
||||||
|
var uploadQuery = db.Attachments
|
||||||
|
.Where(a => db.Messages.Any(m => m.Id == a.MessageId && m.SentAt >= periodStart));
|
||||||
|
var filesUploaded = await uploadQuery.CountAsync(ct);
|
||||||
|
var bytesUploaded = filesUploaded == 0 ? 0L : await uploadQuery.SumAsync(a => a.FileSize, ct);
|
||||||
|
|
||||||
|
var newMembers = await db.Users.CountAsync(u => u.CreatedAt >= periodStart, ct);
|
||||||
|
var totalMembers = await db.Users.CountAsync(ct);
|
||||||
|
|
||||||
|
var report = new ServerStatsReport
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
GeneratedAt = periodEnd,
|
||||||
|
PeriodStart = periodStart,
|
||||||
|
PeriodEnd = periodEnd,
|
||||||
|
WindowHours = Math.Round((periodEnd - periodStart).TotalHours, 2),
|
||||||
|
MessagesSent = messagesSent,
|
||||||
|
FilesUploaded = filesUploaded,
|
||||||
|
BytesUploaded = bytesUploaded,
|
||||||
|
NewMembers = newMembers,
|
||||||
|
ActiveMembers = activeMembers,
|
||||||
|
Connections = (int)counters.Connections,
|
||||||
|
Disconnections = (int)counters.Disconnections,
|
||||||
|
Kicks = (int)counters.Kicks,
|
||||||
|
Bans = (int)counters.Bans,
|
||||||
|
TotalMembers = totalMembers,
|
||||||
|
OnlineNow = onlineNow,
|
||||||
|
PeakOnline = counters.PeakOnline,
|
||||||
|
};
|
||||||
|
|
||||||
|
db.ServerStatsReports.Add(report);
|
||||||
|
|
||||||
|
// Prune reports beyond the retention window (0 = keep forever).
|
||||||
|
if (_options.RetentionDays > 0)
|
||||||
|
{
|
||||||
|
var cutoff = periodEnd.AddDays(-_options.RetentionDays);
|
||||||
|
var stale = await db.ServerStatsReports
|
||||||
|
.Where(r => r.GeneratedAt < cutoff)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
if (stale.Count > 0)
|
||||||
|
db.ServerStatsReports.RemoveRange(stale);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
_periodStart = periodEnd;
|
||||||
|
|
||||||
|
// Pretty-printed JSON so the report is readable both in the log files and the logs room.
|
||||||
|
var json = JsonSerializer.Serialize(report, JsonOptions);
|
||||||
|
_logger.LogInformation("Server stats report ({WindowHours}h window):\n{Report}", report.WindowHours, json);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,13 +11,24 @@ namespace EchoHub.Server.Services;
|
|||||||
public class UserService : IUserService
|
public class UserService : IUserService
|
||||||
{
|
{
|
||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
|
||||||
public UserService(IServiceScopeFactory scopeFactory)
|
public UserService(IServiceScopeFactory scopeFactory, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_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))
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required.");
|
return UserOperationResult.Fail(UserError.ValidationFailed, "Username and password are required.");
|
||||||
@@ -35,6 +46,10 @@ public class UserService : IUserService
|
|||||||
|
|
||||||
var normalizedUsername = username.ToLowerInvariant().Trim();
|
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();
|
using var scope = _scopeFactory.CreateScope();
|
||||||
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
@@ -43,6 +58,24 @@ public class UserService : IUserService
|
|||||||
|
|
||||||
var isFirstUser = !await db.Users.AnyAsync();
|
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
|
var user = new User
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -58,6 +91,32 @@ public class UserService : IUserService
|
|||||||
return UserOperationResult.Success(ToProfileDto(user));
|
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)
|
public async Task<UserOperationResult> AuthenticateUserAsync(string username, string password)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(password))
|
||||||
|
|||||||
@@ -14,9 +14,12 @@
|
|||||||
"PublicServer": false,
|
"PublicServer": false,
|
||||||
"PublicHosts": [],
|
"PublicHosts": [],
|
||||||
"Tags": [],
|
"Tags": [],
|
||||||
"Admins": []
|
"Admins": [],
|
||||||
|
"Registration": "open",
|
||||||
|
"DirectoryClaimPath": ""
|
||||||
},
|
},
|
||||||
"Storage": {
|
"Storage": {
|
||||||
|
"Path": "",
|
||||||
"CleanupIntervalHours": 1,
|
"CleanupIntervalHours": 1,
|
||||||
"RetentionDays": 30
|
"RetentionDays": 30
|
||||||
},
|
},
|
||||||
@@ -27,10 +30,37 @@
|
|||||||
"MaxAvatarSizeMB": 2,
|
"MaxAvatarSizeMB": 2,
|
||||||
"MaxAttachmentsPerMessage": 10
|
"MaxAttachmentsPerMessage": 10
|
||||||
},
|
},
|
||||||
|
"Spam": {
|
||||||
|
"Enabled": true,
|
||||||
|
"MaxMessagesPerWindow": 8,
|
||||||
|
"WindowSeconds": 5,
|
||||||
|
"MaxDuplicateMessages": 3,
|
||||||
|
"AutoMuteMinutes": 5,
|
||||||
|
"ViolationThreshold": 5,
|
||||||
|
"ViolationWindowMinutes": 5,
|
||||||
|
"MaxJoinsPerWindow": 25,
|
||||||
|
"JoinWindowSeconds": 30,
|
||||||
|
"MaxChannelCreatesPerWindow": 3,
|
||||||
|
"ChannelCreateWindowMinutes": 10
|
||||||
|
},
|
||||||
"Encryption": {
|
"Encryption": {
|
||||||
"Key": "",
|
"Key": "",
|
||||||
"EncryptDatabase": false
|
"EncryptDatabase": false
|
||||||
},
|
},
|
||||||
|
"ServerLogs": {
|
||||||
|
"Enabled": true,
|
||||||
|
"RoomName": "server-logs",
|
||||||
|
"MinRole": "Mod",
|
||||||
|
"MinLevel": "Information",
|
||||||
|
"BacklogLines": 100,
|
||||||
|
"LogDirectory": "logs",
|
||||||
|
"LogFilePattern": "echohub-server-*.log"
|
||||||
|
},
|
||||||
|
"Stats": {
|
||||||
|
"Enabled": true,
|
||||||
|
"IntervalHours": 6,
|
||||||
|
"RetentionDays": 90
|
||||||
|
},
|
||||||
"Irc": {
|
"Irc": {
|
||||||
"Enabled": false,
|
"Enabled": false,
|
||||||
"Port": 6667,
|
"Port": 6667,
|
||||||
@@ -39,7 +69,11 @@
|
|||||||
"TlsCertPath": "",
|
"TlsCertPath": "",
|
||||||
"TlsCertPassword": "",
|
"TlsCertPassword": "",
|
||||||
"ServerName": "echohub",
|
"ServerName": "echohub",
|
||||||
"Motd": "Welcome to EchoHub IRC Gateway!"
|
"Motd": "Welcome to EchoHub IRC Gateway!",
|
||||||
|
"PublicBaseUrl": ""
|
||||||
|
},
|
||||||
|
"Cors": {
|
||||||
|
"AllowedOrigins": []
|
||||||
},
|
},
|
||||||
"Serilog": {
|
"Serilog": {
|
||||||
"MinimumLevel": {
|
"MinimumLevel": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,227 @@
|
|||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using EchoHub.Server.Services.ServerLogs;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// System-channel behavior of <see cref="ChannelService"/> (the live server-log room):
|
||||||
|
/// name reservation, role-gated visibility + top ordering, delete protection, and the
|
||||||
|
/// join-time role gate with auto-recreation. Runs against a real SQLite in-memory database.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChannelServiceSystemChannelTests : IDisposable
|
||||||
|
{
|
||||||
|
private const string LogRoom = "server-logs";
|
||||||
|
|
||||||
|
private readonly SqliteConnection _connection;
|
||||||
|
private readonly ServiceProvider _provider;
|
||||||
|
private readonly ServerLogsService _serverLogs = new(new ServerLogsOptions
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
RoomName = LogRoom,
|
||||||
|
MinRole = ServerRole.Mod,
|
||||||
|
});
|
||||||
|
|
||||||
|
public ChannelServiceSystemChannelTests()
|
||||||
|
{
|
||||||
|
_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 ChannelService CreateService() => new(
|
||||||
|
_provider.GetRequiredService<IServiceScopeFactory>(),
|
||||||
|
new PresenceTracker(),
|
||||||
|
// Disable the spam guard so channel-create throttling never interferes with assertions.
|
||||||
|
new SpamGuard(new SpamOptions { Enabled = false }),
|
||||||
|
_serverLogs,
|
||||||
|
NullLogger<ChannelService>.Instance);
|
||||||
|
|
||||||
|
private async Task<Guid> SeedUserAsync(ServerRole role)
|
||||||
|
{
|
||||||
|
using var scope = _provider.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Username = "user-" + Guid.NewGuid().ToString("N")[..8],
|
||||||
|
PasswordHash = "x",
|
||||||
|
Role = role,
|
||||||
|
};
|
||||||
|
db.Users.Add(user);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return user.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EchoHubDbContext Db() =>
|
||||||
|
_provider.GetRequiredService<IServiceScopeFactory>()
|
||||||
|
.CreateScope().ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
// ── Name reservation ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChannel_WithReservedLogRoomName_IsRejected()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var userId = await SeedUserAsync(ServerRole.Owner);
|
||||||
|
|
||||||
|
var result = await service.CreateChannelAsync(userId, LogRoom, null, isPublic: true);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(ChannelError.ValidationFailed, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EnsureSystemChannelAsync ──────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureSystemChannel_CreatesPrivateSystemChannel()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
|
||||||
|
var dto = await service.EnsureSystemChannelAsync(LogRoom, "Live server logs");
|
||||||
|
|
||||||
|
Assert.True(dto.IsSystem);
|
||||||
|
Assert.False(dto.IsPublic);
|
||||||
|
|
||||||
|
var stored = await Db().Channels.SingleAsync(c => c.Name == LogRoom);
|
||||||
|
Assert.True(stored.IsSystem);
|
||||||
|
Assert.False(stored.IsPublic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureSystemChannel_IsIdempotent()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
|
||||||
|
var first = await service.EnsureSystemChannelAsync(LogRoom);
|
||||||
|
var second = await service.EnsureSystemChannelAsync(LogRoom);
|
||||||
|
|
||||||
|
Assert.Equal(first.Id, second.Id);
|
||||||
|
Assert.Equal(1, await Db().Channels.CountAsync(c => c.Name == LogRoom));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureSystemChannel_ClaimsExistingRegularChannelOfSameName()
|
||||||
|
{
|
||||||
|
// A regular channel squatting on the name (created while the feature was off) must be
|
||||||
|
// reclaimed so server content never streams into a user-owned room.
|
||||||
|
using (var scope = _provider.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
db.Channels.Add(new Channel
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Name = LogRoom,
|
||||||
|
IsPublic = true,
|
||||||
|
IsSystem = false,
|
||||||
|
PasswordHash = "hash",
|
||||||
|
CreatedByUserId = Guid.NewGuid(),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var service = CreateService();
|
||||||
|
var dto = await service.EnsureSystemChannelAsync(LogRoom);
|
||||||
|
|
||||||
|
Assert.True(dto.IsSystem);
|
||||||
|
var stored = await Db().Channels.SingleAsync(c => c.Name == LogRoom);
|
||||||
|
Assert.True(stored.IsSystem);
|
||||||
|
Assert.False(stored.IsPublic);
|
||||||
|
Assert.Null(stored.PasswordHash);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Visibility + ordering ─────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetChannels_HidesSystemChannelFromMembers()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
await service.EnsureSystemChannelAsync(LogRoom);
|
||||||
|
var memberId = await SeedUserAsync(ServerRole.Member);
|
||||||
|
|
||||||
|
var page = await service.GetChannelsAsync(memberId, 0, 50);
|
||||||
|
|
||||||
|
Assert.DoesNotContain(page.Items, c => c.Name == LogRoom);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetChannels_ShowsSystemChannelToMods_PinnedAtTop()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
await service.EnsureSystemChannelAsync(LogRoom);
|
||||||
|
var modId = await SeedUserAsync(ServerRole.Mod);
|
||||||
|
|
||||||
|
var page = await service.GetChannelsAsync(modId, 0, 50);
|
||||||
|
|
||||||
|
var logRoom = Assert.Single(page.Items, c => c.Name == LogRoom);
|
||||||
|
Assert.True(logRoom.IsSystem);
|
||||||
|
// Sorts above 'general' (auto-created, alphabetically after 's' would normally lose).
|
||||||
|
Assert.Equal(LogRoom, page.Items[0].Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete protection ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteChannel_SystemChannel_IsRefused()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
await service.EnsureSystemChannelAsync(LogRoom);
|
||||||
|
var ownerId = await SeedUserAsync(ServerRole.Owner);
|
||||||
|
|
||||||
|
var result = await service.DeleteChannelAsync(ownerId, LogRoom);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(ChannelError.Protected, result.Error);
|
||||||
|
Assert.True(await Db().Channels.AnyAsync(c => c.Name == LogRoom));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Join-time role gate + auto-recreate ───────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureMembership_LogRoom_RejectsMember()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
await service.EnsureSystemChannelAsync(LogRoom);
|
||||||
|
var memberId = await SeedUserAsync(ServerRole.Member);
|
||||||
|
|
||||||
|
var (success, error, _) = await service.EnsureChannelMembershipAsync(memberId, LogRoom);
|
||||||
|
|
||||||
|
Assert.False(success);
|
||||||
|
Assert.NotNull(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureMembership_LogRoom_AllowsModAndRecreatesIfMissing()
|
||||||
|
{
|
||||||
|
// No prior EnsureSystemChannelAsync — a Mod joining a missing log room recreates it.
|
||||||
|
var service = CreateService();
|
||||||
|
var modId = await SeedUserAsync(ServerRole.Mod);
|
||||||
|
|
||||||
|
var (success, error, _) = await service.EnsureChannelMembershipAsync(modId, LogRoom);
|
||||||
|
|
||||||
|
Assert.True(success);
|
||||||
|
Assert.Null(error);
|
||||||
|
var stored = await Db().Channels.SingleAsync(c => c.Name == LogRoom);
|
||||||
|
Assert.True(stored.IsSystem);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.DTOs;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
using EchoHub.Server.Data;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using EchoHub.Server.Services.ServerLogs;
|
||||||
|
using Microsoft.Data.Sqlite;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// General <see cref="ChannelService"/> CRUD, validation, password gates, and role/creator
|
||||||
|
/// authorization. (System-channel behavior lives in <see cref="ChannelServiceSystemChannelTests"/>.)
|
||||||
|
/// Runs against a real SQLite in-memory database so the guarded queries and FK relationships
|
||||||
|
/// behave as in production.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class ChannelServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection _connection;
|
||||||
|
private readonly ServiceProvider _provider;
|
||||||
|
// Default options → reserved name "server-logs"; feature enabled but never targeted here.
|
||||||
|
private readonly ServerLogsService _serverLogs = new(new ServerLogsOptions());
|
||||||
|
|
||||||
|
public ChannelServiceTests()
|
||||||
|
{
|
||||||
|
_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 ChannelService CreateService() => new(
|
||||||
|
_provider.GetRequiredService<IServiceScopeFactory>(),
|
||||||
|
new PresenceTracker(),
|
||||||
|
new SpamGuard(new SpamOptions { Enabled = false }),
|
||||||
|
_serverLogs,
|
||||||
|
NullLogger<ChannelService>.Instance);
|
||||||
|
|
||||||
|
private async Task<Guid> SeedUserAsync(ServerRole role = ServerRole.Member)
|
||||||
|
{
|
||||||
|
using var scope = _provider.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
var user = new User
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Username = "user-" + Guid.NewGuid().ToString("N")[..8],
|
||||||
|
PasswordHash = "x",
|
||||||
|
Role = role,
|
||||||
|
};
|
||||||
|
db.Users.Add(user);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
return user.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EchoHubDbContext Db() =>
|
||||||
|
_provider.GetRequiredService<IServiceScopeFactory>()
|
||||||
|
.CreateScope().ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
// ── Create: happy path + membership ───────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChannel_Valid_SucceedsAndAddsCreatorMembership()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
|
||||||
|
var result = await service.CreateChannelAsync(creator, "dev-talk", "About dev", isPublic: true);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("dev-talk", result.Channel!.Name);
|
||||||
|
Assert.True(result.Channel.IsPublic);
|
||||||
|
|
||||||
|
var channel = await Db().Channels.SingleAsync(c => c.Name == "dev-talk");
|
||||||
|
Assert.True(await Db().ChannelMemberships.AnyAsync(m => m.ChannelId == channel.Id && m.UserId == creator));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChannel_LowercasesName()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
|
||||||
|
var result = await service.CreateChannelAsync(creator, "DevTalk", null, isPublic: true);
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("devtalk", result.Channel!.Name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Create: validation ────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("a")] // too short (< 2)
|
||||||
|
[InlineData("has space")] // invalid character
|
||||||
|
[InlineData("bang!")] // invalid character
|
||||||
|
public async Task CreateChannel_InvalidName_Rejected(string name)
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
|
||||||
|
var result = await service.CreateChannelAsync(creator, name, null, isPublic: true);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(ChannelError.ValidationFailed, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChannel_DuplicateName_Rejected()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
await service.CreateChannelAsync(creator, "dupe", null, isPublic: true);
|
||||||
|
|
||||||
|
var result = await service.CreateChannelAsync(creator, "dupe", null, isPublic: true);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(ChannelError.AlreadyExists, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChannel_ShortPassword_Rejected()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
|
||||||
|
var result = await service.CreateChannelAsync(creator, "locked", null, isPublic: true, password: "ab");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(ChannelError.ValidationFailed, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChannel_WithPassword_IsMarkedProtectedAndHashed()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
|
||||||
|
var result = await service.CreateChannelAsync(creator, "locked", null, isPublic: true, password: "secret");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Channel!.IsProtected);
|
||||||
|
var stored = await Db().Channels.SingleAsync(c => c.Name == "locked");
|
||||||
|
Assert.NotNull(stored.PasswordHash);
|
||||||
|
Assert.NotEqual("secret", stored.PasswordHash); // hashed, not plaintext
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChannel_EncryptionEnvelopeWithoutPassword_Rejected()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
|
||||||
|
var result = await service.CreateChannelAsync(creator, "e2e", null, isPublic: false,
|
||||||
|
password: null, encryptionSalt: "salt", wrappedRoomKey: "wrapped");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(ChannelError.ValidationFailed, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CreateChannel_EncryptedChannel_ExposesCryptoMetadataButNotKey()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
await service.CreateChannelAsync(creator, "e2e", null, isPublic: false,
|
||||||
|
password: "passphrase", encryptionSalt: "the-salt", wrappedRoomKey: "the-wrapped-key");
|
||||||
|
|
||||||
|
var crypto = await service.GetChannelCryptoAsync("e2e");
|
||||||
|
var (salt, wrapped) = await service.GetChannelKeyEnvelopeAsync("e2e");
|
||||||
|
|
||||||
|
Assert.True(crypto!.IsEncrypted);
|
||||||
|
Assert.Equal("the-salt", crypto.EncryptionSalt);
|
||||||
|
Assert.Equal("the-salt", salt);
|
||||||
|
Assert.Equal("the-wrapped-key", wrapped);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Visibility ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetChannels_ShowsPublicAndOwnPrivate_HidesOthersPrivate()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var owner = await SeedUserAsync();
|
||||||
|
var outsider = await SeedUserAsync();
|
||||||
|
await service.CreateChannelAsync(owner, "public-room", null, isPublic: true);
|
||||||
|
await service.CreateChannelAsync(owner, "private-room", null, isPublic: false);
|
||||||
|
|
||||||
|
var outsiderView = await service.GetChannelsAsync(outsider, 0, 50);
|
||||||
|
|
||||||
|
Assert.Contains(outsiderView.Items, c => c.Name == "public-room");
|
||||||
|
Assert.DoesNotContain(outsiderView.Items, c => c.Name == "private-room");
|
||||||
|
|
||||||
|
var ownerView = await service.GetChannelsAsync(owner, 0, 50);
|
||||||
|
Assert.Contains(ownerView.Items, c => c.Name == "private-room");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Membership + password gate ────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureMembership_ProtectedChannel_RequiresCorrectPassword()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
await service.CreateChannelAsync(creator, "vault", null, isPublic: true, password: "opensesame");
|
||||||
|
var joiner = await SeedUserAsync();
|
||||||
|
|
||||||
|
var noPassword = await service.EnsureChannelMembershipAsync(joiner, "vault");
|
||||||
|
Assert.False(noPassword.Success);
|
||||||
|
Assert.True(noPassword.PasswordRequired);
|
||||||
|
|
||||||
|
var wrongPassword = await service.EnsureChannelMembershipAsync(joiner, "vault", "nope");
|
||||||
|
Assert.False(wrongPassword.Success);
|
||||||
|
Assert.True(wrongPassword.PasswordRequired);
|
||||||
|
|
||||||
|
var correct = await service.EnsureChannelMembershipAsync(joiner, "vault", "opensesame");
|
||||||
|
Assert.True(correct.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureMembership_ExistingMember_NoPasswordNeeded()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
await service.CreateChannelAsync(creator, "vault", null, isPublic: true, password: "opensesame");
|
||||||
|
|
||||||
|
// Creator already has membership from creation → re-join needs no password.
|
||||||
|
var result = await service.EnsureChannelMembershipAsync(creator, "vault");
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureMembership_NonexistentChannel_Fails()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var user = await SeedUserAsync();
|
||||||
|
|
||||||
|
var result = await service.EnsureChannelMembershipAsync(user, "ghost");
|
||||||
|
|
||||||
|
Assert.False(result.Success);
|
||||||
|
Assert.False(result.PasswordRequired);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnsureMembership_DefaultChannel_AutoRecreatedIfMissing()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var user = await SeedUserAsync();
|
||||||
|
|
||||||
|
var result = await service.EnsureChannelMembershipAsync(user, HubConstants.DefaultChannel);
|
||||||
|
|
||||||
|
Assert.True(result.Success);
|
||||||
|
Assert.True(await Db().Channels.AnyAsync(c => c.Name == HubConstants.DefaultChannel));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Topic ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateTopic_Creator_Succeeds()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
await service.CreateChannelAsync(creator, "room", null, isPublic: true);
|
||||||
|
|
||||||
|
var result = await service.UpdateTopicAsync(creator, "room", "new topic");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("new topic", result.Channel!.Topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateTopic_NonCreator_Forbidden()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
var other = await SeedUserAsync(ServerRole.Admin); // even an admin isn't the creator
|
||||||
|
await service.CreateChannelAsync(creator, "room", null, isPublic: true);
|
||||||
|
|
||||||
|
var result = await service.UpdateTopicAsync(other, "room", "hijack");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(ChannelError.Forbidden, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateTopic_TooLong_Rejected()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
await service.CreateChannelAsync(creator, "room", null, isPublic: true);
|
||||||
|
|
||||||
|
var result = await service.UpdateTopicAsync(creator, "room",
|
||||||
|
new string('x', ValidationConstants.MaxChannelTopicLength + 1));
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(ChannelError.ValidationFailed, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Password management ───────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetChannelPassword_Admin_CanSetOnAnothersChannel()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
var admin = await SeedUserAsync(ServerRole.Admin);
|
||||||
|
await service.CreateChannelAsync(creator, "room", null, isPublic: true);
|
||||||
|
|
||||||
|
var result = await service.SetChannelPasswordAsync(admin, "room", "newpass");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.True(result.Channel!.IsProtected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetChannelPassword_UnprivilegedNonCreator_Forbidden()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
var member = await SeedUserAsync(ServerRole.Member);
|
||||||
|
await service.CreateChannelAsync(creator, "room", null, isPublic: true);
|
||||||
|
|
||||||
|
var result = await service.SetChannelPasswordAsync(member, "room", "newpass");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(ChannelError.Forbidden, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetChannelPassword_EncryptedChannel_Refused()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
await service.CreateChannelAsync(creator, "e2e", null, isPublic: false,
|
||||||
|
password: "passphrase", encryptionSalt: "salt", wrappedRoomKey: "wrapped");
|
||||||
|
|
||||||
|
var result = await service.SetChannelPasswordAsync(creator, "e2e", "newpass");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(ChannelError.Protected, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Delete ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteChannel_DefaultChannel_Protected()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var owner = await SeedUserAsync(ServerRole.Owner);
|
||||||
|
// GetChannels auto-creates #general.
|
||||||
|
await service.GetChannelsAsync(owner, 0, 50);
|
||||||
|
|
||||||
|
var result = await service.DeleteChannelAsync(owner, HubConstants.DefaultChannel);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(ChannelError.Protected, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteChannel_Creator_Succeeds()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
await service.CreateChannelAsync(creator, "temp", null, isPublic: true);
|
||||||
|
|
||||||
|
var result = await service.DeleteChannelAsync(creator, "temp");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.False(await Db().Channels.AnyAsync(c => c.Name == "temp"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteChannel_UnprivilegedNonCreator_Forbidden()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
var member = await SeedUserAsync(ServerRole.Member);
|
||||||
|
await service.CreateChannelAsync(creator, "temp", null, isPublic: true);
|
||||||
|
|
||||||
|
var result = await service.DeleteChannelAsync(member, "temp");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(ChannelError.Forbidden, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Queries ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetChannelByName_UnknownChannel_ReturnsNull()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
|
||||||
|
Assert.Null(await service.GetChannelByNameAsync("nope"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetChannelMeta_ReturnsMessageCountAndFlags()
|
||||||
|
{
|
||||||
|
var service = CreateService();
|
||||||
|
var creator = await SeedUserAsync();
|
||||||
|
var create = await service.CreateChannelAsync(creator, "room", null, isPublic: true, password: "secret");
|
||||||
|
var channelId = create.Channel!.Id;
|
||||||
|
|
||||||
|
using (var scope = _provider.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
db.Messages.Add(new Message
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Content = "hello",
|
||||||
|
SentAt = DateTimeOffset.UtcNow,
|
||||||
|
ChannelId = channelId,
|
||||||
|
SenderUserId = creator,
|
||||||
|
SenderUsername = "someone",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var meta = await service.GetChannelMetaAsync("room");
|
||||||
|
|
||||||
|
Assert.NotNull(meta);
|
||||||
|
Assert.Equal(1, meta!.MessageCount);
|
||||||
|
Assert.True(meta.IsProtected);
|
||||||
|
Assert.False(meta.IsEncrypted);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -83,17 +83,48 @@ public class CommandHandlerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HandleAsync_StatusCustomMessage_SetsStatusMessage()
|
public async Task HandleAsync_StatusMsg_SetsStatusMessageAndKeepsStatus()
|
||||||
{
|
{
|
||||||
var handler = CreateHandler();
|
var handler = CreateHandler();
|
||||||
|
UserStatus? capturedStatus = UserStatus.Online;
|
||||||
string? capturedMessage = null;
|
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.True(result.Handled);
|
||||||
|
Assert.False(result.IsError);
|
||||||
Assert.Contains("brb lunch", result.Message);
|
Assert.Contains("brb lunch", result.Message);
|
||||||
Assert.Equal("brb lunch", capturedMessage);
|
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]
|
[Fact]
|
||||||
@@ -106,6 +137,128 @@ public class CommandHandlerTests
|
|||||||
Assert.Contains("Usage", result.Message);
|
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 ─────────────────────────────────────────────────────────
|
// ── /nick ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -62,13 +62,11 @@ public class DataMigrationServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void AnsiToColorTags_RoundTrip_WithColorTagsToAnsi()
|
public void AnsiToColorTags_ForegroundBackgroundAndReset_AllConverted()
|
||||||
{
|
{
|
||||||
// AnsiToColorTags and IrcMessageFormatter.ColorTagsToAnsi should be inverses
|
var ansi = "\x1b[38;2;255;0;0mred\x1b[48;2;0;255;0mgreen\x1b[0m";
|
||||||
var original = "{F:FF0000}red{B:00FF00}green{X}";
|
|
||||||
var ansi = EchoHub.Server.Irc.IrcMessageFormatter.ColorTagsToAnsi(original);
|
|
||||||
var backToTags = DataMigrationService.AnsiToColorTags(ansi);
|
var backToTags = DataMigrationService.AnsiToColorTags(ansi);
|
||||||
|
|
||||||
Assert.Equal(original, backToTags);
|
Assert.Equal("{F:FF0000}red{B:00FF00}green{X}", backToTags);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Text;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <see cref="FileStorageService"/> round-trips against a real temp directory: save, resolve by
|
||||||
|
/// id (extension-agnostic), bulk id scan, and delete.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class FileStorageServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _dir;
|
||||||
|
private readonly FileStorageService _service;
|
||||||
|
|
||||||
|
public FileStorageServiceTests()
|
||||||
|
{
|
||||||
|
_dir = Path.Combine(Path.GetTempPath(), "echohub-filestore-" + Guid.NewGuid().ToString("N"));
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["Storage:Path"] = _dir })
|
||||||
|
.Build();
|
||||||
|
_service = new FileStorageService(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_dir))
|
||||||
|
Directory.Delete(_dir, recursive: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Stream StreamOf(string content) => new MemoryStream(Encoding.UTF8.GetBytes(content));
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_CreatesStorageDirectory()
|
||||||
|
{
|
||||||
|
Assert.True(Directory.Exists(_dir));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveFile_WritesContentAndReturnsResolvablePath()
|
||||||
|
{
|
||||||
|
var (fileId, filePath) = await _service.SaveFileAsync(StreamOf("hello world"), "note.txt");
|
||||||
|
|
||||||
|
Assert.True(File.Exists(filePath));
|
||||||
|
Assert.Equal("hello world", await File.ReadAllTextAsync(filePath));
|
||||||
|
Assert.Equal(filePath, _service.GetFilePath(fileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveFile_PreservesExtension()
|
||||||
|
{
|
||||||
|
var (fileId, _) = await _service.SaveFileAsync(StreamOf("x"), "photo.PNG");
|
||||||
|
|
||||||
|
var path = _service.GetFilePath(fileId);
|
||||||
|
|
||||||
|
Assert.NotNull(path);
|
||||||
|
Assert.Equal(".PNG", Path.GetExtension(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveFile_GeneratesDistinctIdsForSameFileName()
|
||||||
|
{
|
||||||
|
var (id1, _) = await _service.SaveFileAsync(StreamOf("a"), "dup.txt");
|
||||||
|
var (id2, _) = await _service.SaveFileAsync(StreamOf("b"), "dup.txt");
|
||||||
|
|
||||||
|
Assert.NotEqual(id1, id2);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetFilePath_UnknownId_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(_service.GetFilePath(Guid.NewGuid().ToString()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStoredFileIds_ReturnsAllSavedIds()
|
||||||
|
{
|
||||||
|
var (id1, _) = await _service.SaveFileAsync(StreamOf("a"), "a.txt");
|
||||||
|
var (id2, _) = await _service.SaveFileAsync(StreamOf("b"), "b.bin");
|
||||||
|
|
||||||
|
var ids = _service.GetStoredFileIds();
|
||||||
|
|
||||||
|
Assert.Contains(id1, ids);
|
||||||
|
Assert.Contains(id2, ids);
|
||||||
|
Assert.Equal(2, ids.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteFile_RemovesFile()
|
||||||
|
{
|
||||||
|
var (fileId, filePath) = await _service.SaveFileAsync(StreamOf("gone soon"), "temp.dat");
|
||||||
|
|
||||||
|
_service.DeleteFile(fileId);
|
||||||
|
|
||||||
|
Assert.False(File.Exists(filePath));
|
||||||
|
Assert.Null(_service.GetFilePath(fileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void DeleteFile_UnknownId_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var ex = Record.Exception(() => _service.DeleteFile(Guid.NewGuid().ToString()));
|
||||||
|
|
||||||
|
Assert.Null(ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,7 +90,7 @@ public class IrcBroadcasterTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SendMessage_DecryptsAttachmentAsciiPreview()
|
public async Task SendMessage_ImageAttachment_SendsLinkLineOnly()
|
||||||
{
|
{
|
||||||
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
var (_, stream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
@@ -104,30 +104,47 @@ public class IrcBroadcasterTests
|
|||||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
await _broadcaster.SendMessageToChannelAsync("general", message);
|
||||||
|
|
||||||
var output = stream.GetOutputLines();
|
var output = stream.GetOutputLines();
|
||||||
Assert.Contains(output, l => l.Contains("[Image: photo.png]"));
|
Assert.Contains(output, l => l.Contains("[Image: photo.png]") && l.Contains("/api/files/abc"));
|
||||||
Assert.Contains(output, l => l.Contains("line1"));
|
// ASCII preview art is never sent to IRC clients — images are links only
|
||||||
Assert.Contains(output, l => l.Contains("line2"));
|
Assert.DoesNotContain(output, l => l.Contains("line1"));
|
||||||
Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
|
Assert.DoesNotContain(output, l => l.Contains("$ENC$"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SendMessage_SkipsSender()
|
public async Task SendMessage_SkipsOnlyOriginConnection()
|
||||||
{
|
{
|
||||||
var (_, aliceStream) = AddConnectionWithCapture("alice", "general");
|
var (aliceConn, aliceStream) = AddConnectionWithCapture("alice", "general");
|
||||||
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
var (_, bobStream) = AddConnectionWithCapture("bob", "general");
|
||||||
|
|
||||||
var message = new MessageDto(
|
var message = new MessageDto(
|
||||||
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
Guid.NewGuid(), _encryption.Encrypt("Hi"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
await _broadcaster.SendMessageToChannelAsync("general", message);
|
await _broadcaster.SendMessageToChannelAsync("general", message, aliceConn.ConnectionId);
|
||||||
|
|
||||||
// Alice (sender) should NOT receive the message
|
// The connection that sent it should NOT get an echo
|
||||||
Assert.Empty(aliceStream.GetOutputLines());
|
Assert.Empty(aliceStream.GetOutputLines());
|
||||||
|
|
||||||
// Bob should receive it
|
// Bob should receive it
|
||||||
Assert.NotEmpty(bobStream.GetOutputLines());
|
Assert.NotEmpty(bobStream.GetOutputLines());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SendMessage_SendersOtherSessionsStillReceive()
|
||||||
|
{
|
||||||
|
// Same account online twice (e.g. TUI + IRC, or two IRC clients): a message sent
|
||||||
|
// from one session must still reach the other — skipping by nickname used to
|
||||||
|
// swallow these until the IRC client reconnected.
|
||||||
|
var (_, ircStream) = AddConnectionWithCapture("alice", "general");
|
||||||
|
|
||||||
|
var message = new MessageDto(
|
||||||
|
Guid.NewGuid(), _encryption.Encrypt("sent from the TUI"), "alice", null, "general", DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
|
// Origin is a SignalR connection, not this IRC one
|
||||||
|
await _broadcaster.SendMessageToChannelAsync("general", message, "signalr-conn-123");
|
||||||
|
|
||||||
|
Assert.Contains(ircStream.GetOutputLines(), l => l.Contains("sent from the TUI"));
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SendMessage_OnlySendsToChannelMembers()
|
public async Task SendMessage_OnlySendsToChannelMembers()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -118,37 +118,50 @@ public class IrcMessageFormatterTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_ImageMessage_IncludesAsciiArt()
|
public void FormatMessage_ImageMessage_NeverEmitsAsciiArt()
|
||||||
{
|
{
|
||||||
var msg = CreateImageMessage("line1\nline2");
|
// Images are shared as plain links (the common IRC practice) — never color art,
|
||||||
|
// regardless of what the preview contains.
|
||||||
|
var msg = CreateImageMessage("{F:FF0000}█{X}\nline2");
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
Assert.Contains(lines, l => l.Contains("line1"));
|
Assert.Single(lines);
|
||||||
Assert.Contains(lines, l => l.Contains("line2"));
|
Assert.Contains("[Image: image.png]", lines[0]);
|
||||||
|
Assert.DoesNotContain(lines, l => l.Contains("line2"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_ImageMessage_SkipsEmptyAsciiLines()
|
public void FormatMessage_RelativeUrl_JoinedWithPublicBaseUrl()
|
||||||
{
|
{
|
||||||
var msg = CreateImageMessage("line1\n\nline2");
|
var msg = CreateImageMessage("art", "photo.jpg", "/api/files/abc");
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com");
|
||||||
|
|
||||||
// Empty lines should be skipped
|
Assert.Single(lines);
|
||||||
var asciiLines = lines.Where(l => !l.Contains("[Image:") && !l.Contains("Download:")).ToList();
|
Assert.Contains("https://chat.example.com/api/files/abc", lines[0]);
|
||||||
Assert.Equal(2, asciiLines.Count);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Fact]
|
||||||
[InlineData("$ENC$v1$abc123$def456")] // transport ciphertext a broadcast path forgot to decrypt
|
public void FormatMessage_AbsoluteUrl_NotRewrittenByPublicBaseUrl()
|
||||||
[InlineData("$RC1$abc123def456")] // E2E room ciphertext the server cannot decrypt
|
|
||||||
public void FormatMessage_ImageMessage_SkipsCiphertextPreview(string ciphertextPreview)
|
|
||||||
{
|
{
|
||||||
var msg = CreateImageMessage(ciphertextPreview, "photo.jpg", "https://example.com/photo.jpg");
|
var msg = CreateImageMessage("art", "photo.jpg", "https://cdn.example.com/photo.jpg");
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com");
|
||||||
|
|
||||||
// Only the [Image: ...] header line — never the ciphertext blob
|
Assert.Contains("https://cdn.example.com/photo.jpg", lines[0]);
|
||||||
Assert.Single(lines);
|
Assert.DoesNotContain("https://chat.example.com", lines[0]);
|
||||||
Assert.Contains("[Image: photo.jpg]", lines[0]);
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToAbsoluteUrl_NoBaseUrl_ReturnsRelativeUnchanged()
|
||||||
|
{
|
||||||
|
Assert.Equal("/api/files/abc", IrcMessageFormatter.ToAbsoluteUrl("/api/files/abc", null));
|
||||||
|
Assert.Equal("/api/files/abc", IrcMessageFormatter.ToAbsoluteUrl("/api/files/abc", " "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ToAbsoluteUrl_TrailingSlashBase_JoinsWithoutDoubleSlash()
|
||||||
|
{
|
||||||
|
Assert.Equal("https://x.example/api/files/1",
|
||||||
|
IrcMessageFormatter.ToAbsoluteUrl("/api/files/1", "https://x.example/"));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -244,62 +257,4 @@ public class IrcMessageFormatterTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ColorTagsToAnsi ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_NoTags_ReturnsUnchanged()
|
|
||||||
{
|
|
||||||
Assert.Equal("Hello world", IrcMessageFormatter.ColorTagsToAnsi("Hello world"));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsi()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red text");
|
|
||||||
Assert.Equal("\x1b[38;2;255;0;0mRed text", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsi()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}Green bg");
|
|
||||||
Assert.Equal("\x1b[48;2;0;255;0mGreen bg", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_ResetTag_ConvertsToReset()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red{X} Normal");
|
|
||||||
Assert.Equal("\x1b[38;2;255;0;0mRed\x1b[0m Normal", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_MultipleTags_ConvertsAll()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}Red {F:0000FF}Blue{X}");
|
|
||||||
Assert.Contains("\x1b[38;2;255;0;0m", result);
|
|
||||||
Assert.Contains("\x1b[38;2;0;0;255m", result);
|
|
||||||
Assert.Contains("\x1b[0m", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_LowercaseHex_ConvertsCorrectly()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:ff8800}text");
|
|
||||||
Assert.Equal("\x1b[38;2;255;136;0mtext", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_NoBraces_SkipsProcessing()
|
|
||||||
{
|
|
||||||
var text = "plain text without braces";
|
|
||||||
Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_ExistingAnsiCodes_PreservesUnchanged()
|
|
||||||
{
|
|
||||||
var text = "\x1b[31mAlready colored\x1b[0m";
|
|
||||||
Assert.Equal(text, IrcMessageFormatter.ColorTagsToAnsi(text));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ internal sealed class FakeChatService : IChatService
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content)
|
public Task<string?> SendMessageAsync(Guid userId, string username, string channelName, string content, string? originConnectionId = null, Guid? replyToMessageId = null)
|
||||||
{
|
{
|
||||||
SentMessages.Add((channelName, content));
|
SentMessages.Add((channelName, content));
|
||||||
return Task.FromResult(SendMessageError);
|
return Task.FromResult(SendMessageError);
|
||||||
@@ -212,6 +212,9 @@ internal sealed class FakeChatService : IChatService
|
|||||||
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) =>
|
public Task BroadcastChannelUpdatedAsync(ChannelDto channel, string? channelName = null) =>
|
||||||
Task.CompletedTask;
|
Task.CompletedTask;
|
||||||
|
|
||||||
|
public Task BroadcastChannelDeletedAsync(string channelName) =>
|
||||||
|
Task.CompletedTask;
|
||||||
|
|
||||||
public Task<List<string>> GetChannelsForUserAsync(string username) =>
|
public Task<List<string>> GetChannelsForUserAsync(string username) =>
|
||||||
Task.FromResult(ChannelsForUserToReturn);
|
Task.FromResult(ChannelsForUserToReturn);
|
||||||
}
|
}
|
||||||
@@ -277,6 +280,12 @@ internal sealed class FakeChannelService : IChannelService
|
|||||||
|
|
||||||
public Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null) =>
|
public Task<(bool Success, string? Error, bool PasswordRequired)> EnsureChannelMembershipAsync(Guid userId, string channelName, string? password = null) =>
|
||||||
Task.FromResult(MembershipResult);
|
Task.FromResult(MembershipResult);
|
||||||
|
|
||||||
|
public ChannelDto? SystemChannelToReturn { get; set; }
|
||||||
|
|
||||||
|
public Task<ChannelDto> EnsureSystemChannelAsync(string channelName, string? topic = null) =>
|
||||||
|
Task.FromResult(SystemChannelToReturn ?? new ChannelDto(
|
||||||
|
Guid.NewGuid(), channelName, topic, false, 0, DateTimeOffset.UnixEpoch, false, false, true));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -302,9 +311,14 @@ internal sealed class FakeUserService : IUserService
|
|||||||
Task.FromResult(AuthResult
|
Task.FromResult(AuthResult
|
||||||
?? UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password."));
|
?? UserOperationResult.Fail(UserError.InvalidCredentials, "Invalid username or password."));
|
||||||
|
|
||||||
public Task<UserOperationResult> RegisterUserAsync(string username, string password, string? displayName = null) =>
|
public List<string?> RegisterInviteCodes { get; } = [];
|
||||||
Task.FromResult(RegisterResult
|
|
||||||
|
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."));
|
?? UserOperationResult.Fail(UserError.AlreadyExists, "Username is already taken."));
|
||||||
|
}
|
||||||
|
|
||||||
public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
|
public Task<UserProfileDto?> GetUserProfileAsync(string username) =>
|
||||||
Task.FromResult(ProfileToReturn);
|
Task.FromResult(ProfileToReturn);
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ public class IrcMessageFormatterTests
|
|||||||
string sender = "alice",
|
string sender = "alice",
|
||||||
string channel = "general",
|
string channel = "general",
|
||||||
List<AttachmentDto>? attachments = null,
|
List<AttachmentDto>? attachments = null,
|
||||||
List<EmbedDto>? embeds = null) => new(
|
List<EmbedDto>? embeds = null,
|
||||||
|
ReplyRefDto? replyTo = null) => new(
|
||||||
Id: Guid.NewGuid(),
|
Id: Guid.NewGuid(),
|
||||||
Content: content,
|
Content: content,
|
||||||
SenderUsername: sender,
|
SenderUsername: sender,
|
||||||
@@ -20,7 +21,81 @@ public class IrcMessageFormatterTests
|
|||||||
ChannelName: channel,
|
ChannelName: channel,
|
||||||
SentAt: DateTimeOffset.UtcNow,
|
SentAt: DateTimeOffset.UtcNow,
|
||||||
Attachments: attachments,
|
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 ─────────────────────────────────────────────────
|
// ── FormatMessage ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -59,11 +134,24 @@ public class IrcMessageFormatterTests
|
|||||||
attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, "{F:FF0000}█{X}")]);
|
attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, "{F:FF0000}█{X}")]);
|
||||||
var lines = IrcMessageFormatter.FormatMessage(msg);
|
var lines = IrcMessageFormatter.FormatMessage(msg);
|
||||||
|
|
||||||
Assert.True(lines.Count >= 2);
|
// Images are a single link line — the ASCII preview is never sent to IRC clients
|
||||||
|
Assert.Single(lines);
|
||||||
Assert.Contains("[Image: photo.png]", lines[0]);
|
Assert.Contains("[Image: photo.png]", lines[0]);
|
||||||
Assert.Contains("/api/files/abc", lines[0]);
|
Assert.Contains("/api/files/abc", lines[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FormatMessage_WithPublicBaseUrl_EmitsAbsoluteAttachmentLinks()
|
||||||
|
{
|
||||||
|
var msg = CreateMessage(
|
||||||
|
content: "",
|
||||||
|
attachments: [new AttachmentDto(AttachmentKind.Image, "/api/files/abc", "photo.png", 0, null)]);
|
||||||
|
var lines = IrcMessageFormatter.FormatMessage(msg, "https://chat.example.com/");
|
||||||
|
|
||||||
|
Assert.Single(lines);
|
||||||
|
Assert.Contains("https://chat.example.com/api/files/abc", lines[0]);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void FormatMessage_FileAttachment_IncludesFileTag()
|
public void FormatMessage_FileAttachment_IncludesFileTag()
|
||||||
{
|
{
|
||||||
@@ -121,48 +209,6 @@ public class IrcMessageFormatterTests
|
|||||||
Assert.Contains(lines, l => l.Contains("[File: c.pdf]"));
|
Assert.Contains(lines, l => l.Contains("[File: c.pdf]"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ColorTagsToAnsi ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_ForegroundTag_ConvertsToAnsiEscape()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}text");
|
|
||||||
Assert.Contains("\x1b[38;2;255;0;0m", result);
|
|
||||||
Assert.Contains("text", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_BackgroundTag_ConvertsToAnsiEscape()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{B:00FF00}text");
|
|
||||||
Assert.Contains("\x1b[48;2;0;255;0m", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_ResetTag_ConvertsToAnsiReset()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{X}");
|
|
||||||
Assert.Equal("\x1b[0m", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_NoTags_ReturnsUnchanged()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("plain text");
|
|
||||||
Assert.Equal("plain text", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ColorTagsToAnsi_MultipleTags_ConvertsAll()
|
|
||||||
{
|
|
||||||
var result = IrcMessageFormatter.ColorTagsToAnsi("{F:FF0000}red{F:0000FF}blue{X}");
|
|
||||||
Assert.Contains("\x1b[38;2;255;0;0m", result);
|
|
||||||
Assert.Contains("\x1b[38;2;0;0;255m", result);
|
|
||||||
Assert.Contains("\x1b[0m", result);
|
|
||||||
Assert.Contains("red", result);
|
|
||||||
Assert.Contains("blue", result);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── SplitMessage ──────────────────────────────────────────────────
|
// ── SplitMessage ──────────────────────────────────────────────────
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -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,268 @@
|
|||||||
|
using EchoHub.Core.Constants;
|
||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
using EchoHub.Server.Services.ServerLogs;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Serilog.Core;
|
||||||
|
using Serilog.Events;
|
||||||
|
using Serilog.Parsing;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
public class ServerLogsTests
|
||||||
|
{
|
||||||
|
private static readonly MessageTemplateParser Parser = new();
|
||||||
|
|
||||||
|
private static LogEvent MakeEvent(LogEventLevel level, string message,
|
||||||
|
string? sourceContext = null, Exception? exception = null)
|
||||||
|
{
|
||||||
|
var props = new List<LogEventProperty>();
|
||||||
|
if (sourceContext is not null)
|
||||||
|
props.Add(new LogEventProperty(Constants.SourceContextPropertyName, new ScalarValue(sourceContext)));
|
||||||
|
return new LogEvent(DateTimeOffset.UnixEpoch, level, exception, Parser.Parse(message), props);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Options binding ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Options_Defaults_EnabledModInformation()
|
||||||
|
{
|
||||||
|
var options = new ServerLogsOptions();
|
||||||
|
|
||||||
|
Assert.True(options.Enabled);
|
||||||
|
Assert.Equal("server-logs", options.RoomName);
|
||||||
|
Assert.Equal(ServerRole.Mod, options.MinRole);
|
||||||
|
Assert.Equal(LogEventLevel.Information, options.MinLevel);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Options_BoundFromConfiguration_ParsesEnums()
|
||||||
|
{
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["ServerLogs:Enabled"] = "false",
|
||||||
|
["ServerLogs:RoomName"] = "audit",
|
||||||
|
["ServerLogs:MinRole"] = "Admin",
|
||||||
|
["ServerLogs:MinLevel"] = "Warning",
|
||||||
|
["ServerLogs:BacklogLines"] = "42",
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var options = config.GetSection("ServerLogs").Get<ServerLogsOptions>()!;
|
||||||
|
|
||||||
|
Assert.False(options.Enabled);
|
||||||
|
Assert.Equal("audit", options.RoomName);
|
||||||
|
Assert.Equal(ServerRole.Admin, options.MinRole);
|
||||||
|
Assert.Equal(LogEventLevel.Warning, options.MinLevel);
|
||||||
|
Assert.Equal(42, options.BacklogLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── ServerLogsService: identity + role gate ───────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsLogsChannel_MatchesConfiguredNameCaseInsensitively()
|
||||||
|
{
|
||||||
|
var service = new ServerLogsService(new ServerLogsOptions { RoomName = "Server-Logs" });
|
||||||
|
|
||||||
|
Assert.True(service.IsLogsChannel("server-logs"));
|
||||||
|
Assert.True(service.IsLogsChannel("SERVER-LOGS"));
|
||||||
|
Assert.False(service.IsLogsChannel("general"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsLogsChannel_WhenDisabled_AlwaysFalse()
|
||||||
|
{
|
||||||
|
var service = new ServerLogsService(new ServerLogsOptions { Enabled = false });
|
||||||
|
|
||||||
|
Assert.False(service.IsLogsChannel("server-logs"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ServerRole.Member, false)]
|
||||||
|
[InlineData(ServerRole.Mod, true)]
|
||||||
|
[InlineData(ServerRole.Admin, true)]
|
||||||
|
[InlineData(ServerRole.Owner, true)]
|
||||||
|
public void CanView_RespectsMinRole(ServerRole role, bool expected)
|
||||||
|
{
|
||||||
|
var service = new ServerLogsService(new ServerLogsOptions { MinRole = ServerRole.Mod });
|
||||||
|
|
||||||
|
Assert.Equal(expected, service.CanView(role));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CanView_WhenDisabled_AlwaysFalse()
|
||||||
|
{
|
||||||
|
var service = new ServerLogsService(new ServerLogsOptions { Enabled = false, MinRole = ServerRole.Member });
|
||||||
|
|
||||||
|
Assert.False(service.CanView(ServerRole.Owner));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Backlog grouping ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GroupIntoEntries_AttachesContinuationLinesToPrecedingEntry()
|
||||||
|
{
|
||||||
|
string[] lines =
|
||||||
|
[
|
||||||
|
"2026-07-17 10:00:00.000 [INF] first line",
|
||||||
|
"2026-07-17 10:00:01.000 [ERR] boom",
|
||||||
|
"System.Exception: boom",
|
||||||
|
" at Foo.Bar()",
|
||||||
|
];
|
||||||
|
|
||||||
|
var entries = ServerLogsService.GroupIntoEntries(lines, skipLeadingContinuations: false, maxEntries: 100);
|
||||||
|
|
||||||
|
Assert.Equal(2, entries.Count);
|
||||||
|
Assert.Equal("[INF] first line", entries[0].Content);
|
||||||
|
Assert.Equal("[ERR] boom\nSystem.Exception: boom\n at Foo.Bar()", entries[1].Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GroupIntoEntries_SkipLeadingContinuations_DropsPartialFirstEntry()
|
||||||
|
{
|
||||||
|
// Simulates seeking into the middle of a file: the leading stack-trace fragment has no
|
||||||
|
// owning timestamped line and must be discarded rather than shown as its own entry.
|
||||||
|
string[] lines =
|
||||||
|
[
|
||||||
|
" at Orphaned.Frame()",
|
||||||
|
"2026-07-17 10:00:00.000 [INF] real entry",
|
||||||
|
];
|
||||||
|
|
||||||
|
var entries = ServerLogsService.GroupIntoEntries(lines, skipLeadingContinuations: true, maxEntries: 100);
|
||||||
|
|
||||||
|
Assert.Single(entries);
|
||||||
|
Assert.Equal("[INF] real entry", entries[0].Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GroupIntoEntries_CapsToMaxEntries_KeepingNewest()
|
||||||
|
{
|
||||||
|
var lines = Enumerable.Range(0, 10)
|
||||||
|
.Select(i => $"2026-07-17 10:00:0{i}.000 [INF] entry {i}")
|
||||||
|
.ToArray();
|
||||||
|
|
||||||
|
var entries = ServerLogsService.GroupIntoEntries(lines, skipLeadingContinuations: false, maxEntries: 3);
|
||||||
|
|
||||||
|
Assert.Equal(3, entries.Count);
|
||||||
|
Assert.Equal("[INF] entry 7", entries[0].Content);
|
||||||
|
Assert.Equal("[INF] entry 9", entries[2].Content);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReadBacklog_ReadsNewestFile_MostRecentEntries()
|
||||||
|
{
|
||||||
|
var dir = Path.Combine(Path.GetTempPath(), "echohub-logtest-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(dir);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
File.WriteAllText(Path.Combine(dir, "echohub-server-20260717.log"),
|
||||||
|
"2026-07-17 10:00:00.000 [INF] alpha\n2026-07-17 10:00:01.000 [WRN] beta\n");
|
||||||
|
|
||||||
|
var service = new ServerLogsService(new ServerLogsOptions
|
||||||
|
{
|
||||||
|
LogDirectory = dir,
|
||||||
|
LogFilePattern = "echohub-server-*.log",
|
||||||
|
BacklogLines = 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
var entries = service.ReadBacklog();
|
||||||
|
|
||||||
|
Assert.Equal(2, entries.Count);
|
||||||
|
Assert.Equal("[INF] alpha", entries[0].Content);
|
||||||
|
Assert.Equal("[WRN] beta", entries[1].Content);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
Directory.Delete(dir, recursive: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ReadBacklog_MissingDirectory_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
var service = new ServerLogsService(new ServerLogsOptions
|
||||||
|
{
|
||||||
|
LogDirectory = Path.Combine(Path.GetTempPath(), "echohub-does-not-exist-" + Guid.NewGuid().ToString("N")),
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Empty(service.ReadBacklog());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Formatting ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Format_RendersLevelAndMessage()
|
||||||
|
{
|
||||||
|
var formatted = ServerLogsStreamService.Format(MakeEvent(LogEventLevel.Warning, "disk almost full"));
|
||||||
|
|
||||||
|
Assert.Equal("[WRN] disk almost full", formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Format_AppendsException()
|
||||||
|
{
|
||||||
|
var formatted = ServerLogsStreamService.Format(
|
||||||
|
MakeEvent(LogEventLevel.Error, "failed", exception: new InvalidOperationException("nope")));
|
||||||
|
|
||||||
|
Assert.StartsWith("[ERR] failed\n", formatted);
|
||||||
|
Assert.Contains("nope", formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Format_TruncatesToMaxMessageLength()
|
||||||
|
{
|
||||||
|
var formatted = ServerLogsStreamService.Format(
|
||||||
|
MakeEvent(LogEventLevel.Information, new string('x', HubConstants.MaxMessageLength * 2)));
|
||||||
|
|
||||||
|
Assert.True(formatted.Length <= HubConstants.MaxMessageLength);
|
||||||
|
Assert.EndsWith("…", formatted);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sink: level + source filtering, no feedback loop ──────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sink_DropsEventsBelowMinLevel()
|
||||||
|
{
|
||||||
|
var sink = new ServerLogsSink(new ServerLogsOptions { MinLevel = LogEventLevel.Warning });
|
||||||
|
|
||||||
|
sink.Emit(MakeEvent(LogEventLevel.Information, "quiet"));
|
||||||
|
|
||||||
|
Assert.False(sink.Reader.TryRead(out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sink_EnqueuesEventsAtOrAboveMinLevel()
|
||||||
|
{
|
||||||
|
var sink = new ServerLogsSink(new ServerLogsOptions { MinLevel = LogEventLevel.Information });
|
||||||
|
|
||||||
|
sink.Emit(MakeEvent(LogEventLevel.Warning, "heads up"));
|
||||||
|
|
||||||
|
Assert.True(sink.Reader.TryRead(out var e));
|
||||||
|
Assert.Equal(LogEventLevel.Warning, e!.Level);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("EchoHub.Server.Services.ServerLogs.ServerLogsStreamService")]
|
||||||
|
[InlineData("Microsoft.AspNetCore.SignalR.HubConnectionContext")]
|
||||||
|
[InlineData("Microsoft.AspNetCore.Http.Connections.Internal.HttpConnectionManager")]
|
||||||
|
public void Sink_DropsEventsFromPipelineSources_PreventingFeedbackLoop(string source)
|
||||||
|
{
|
||||||
|
var sink = new ServerLogsSink(new ServerLogsOptions { MinLevel = LogEventLevel.Information });
|
||||||
|
|
||||||
|
sink.Emit(MakeEvent(LogEventLevel.Error, "would loop", sourceContext: source));
|
||||||
|
|
||||||
|
Assert.False(sink.Reader.TryRead(out _));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Sink_KeepsEventsFromOtherSources()
|
||||||
|
{
|
||||||
|
var sink = new ServerLogsSink(new ServerLogsOptions { MinLevel = LogEventLevel.Information });
|
||||||
|
|
||||||
|
sink.Emit(MakeEvent(LogEventLevel.Information, "kept", sourceContext: "EchoHub.Server.Services.ChatService"));
|
||||||
|
|
||||||
|
Assert.True(sink.Reader.TryRead(out _));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
using EchoHub.Core.Models;
|
||||||
|
using EchoHub.Server.Config;
|
||||||
|
using EchoHub.Server.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace EchoHub.Tests;
|
||||||
|
|
||||||
|
public class SpamGuardTests
|
||||||
|
{
|
||||||
|
private static readonly DateTimeOffset T0 = new(2026, 1, 1, 12, 0, 0, TimeSpan.Zero);
|
||||||
|
private static readonly Guid Alice = Guid.NewGuid();
|
||||||
|
|
||||||
|
private static SpamGuard CreateGuard(Action<SpamOptions>? configure = null)
|
||||||
|
{
|
||||||
|
var options = new SpamOptions();
|
||||||
|
configure?.Invoke(options);
|
||||||
|
return new SpamGuard(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Message flood ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flood_UnderLimit_Allowed()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard();
|
||||||
|
|
||||||
|
for (var i = 0; i < 8; i++)
|
||||||
|
{
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, $"msg {i}", T0.AddMilliseconds(i * 100));
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flood_OverLimit_Rejected()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard();
|
||||||
|
|
||||||
|
for (var i = 0; i < 8; i++)
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, $"msg {i}", T0.AddMilliseconds(i * 100));
|
||||||
|
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, "msg 9", T0.AddSeconds(1));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind);
|
||||||
|
Assert.Contains("too fast", verdict.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flood_RecoversAfterWindow()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard();
|
||||||
|
|
||||||
|
for (var i = 0; i < 9; i++)
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, $"msg {i}", T0.AddMilliseconds(i * 100));
|
||||||
|
|
||||||
|
// The whole window has passed with no attempts — allowed again
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, "later", T0.AddSeconds(10));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Flood_IsPerUser()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard();
|
||||||
|
var bob = Guid.NewGuid();
|
||||||
|
|
||||||
|
for (var i = 0; i < 9; i++)
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, $"msg {i}", T0.AddMilliseconds(i * 100));
|
||||||
|
|
||||||
|
var verdict = guard.CheckMessage(bob, ServerRole.Member, "hello", T0.AddSeconds(1));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Duplicates ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Duplicates_UpToLimit_Allowed_ThenRejected()
|
||||||
|
{
|
||||||
|
// Slow enough that the flood window never trips (one message per 10 s)
|
||||||
|
var guard = CreateGuard();
|
||||||
|
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
{
|
||||||
|
var v = guard.CheckMessage(Alice, ServerRole.Member, "same thing", T0.AddSeconds(i * 10));
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, v.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, "same thing", T0.AddSeconds(30));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind);
|
||||||
|
Assert.Contains("Duplicate", verdict.Reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Duplicates_CaseAndWhitespaceInsensitive()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => o.MaxDuplicateMessages = 1);
|
||||||
|
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, "Hello World", T0);
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, " hello world ", T0.AddSeconds(10));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Duplicates_ResetByDifferentMessage()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => o.MaxDuplicateMessages = 1);
|
||||||
|
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, "same", T0);
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, "different", T0.AddSeconds(10));
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, "same", T0.AddSeconds(20));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Escalation → auto-mute ────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Escalation_EnoughViolations_ReturnsAutoMute()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => o.ViolationThreshold = 3);
|
||||||
|
SpamVerdict verdict = default;
|
||||||
|
|
||||||
|
// Keep hammering inside the flood window: 8 allowed, then rejections accumulate
|
||||||
|
for (var i = 0; i < 8 + 3; i++)
|
||||||
|
verdict = guard.CheckMessage(Alice, ServerRole.Member, $"m{i}", T0.AddMilliseconds(i * 50));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.AutoMute, verdict.Kind);
|
||||||
|
Assert.Equal(TimeSpan.FromMinutes(5), verdict.MuteDuration);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Escalation_ClearsViolations_NextRejectionIsPlain()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => o.ViolationThreshold = 3);
|
||||||
|
|
||||||
|
for (var i = 0; i < 8 + 3; i++)
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, $"m{i}", T0.AddMilliseconds(i * 50));
|
||||||
|
|
||||||
|
// Still inside the flood window — rejected, but the counter restarted
|
||||||
|
var verdict = guard.CheckMessage(Alice, ServerRole.Member, "again", T0.AddSeconds(2));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Escalation_AutoMuteDisabled_StaysRejected()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => { o.ViolationThreshold = 3; o.AutoMuteMinutes = 0; });
|
||||||
|
SpamVerdict verdict = default;
|
||||||
|
|
||||||
|
for (var i = 0; i < 8 + 10; i++)
|
||||||
|
verdict = guard.CheckMessage(Alice, ServerRole.Member, $"m{i}", T0.AddMilliseconds(i * 50));
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, verdict.Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Exemptions / master switch ────────────────────────────────────
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(ServerRole.Mod)]
|
||||||
|
[InlineData(ServerRole.Admin)]
|
||||||
|
[InlineData(ServerRole.Owner)]
|
||||||
|
public void ModAndAbove_AlwaysAllowed(ServerRole role)
|
||||||
|
{
|
||||||
|
var guard = CreateGuard();
|
||||||
|
|
||||||
|
for (var i = 0; i < 50; i++)
|
||||||
|
{
|
||||||
|
var verdict = guard.CheckMessage(Alice, role, "same spam", T0.AddMilliseconds(i * 10));
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, verdict.Kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Disabled_EverythingAllowed()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => o.Enabled = false);
|
||||||
|
|
||||||
|
for (var i = 0; i < 50; i++)
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed,
|
||||||
|
guard.CheckMessage(Alice, ServerRole.Member, "same", T0.AddMilliseconds(i * 10)).Kind);
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, guard.CheckJoin(Alice, ServerRole.Member, T0).Kind);
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, guard.CheckChannelCreate(Alice, ServerRole.Member, T0).Kind);
|
||||||
|
Assert.False(guard.Enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Join throttle ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Joins_OverLimit_Rejected_ThenRecovers()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => { o.MaxJoinsPerWindow = 5; o.JoinWindowSeconds = 30; });
|
||||||
|
|
||||||
|
for (var i = 0; i < 5; i++)
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, guard.CheckJoin(Alice, ServerRole.Member, T0.AddSeconds(i)).Kind);
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, guard.CheckJoin(Alice, ServerRole.Member, T0.AddSeconds(6)).Kind);
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, guard.CheckJoin(Alice, ServerRole.Member, T0.AddSeconds(60)).Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Channel-create throttle ───────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ChannelCreates_OverLimit_Rejected_ThenRecovers()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard();
|
||||||
|
|
||||||
|
for (var i = 0; i < 3; i++)
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, guard.CheckChannelCreate(Alice, ServerRole.Member, T0.AddSeconds(i)).Kind);
|
||||||
|
|
||||||
|
Assert.Equal(SpamVerdictKind.Rejected, guard.CheckChannelCreate(Alice, ServerRole.Member, T0.AddSeconds(10)).Kind);
|
||||||
|
Assert.Equal(SpamVerdictKind.Allowed, guard.CheckChannelCreate(Alice, ServerRole.Member, T0.AddMinutes(15)).Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void JoinAndCreateRejections_NeverAutoMute()
|
||||||
|
{
|
||||||
|
var guard = CreateGuard(o => { o.MaxJoinsPerWindow = 1; o.ViolationThreshold = 2; });
|
||||||
|
|
||||||
|
for (var i = 0; i < 20; i++)
|
||||||
|
{
|
||||||
|
var verdict = guard.CheckJoin(Alice, ServerRole.Member, T0.AddSeconds(i));
|
||||||
|
Assert.NotEqual(SpamVerdictKind.AutoMute, verdict.Kind);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
using EchoHub.Core.Constants;
|
||||||
|
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>
|
||||||
|
/// <see cref="UserService"/> authentication, profile reads, profile-update validation, and
|
||||||
|
/// avatar. (Registration-gate behavior is covered by <see cref="UserServiceRegistrationTests"/>.)
|
||||||
|
/// Runs against a real SQLite in-memory database with real BCrypt hashing.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly SqliteConnection _connection;
|
||||||
|
private readonly ServiceProvider _provider;
|
||||||
|
private readonly UserService _service;
|
||||||
|
|
||||||
|
public UserServiceTests()
|
||||||
|
{
|
||||||
|
_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();
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["Server:Registration"] = "open" })
|
||||||
|
.Build();
|
||||||
|
_service = new UserService(_provider.GetRequiredService<IServiceScopeFactory>(), config);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_provider.Dispose();
|
||||||
|
_connection.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<UserProfileDto> RegisterAsync(string username = "alice", string password = "password1")
|
||||||
|
{
|
||||||
|
var result = await _service.RegisterUserAsync(username, password);
|
||||||
|
Assert.True(result.IsSuccess, result.ErrorMessage);
|
||||||
|
return result.User!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private EchoHubDbContext Db() =>
|
||||||
|
_provider.GetRequiredService<IServiceScopeFactory>()
|
||||||
|
.CreateScope().ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
|
||||||
|
// ── Authentication ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticate_CorrectCredentials_Succeeds()
|
||||||
|
{
|
||||||
|
await RegisterAsync("alice", "password1");
|
||||||
|
|
||||||
|
var result = await _service.AuthenticateUserAsync("alice", "password1");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("alice", result.User!.Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticate_IsCaseInsensitiveOnUsername()
|
||||||
|
{
|
||||||
|
await RegisterAsync("alice", "password1");
|
||||||
|
|
||||||
|
var result = await _service.AuthenticateUserAsync("ALICE", "password1");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticate_WrongPassword_InvalidCredentials()
|
||||||
|
{
|
||||||
|
await RegisterAsync("alice", "password1");
|
||||||
|
|
||||||
|
var result = await _service.AuthenticateUserAsync("alice", "wrong");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(UserError.InvalidCredentials, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticate_UnknownUser_InvalidCredentials()
|
||||||
|
{
|
||||||
|
var result = await _service.AuthenticateUserAsync("nobody", "password1");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(UserError.InvalidCredentials, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticate_EmptyInput_ValidationFailed()
|
||||||
|
{
|
||||||
|
var result = await _service.AuthenticateUserAsync("", "");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(UserError.ValidationFailed, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Authenticate_BannedUser_ReturnsBanned()
|
||||||
|
{
|
||||||
|
var profile = await RegisterAsync("alice", "password1");
|
||||||
|
using (var scope = _provider.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
|
||||||
|
var user = await db.Users.FindAsync(profile.Id);
|
||||||
|
user!.IsBanned = true;
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var result = await _service.AuthenticateUserAsync("alice", "password1");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(UserError.Banned, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Profile reads ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUserProfile_KnownUser_ReturnsProfile()
|
||||||
|
{
|
||||||
|
await RegisterAsync("alice", "password1");
|
||||||
|
|
||||||
|
var profile = await _service.GetUserProfileAsync("alice");
|
||||||
|
|
||||||
|
Assert.NotNull(profile);
|
||||||
|
Assert.Equal("alice", profile!.Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUserProfile_UnknownUser_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(await _service.GetUserProfileAsync("ghost"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUserById_RoundTrips()
|
||||||
|
{
|
||||||
|
var profile = await RegisterAsync("alice", "password1");
|
||||||
|
|
||||||
|
var byId = await _service.GetUserByIdAsync(profile.Id);
|
||||||
|
|
||||||
|
Assert.NotNull(byId);
|
||||||
|
Assert.Equal("alice", byId!.Username);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUserById_UnknownId_ReturnsNull()
|
||||||
|
{
|
||||||
|
Assert.Null(await _service.GetUserByIdAsync(Guid.NewGuid()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Profile updates ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateProfile_ValidFields_Persisted()
|
||||||
|
{
|
||||||
|
var profile = await RegisterAsync("alice", "password1");
|
||||||
|
|
||||||
|
var result = await _service.UpdateProfileAsync(profile.Id, "Alice A", "hi there", "#FF5500");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Equal("Alice A", result.User!.DisplayName);
|
||||||
|
Assert.Equal("hi there", result.User.Bio);
|
||||||
|
Assert.Equal("#FF5500", result.User.NicknameColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateProfile_InvalidHexColor_Rejected()
|
||||||
|
{
|
||||||
|
var profile = await RegisterAsync("alice", "password1");
|
||||||
|
|
||||||
|
var result = await _service.UpdateProfileAsync(profile.Id, null, null, "red");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(UserError.ValidationFailed, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateProfile_EmptyColor_ClearsIt()
|
||||||
|
{
|
||||||
|
var profile = await RegisterAsync("alice", "password1");
|
||||||
|
await _service.UpdateProfileAsync(profile.Id, null, null, "#FF5500");
|
||||||
|
|
||||||
|
var result = await _service.UpdateProfileAsync(profile.Id, null, null, "");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
Assert.Null(result.User!.NicknameColor);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateProfile_DisplayNameTooLong_Rejected()
|
||||||
|
{
|
||||||
|
var profile = await RegisterAsync("alice", "password1");
|
||||||
|
|
||||||
|
var result = await _service.UpdateProfileAsync(
|
||||||
|
profile.Id, new string('x', ValidationConstants.MaxDisplayNameLength + 1), null, null);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(UserError.ValidationFailed, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateProfile_BioTooLong_Rejected()
|
||||||
|
{
|
||||||
|
var profile = await RegisterAsync("alice", "password1");
|
||||||
|
|
||||||
|
var result = await _service.UpdateProfileAsync(
|
||||||
|
profile.Id, null, new string('x', ValidationConstants.MaxBioLength + 1), null);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(UserError.ValidationFailed, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task UpdateProfile_UnknownUser_NotFound()
|
||||||
|
{
|
||||||
|
var result = await _service.UpdateProfileAsync(Guid.NewGuid(), "x", null, null);
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(UserError.NotFound, result.Error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Avatar ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetAvatar_Persisted()
|
||||||
|
{
|
||||||
|
var profile = await RegisterAsync("alice", "password1");
|
||||||
|
|
||||||
|
var result = await _service.SetAvatarAsync(profile.Id, "{F:FF0000}art");
|
||||||
|
|
||||||
|
Assert.True(result.IsSuccess);
|
||||||
|
var stored = await Db().Users.FindAsync(profile.Id);
|
||||||
|
Assert.Equal("{F:FF0000}art", stored!.AvatarAscii);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SetAvatar_UnknownUser_NotFound()
|
||||||
|
{
|
||||||
|
var result = await _service.SetAvatarAsync(Guid.NewGuid(), "art");
|
||||||
|
|
||||||
|
Assert.False(result.IsSuccess);
|
||||||
|
Assert.Equal(UserError.NotFound, result.Error);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user