feat: add /meta command for channel metadata retrieval

- Implemented the `/meta` command to fetch and display channel metadata including room ID, topic, message count, unique user count, estimated size, and protection level.
- Added `ChannelMetaDto` to encapsulate channel metadata.
- Updated `ChannelsController` to handle the new `/meta` endpoint.
- Introduced `UploadLimits` configuration for admin-defined upload size limits for files, images, audio, and avatars.
- Enhanced error handling and user feedback for metadata retrieval.
- Updated documentation to reflect changes in encryption and room metadata.
- Added tests for the new functionality and upload limits.
This commit is contained in:
HueByte
2026-07-16 07:04:58 +02:00
parent dbf6565d18
commit e797ec2542
22 changed files with 526 additions and 93 deletions
+160
View File
@@ -0,0 +1,160 @@
# Encrypted Rooms (Password-Protected Channels)
An **encrypted room** is a channel whose entire content — every message and every file — is
end-to-end encrypted with a key derived from a shared passphrase. Only people who know the
passphrase can read the room. **Not even the server owner can read the content**, yet the server
can still gate who joins, and it can count and measure what's stored (message count, file sizes,
timestamps) without ever seeing the plaintext.
This is a stronger guarantee than the [transport and at-rest encryption](encryption.md) described
elsewhere, where the server decrypts every message to process it. Here the server is treated as
*untrusted* for content: it holds only ciphertext and wrapped keys.
> **The passphrase is the only key.** There is no recovery. If everyone who knows a room's
> passphrase forgets it, that room's history is permanently unreadable — by design.
## What the server can and cannot see
| The server **can** see | The server **cannot** see |
| --- | --- |
| That the channel is encrypted | Message text |
| Message count and timestamps | File contents |
| Who sent each message (sender identity) | Image previews (ASCII art) |
| Each attachment's **file name** and byte size | The passphrase, the room key, or the key-encryption key |
| The estimated total size (via `/meta`) | Anything that would let it decrypt the above |
File **names are stored in plaintext** so the file list stays usable — treat a file name itself as
non-secret. Everything *inside* the file is encrypted.
## Key hierarchy
Three keys are derived from one passphrase. The passphrase, the key-encryption key, and the room
content key **never leave the client**.
```mermaid
flowchart TD
P[Passphrase] -->|PBKDF2-SHA256, 210k iterations, per-room salt| OKM[64-byte output]
OKM --> AK["Auth key (first 32 bytes)"]
OKM --> KEK["Key-encryption key / KEK (last 32 bytes)"]
AK -->|sent as lowercase hex| SRV1["Server: BCrypt-hash as the join gate"]
RCK["Room Content Key (random 256-bit)"] -->|encrypts all content| CONTENT[Messages + files + previews]
RCK -->|AES-256-GCM wrap under KEK| WRAP["Wrapped room key"]
WRAP -->|stored| SRV2["Server: stores wrapped key + salt only"]
```
- **Auth key** — the join credential. Derived from the passphrase, sent to the server as hex, and
stored only as a **BCrypt hash**. Proving knowledge of it is what lets you join; it reveals
nothing about the content key.
- **Key-encryption key (KEK)** — never sent. Used locally to *wrap* (encrypt) and *unwrap* the room
content key.
- **Room Content Key (RCK)** — a random 256-bit key generated once, at room creation. It encrypts
every message and file. The server stores it only in wrapped form, so it can hand the wrapped key
to a joiner but can never unwrap it itself.
All content encryption is **AES-256-GCM** with a random 12-byte nonce and a 16-byte authentication
tag per item, so identical inputs never produce identical ciphertext, and any tampering is detected.
Room-encrypted text carries a self-describing prefix so clients and the server can tell it apart
from plaintext:
```text
$RC1$base64(nonce || tag || ciphertext)
```
## Creating a room
The client does all the cryptography locally, then hands the server only what it needs to gate joins
and store (but not read) the content.
```mermaid
sequenceDiagram
participant Client
participant Server
Client->>Client: generate salt + random Room Content Key (RCK)
Client->>Client: DeriveKeys(passphrase, salt) → auth key + KEK
Client->>Client: wrap RCK under KEK
Client->>Server: create channel { authKey(hex), salt, wrappedRoomKey }
Server->>Server: BCrypt-hash auth key, store salt + wrapped key
Note over Server: Server never receives passphrase, KEK, or RCK
```
## Joining a room
```mermaid
sequenceDiagram
participant Client
participant Server
Client->>Server: GET /crypto → { isEncrypted, salt }
Client->>Client: DeriveKeys(passphrase, salt) → auth key + KEK
Client->>Server: join { authKey(hex) }
Server->>Server: BCrypt-verify against stored hash
alt correct passphrase
Server->>Client: history (ciphertext) + wrapped room key
Client->>Client: unwrap RCK with KEK, then decrypt everything locally
else wrong passphrase
Server->>Client: rejected (join gate fails)
end
```
A wrong passphrase fails the BCrypt gate, so the server never even hands out the wrapped key. Even if
it did, an attacker without the KEK cannot unwrap it.
## What gets encrypted
When you send a message or attach files to an encrypted room, the client encrypts each part with the
room content key **before** uploading:
- **Message text** → `$RC1$…` ciphertext.
- **Files** (any kind) → the whole blob is AES-256-GCM encrypted client-side; the server stores an
opaque ciphertext blob.
- **Image ASCII previews** → rendered on the client, then room-encrypted. The server never sees the
rendered art.
The server records each attachment's **kind**, **file name**, and **byte size** (of the ciphertext
blob) as metadata, and broadcasts the ciphertext to other members, who decrypt locally.
## Changing the passphrase
`/passwd <old> <new>` rotates the passphrase. Because only the *wrapping* of the room content key
changes — not the RCK itself — **all existing history stays readable**:
1. The client proves knowledge of the old passphrase (old auth key).
2. It unwraps the RCK with the old KEK, then re-wraps it under the new KEK (new salt).
3. It uploads the new auth key + salt + re-wrapped key. The content is never re-encrypted.
## Inspecting a room
Use `/meta` in any channel to see what the server knows about it, including encrypted rooms:
```text
Room info for #private-room:
Room ID 3f2a…-…-…
Created 7/16/2026 2:31 PM
Messages 128
Unique users 4
Est. size 42.5 MB
Protection end-to-end encrypted
```
`Est. size` is the sum of stored attachment blob sizes plus message text length — an estimate of the
room's footprint, computed entirely from metadata the server holds without reading any content.
## Limitations & security notes
- **No recovery.** A lost passphrase means unrecoverable history. Keep it safe; there is no reset.
- **File names are plaintext.** They stay readable so the file list works — don't put secrets in a
file name.
- **IRC is disabled for encrypted rooms.** The IRC gateway forwards plaintext and cannot participate
in the room's key scheme, so encrypted channels are not bridged to IRC.
- **Metadata is visible.** Message counts, timestamps, sender identities, file names, and sizes are
intentionally readable so the server can moderate at the metadata level and report `/meta`.
- **Endpoint trust.** End-to-end encryption protects content from the server and the network, not
from a compromised client device that already holds the passphrase.
## Related
- [Message Encryption](encryption.md) — transport (`$ENC$v1$`) and optional at-rest database
encryption, where the server *does* decrypt content for processing. Encrypted rooms are a separate,
stronger layer that sits on top.
+1 -1
View File
@@ -118,7 +118,7 @@ If you need to recover old encrypted messages, restore the original key from a b
### Limitations ### Limitations
- **TLS-inspecting proxies** — if a corporate proxy terminates TLS with a trusted root CA, it can intercept the key exchange (`GET /api/server/encryption-key`) and read all traffic. A future upgrade to ECDH key exchange would address this. - **TLS-inspecting proxies** — if a corporate proxy terminates TLS with a trusted root CA, it can intercept the key exchange (`GET /api/server/encryption-key`) and read all traffic. A future upgrade to ECDH key exchange would address this.
- **Server has full access** — the server decrypts all messages for processing. This is not end-to-end encryption between users; it's transport encryption between client and server. - **Server has full access** — the server decrypts all messages for processing. This is not end-to-end encryption between users; it's transport encryption between client and server. For true end-to-end encryption where the server cannot read content, use [encrypted rooms](encrypted-rooms.md).
- **IRC clients receive plaintext** — IRC is an open protocol and third-party clients cannot participate in the encryption scheme. - **IRC clients receive plaintext** — IRC is an open protocol and third-party clients cannot participate in the encryption scheme.
## Troubleshooting ## Troubleshooting
+2
View File
@@ -10,6 +10,8 @@
href: configuration.md href: configuration.md
- name: Encryption - name: Encryption
href: encryption.md href: encryption.md
- name: Encrypted Rooms
href: encrypted-rooms.md
- name: Notification Sounds - name: Notification Sounds
href: notification-sounds.md href: notification-sounds.md
- name: Flows - name: Flows
+8 -1
View File
@@ -28,7 +28,10 @@ Private channels are now genuinely private: password-protected channels are end-
- Attach a file by drag & drop or by pasting — drop a file onto the terminal, or **copy a file in your file manager and press Ctrl+V**, to stage it as an attachment (the next Enter sends it with your caption). Multiple files at once are supported. Ctrl+V still pastes text when the clipboard holds text; Ctrl+Y is a paste alias. On Windows the copied-file paste reads the clipboard's file list directly (Windows Terminal never pastes copied files as text), with `xclip`/`wl-paste` used on Linux - Attach a file by drag & drop or by pasting — drop a file onto the terminal, or **copy a file in your file manager and press Ctrl+V**, to stage it as an attachment (the next Enter sends it with your caption). Multiple files at once are supported. Ctrl+V still pastes text when the clipboard holds text; Ctrl+Y is a paste alias. On Windows the copied-file paste reads the clipboard's file list directly (Windows Terminal never pastes copied files as text), with `xclip`/`wl-paste` used on Linux
- Pick ASCII-art size for attached images — `/size` opens a Small/Medium/Large picker (40×40 / 80×80 / 120×120) with descriptions, `/size <s|m|l>` sets it directly, and `/send <file> -l` sets it for that message. The choice is a saved preference and applies to copy-paste/drag-drop images (which have no per-file flag); the current size is shown in the staging tray - Pick ASCII-art size for attached images — `/size` opens a Small/Medium/Large picker (40×40 / 80×80 / 120×120) with descriptions, `/size <s|m|l>` sets it directly, and `/send <file> -l` sets it for that message. The choice is a saved preference and applies to copy-paste/drag-drop images (which have no per-file flag); the current size is shown in the staging tray
- New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`) - New `TransparentLight` theme — dark characters on a transparent background, for light terminal color schemes (`/theme transparentlight`)
- Timestamps in messages are now aware of the current culture and display the short time pattern for today's messages and the short date+time pattern for older messages. - **`/meta` command** — shows a summary of the current room: room id, created date, message count, unique participant count, estimated storage size, and protection level (open / password-protected / end-to-end encrypted). Works for encrypted rooms too, since these are all metadata the server tracks without reading content. (`/info` is an alias.)
- **Configurable upload limits** — a new `Uploads` section in the server settings sets the maximum size per file, image, audio clip, and avatar, plus the maximum attachments per message. Absent values fall back to the previous built-in limits, so existing servers are unaffected until they opt in.
- **Updater progress bar** — the self-updater now draws a real progress bar on the console (`Downloading [████████░░] 62%`) for each step, replacing the plain status lines, so a large download shows visible progress.
- Timestamps in messages are culture-aware and now **always include the date**: today's messages show a compact date + short time, and older messages show the general short date/time.
## Bug Fixes ## Bug Fixes
@@ -36,12 +39,16 @@ Private channels are now genuinely private: password-protected channels are end-
- Attachments whose files have been pruned (retention cleanup deletes blobs older than `Storage:RetentionDays` but left the message rows) no longer render a dead download/preview. When channel history loads, the server checks which attachment blobs still exist: missing ones are dropped from the message, and an attachment-only message whose files are all gone is removed from the database. - Attachments whose files have been pruned (retention cleanup deletes blobs older than `Storage:RetentionDays` but left the message rows) no longer render a dead download/preview. When channel history loads, the server checks which attachment blobs still exist: missing ones are dropped from the message, and an attachment-only message whose files are all gone is removed from the database.
- Fixed intermittent crash on Ctrl+W — Terminal.Gui binds Ctrl+W to clipboard-cut, and Windows clipboard contention (another app holding the clipboard) threw an unhandled `Win32Exception` that took the app down. Ctrl+W now deletes the previous word (readline behavior, no clipboard), and all clipboard shortcuts (Ctrl+X/C/V/Y) are guarded so transient clipboard failures log a warning instead of crashing - Fixed intermittent crash on Ctrl+W — Terminal.Gui binds Ctrl+W to clipboard-cut, and Windows clipboard contention (another app holding the clipboard) threw an unhandled `Win32Exception` that took the app down. Ctrl+W now deletes the previous word (readline behavior, no clipboard), and all clipboard shortcuts (Ctrl+X/C/V/Y) are guarded so transient clipboard failures log a warning instead of crashing
- Fixed emoji shortcode replacement permanently disabling itself if a cursor update threw mid-replacement - Fixed emoji shortcode replacement permanently disabling itself if a cursor update threw mid-replacement
- Fixed new messages showing no date — today's messages rendered time-only, and the "is it today?" check compared the server's UTC date against the local date, so the classification could also be wrong near midnight. Timestamps are now converted to local time first and always include the date.
- Fixed the self-updater hanging at "extracting" — the update ran while the Terminal.Gui main loop still owned the console, so the old and new processes deadlocked over it. The update now runs after the TUI shuts down, on a clean console
- IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]` - IRC `LIST` no longer leaks private channels; protected channels are marked `[+k]`
## API Changes ## API Changes
- `ChannelDto` gains `isProtected` and `isEncrypted`; `CreateChannelRequest` gains optional `password`, `encryptionSalt`, and `wrappedRoomKey`; SignalR `JoinChannel` takes an optional second `password` argument and `JoinChannelResult` gains `passwordRequired`, `encryptionSalt`, and `wrappedRoomKey` (older clients must update to join over SignalR) - `ChannelDto` gains `isProtected` and `isEncrypted`; `CreateChannelRequest` gains optional `password`, `encryptionSalt`, and `wrappedRoomKey`; SignalR `JoinChannel` takes an optional second `password` argument and `JoinChannelResult` gains `passwordRequired`, `encryptionSalt`, and `wrappedRoomKey` (older clients must update to join over SignalR)
- New endpoints: `GET /api/channels/{channel}/crypto` (public crypto metadata — salt only, never the wrapped key) and `POST /api/channels/{channel}/rekey` (creator-only passphrase change) - New endpoints: `GET /api/channels/{channel}/crypto` (public crypto metadata — salt only, never the wrapped key) and `POST /api/channels/{channel}/rekey` (creator-only passphrase change)
- New endpoint `GET /api/channels/{channel}/meta` returns a `ChannelMetaDto` (room id, name, topic, encrypted/protected flags, message count, unique user count, estimated size, created date) backing the `/meta` command
- New server `Uploads` configuration section (`MaxFileSizeMB`, `MaxImageSizeMB`, `MaxAudioSizeMB`, `MaxAvatarSizeMB`, `MaxAttachmentsPerMessage`); the message-upload endpoint's request-body ceiling is now derived from these values at runtime rather than from compile-time constants
- The upload endpoint accepts `type` and `content` form fields for encrypted channels, where the client supplies the declared message type and room-encrypted content - The upload endpoint accepts `type` and `content` form fields for encrypted channels, where the client supplies the declared message type and room-encrypted content
- `ImageToAsciiService` and `FileValidationHelper` moved from `EchoHub.Server` to `EchoHub.Core` so the client can render ASCII art and detect file types for encrypted uploads - `ImageToAsciiService` and `FileValidationHelper` moved from `EchoHub.Server` to `EchoHub.Core` so the client can render ASCII art and detect file types for encrypted uploads
- **Message shape change**: `MessageDto` drops `Type`/`AttachmentUrl`/`AttachmentFileName`/`AttachmentFileSize` and gains `Attachments` (a list of `AttachmentDto { Kind, Url, FileName, FileSize, AsciiPreview }`, null/empty for plain text). New `Attachment` entity + table with a cascade FK to `Message` - **Message shape change**: `MessageDto` drops `Type`/`AttachmentUrl`/`AttachmentFileName`/`AttachmentFileSize` and gains `Attachments` (a list of `AttachmentDto { Kind, Url, FileName, FileSize, AsciiPreview }`, null/empty for plain text). New `Attachment` entity + table with a cascade FK to `Message`
+2
View File
@@ -0,0 +1,2 @@
**/obj/
**/bin/
+41
View File
@@ -127,6 +127,7 @@ public sealed class AppOrchestrator : IDisposable
_commandHandler.OnLeaveChannel += HandleCmdLeaveChannel; _commandHandler.OnLeaveChannel += HandleCmdLeaveChannel;
_commandHandler.OnSetTopic += HandleCmdSetTopic; _commandHandler.OnSetTopic += HandleCmdSetTopic;
_commandHandler.OnListUsers += HandleCmdListUsers; _commandHandler.OnListUsers += HandleCmdListUsers;
_commandHandler.OnRoomInfo += HandleCmdMeta;
_commandHandler.OnKickUser += HandleCmdKickUser; _commandHandler.OnKickUser += HandleCmdKickUser;
_commandHandler.OnBanUser += HandleCmdBanUser; _commandHandler.OnBanUser += HandleCmdBanUser;
_commandHandler.OnUnbanUser += HandleCmdUnbanUser; _commandHandler.OnUnbanUser += HandleCmdUnbanUser;
@@ -611,6 +612,46 @@ public sealed class AppOrchestrator : IDisposable
} }
} }
private async Task HandleCmdMeta()
{
if (!_conn.IsConnected || _conn.Api is null) return;
var channel = _mainWindow.CurrentChannel;
if (string.IsNullOrEmpty(channel)) return;
try
{
var meta = await _conn.Api.GetChannelMetaAsync(channel);
if (meta is null)
{
InvokeUI(() => _mainWindow.ShowError($"Channel #{channel} not found."));
return;
}
var size = meta.EstimatedSizeBytes <= 0 ? "0 B" : ChatMessageManager.FormatFileSize(meta.EstimatedSizeBytes);
var protection = meta.IsEncrypted ? "end-to-end encrypted"
: meta.IsProtected ? "password-protected"
: "open";
InvokeUI(() =>
{
_messageManager.AddSystemMessage(channel, $"Room info for #{meta.Name}:");
if (!string.IsNullOrWhiteSpace(meta.Topic))
_messageManager.AddSystemMessage(channel, $" Topic {meta.Topic}");
_messageManager.AddSystemMessage(channel, $" Room ID {meta.Id}");
_messageManager.AddSystemMessage(channel, $" Created {meta.CreatedAt.ToLocalTime():g}");
_messageManager.AddSystemMessage(channel, $" Messages {meta.MessageCount}");
_messageManager.AddSystemMessage(channel, $" Unique users {meta.UniqueUserCount}");
_messageManager.AddSystemMessage(channel, $" Est. size {size}");
_messageManager.AddSystemMessage(channel, $" Protection {protection}");
});
}
catch (Exception ex)
{
InvokeUI(() => _mainWindow.ShowError($"Failed to fetch room info: {ex.Message}"));
}
}
private async Task HandleCmdKickUser(string username, string? reason) private async Task HandleCmdKickUser(string username, string? reason)
{ {
if (!_conn.IsAuthenticated) return; if (!_conn.IsAuthenticated) return;
@@ -21,6 +21,7 @@ public class CommandHandler
public event Func<Task>? OnLeaveChannel; public event Func<Task>? OnLeaveChannel;
public event Func<string, Task>? OnSetTopic; public event Func<string, Task>? OnSetTopic;
public event Func<Task>? OnListUsers; public event Func<Task>? OnListUsers;
public event Func<Task>? OnRoomInfo;
public event Func<string, Task>? OnSetAvatar; public event Func<string, Task>? OnSetAvatar;
public event Func<string, string?, Task>? OnKickUser; public event Func<string, string?, Task>? OnKickUser;
public event Func<string, string?, Task>? OnBanUser; public event Func<string, string?, Task>? OnBanUser;
@@ -62,6 +63,7 @@ public class CommandHandler
"leave" => await HandleLeave(), "leave" => await HandleLeave(),
"topic" => await HandleTopic(args), "topic" => await HandleTopic(args),
"users" => await HandleUsers(), "users" => await HandleUsers(),
"meta" or "info" => await HandleMeta(),
"kick" => await HandleKick(args), "kick" => await HandleKick(args),
"ban" => await HandleBan(args), "ban" => await HandleBan(args),
"unban" => await HandleUnban(args), "unban" => await HandleUnban(args),
@@ -273,6 +275,13 @@ public class CommandHandler
return new CommandResult(true); return new CommandResult(true);
} }
private async Task<CommandResult> HandleMeta()
{
if (OnRoomInfo is not null)
await OnRoomInfo();
return new CommandResult(true);
}
private async Task<CommandResult> HandleQuit() private async Task<CommandResult> HandleQuit()
{ {
if (OnQuit is not null) if (OnQuit is not null)
@@ -403,6 +412,7 @@ public class CommandHandler
/leave - Leave current channel /leave - Leave current channel
/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)
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+)
+14
View File
@@ -271,6 +271,20 @@ public sealed class ApiClient : IDisposable
return await response.Content.ReadFromJsonAsync<ChannelCryptoDto>(); return await response.Content.ReadFromJsonAsync<ChannelCryptoDto>();
} }
/// <summary>
/// Fetches a channel's human-facing metadata (message count, unique posters, estimated
/// size, created date, room id) for the <c>/meta</c> command. Returns null if it doesn't exist.
/// </summary>
public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
{
EnsureAuthenticated();
using var response = await AuthenticatedGetAsync($"/api/channels/{Uri.EscapeDataString(channelName)}/meta");
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
await EnsureSuccessAsync(response);
return await response.Content.ReadFromJsonAsync<ChannelMetaDto>();
}
public async Task<ChannelDto?> RekeyChannelAsync(string channelName, RekeyChannelRequest request) public async Task<ChannelDto?> RekeyChannelAsync(string channelName, RekeyChannelRequest request)
{ {
EnsureAuthenticated(); EnsureAuthenticated();
+36 -4
View File
@@ -1,3 +1,5 @@
using System.Text;
using AlwaysUpToDate; using AlwaysUpToDate;
using EchoHub.Client.UI.Dialogs; using EchoHub.Client.UI.Dialogs;
@@ -88,6 +90,10 @@ public sealed class UpdateChecker : IDisposable
private async Task ApplyUpdateAsync() private async Task ApplyUpdateAsync()
{ {
_applying = true; _applying = true;
// The TUI restored the console on shutdown; make sure the block-glyph bar renders.
try { Console.OutputEncoding = Encoding.UTF8; } catch { /* redirected/non-interactive */ }
Console.WriteLine(); Console.WriteLine();
Console.WriteLine($"Updating EchoHub to v{_pendingVersion}..."); Console.WriteLine($"Updating EchoHub to v{_pendingVersion}...");
@@ -106,23 +112,49 @@ public sealed class UpdateChecker : IDisposable
await _updater.UpdateAsync(); // download → extract → restart → Environment.Exit(0) await _updater.UpdateAsync(); // download → extract → restart → Environment.Exit(0)
} }
private const int BarWidth = 28;
private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage) private void OnProgressChanged(UpdateStep step, long itemsProcessed, long? totalItems, double? progressPercentage)
{ {
// Before the TUI is torn down (i.e. during a check) there is no progress surface; the // Before the TUI is torn down (i.e. during a check) there is no progress surface; the
// real work happens headless after shutdown, so report it on the console. // real work happens headless after shutdown, so draw a progress bar on the console.
if (!_applying) if (!_applying)
return; return;
// Finish the previous step's line so each step keeps its completed bar.
if (step != _lastStep) if (step != _lastStep)
{ {
Console.WriteLine(); if (_lastStep != (UpdateStep)(-1))
Console.WriteLine();
_lastStep = step; _lastStep = step;
} }
var pct = progressPercentage ?? 0; var label = Humanize(step);
Console.Write($"\r {step}: {itemsProcessed}/{totalItems ?? 0} ({pct:F0}%) ");
if (progressPercentage is { } percent)
{
var pct = (int)Math.Clamp(Math.Round(percent), 0, 100);
var filled = pct * BarWidth / 100;
var bar = new string('█', filled) + new string('░', BarWidth - filled); // █ / ░
Console.Write($"\r {label,-13} [{bar}] {pct,3}% ");
}
else
{
// Steps with no measurable total (verifying, restarting): show an indeterminate marker.
Console.Write($"\r {label,-13} working... ");
}
} }
private static string Humanize(UpdateStep step) => step switch
{
UpdateStep.Downloading => "Downloading",
UpdateStep.VerifyingChecksum => "Verifying",
UpdateStep.Extracting => "Extracting",
UpdateStep.CleaningUp => "Cleaning up",
UpdateStep.Restarting => "Restarting",
_ => step.ToString(),
};
private void OnUpdateStarted(string version) private void OnUpdateStarted(string version)
{ {
Log.Information("Update started: v{Version}", version); Log.Information("Update started: v{Version}", version);
@@ -451,10 +451,17 @@ public sealed class ChatMessageManager
private static string FormatDateTime(DateTimeOffset timestamp) private static string FormatDateTime(DateTimeOffset timestamp)
{ {
if (timestamp.Date == DateTimeOffset.Now.Date) // Server timestamps arrive in UTC; convert to local before deciding the calendar day,
return timestamp.ToLocalTime().ToString("t"); // otherwise a "today" message near midnight is misclassified against the local date.
else var local = timestamp.ToLocalTime();
return timestamp.ToLocalTime().ToString("g");
// Today's messages show a compact date + short time; older messages fall back to the
// culture's general short date/time. Both always include the date so new messages
// are never left date-less.
if (local.Date == DateTimeOffset.Now.Date)
return $"{local:d} {local:t}";
return local.ToString("g");
} }
internal static string FormatFileSize(long? bytes) internal static string FormatFileSize(long? bytes)
@@ -1,61 +0,0 @@
using Terminal.Gui.App;
using Terminal.Gui.Views;
using Terminal.Gui.ViewBase;
namespace EchoHub.Client.UI.Dialogs;
public sealed class UpdateProgressDialog
{
private readonly Dialog _dialog;
private readonly ProgressBar _progressBar;
private readonly Label _infoLabel;
private readonly IApplication _app;
public UpdateProgressDialog(IApplication app, string newVersion)
{
_app = app;
_dialog = new Dialog { Title = $"Updating to {newVersion}", Width = 50, Height = 10 };
_infoLabel = new Label
{
Text = "Preparing update...",
X = 1,
Y = 1,
Width = Dim.Fill(2)
};
_progressBar = new ProgressBar
{
X = 1,
Y = 3,
Width = Dim.Fill(2),
Fraction = 0f
};
var cancelButton = new Button
{
Text = "Cancel",
X = Pos.Center(),
Y = 6
};
_dialog.Add(_infoLabel, _progressBar);
}
public void UpdateProgress(float fraction, string statusText)
{
_progressBar.Fraction = fraction;
_infoLabel.Text = statusText;
}
public void Show()
{
_app.Run(_dialog);
}
public void Close()
{
_app.RequestStop();
}
}
@@ -18,6 +18,7 @@ public interface IChannelService
Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName); Task<(string? Topic, bool Exists)> GetChannelTopicAsync(string channelName);
Task<List<ChannelListItem>> GetChannelListAsync(); Task<List<ChannelListItem>> GetChannelListAsync();
Task<ChannelDto?> GetChannelByNameAsync(string channelName); Task<ChannelDto?> GetChannelByNameAsync(string channelName);
Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName);
Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName); Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName);
Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName); Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName);
+17
View File
@@ -58,6 +58,23 @@ public record CreateChannelRequest(
/// </summary> /// </summary>
public record ChannelCryptoDto(bool IsEncrypted, string? EncryptionSalt); public record ChannelCryptoDto(bool IsEncrypted, string? EncryptionSalt);
/// <summary>
/// Human-facing summary of a channel (the <c>/meta</c> command). For encrypted channels the
/// server still knows these figures — count, timestamps, and stored blob sizes — even though it
/// cannot read the content itself. <see cref="EstimatedSizeBytes"/> is the sum of stored
/// attachment blob sizes plus message text length, so it is an estimate, not an exact on-disk total.
/// </summary>
public record ChannelMetaDto(
Guid Id,
string Name,
string? Topic,
bool IsEncrypted,
bool IsProtected,
int MessageCount,
int UniqueUserCount,
long EstimatedSizeBytes,
DateTimeOffset CreatedAt);
/// <summary> /// <summary>
/// Passphrase change for an encrypted channel: the client proves knowledge of the old /// Passphrase change for an encrypted channel: the client proves knowledge of the old
/// passphrase (old auth key), then supplies the re-wrapped room key under the new one. /// passphrase (old auth key), then supplies the re-wrapped room key under the new one.
+38
View File
@@ -0,0 +1,38 @@
using EchoHub.Core.Constants;
using EchoHub.Core.Models;
namespace EchoHub.Server.Config;
/// <summary>
/// Admin-configurable upload limits, bound from the <c>Uploads</c> configuration section.
/// Sizes are expressed in megabytes in configuration; the <c>*Bytes</c> accessors convert them
/// for enforcement. Every default mirrors <see cref="HubConstants"/> so an absent or partial
/// <c>Uploads</c> section preserves the historical built-in limits.
/// </summary>
public sealed class UploadLimits
{
public int MaxFileSizeMB { get; init; } = HubConstants.MaxFileSizeBytes / (1024 * 1024);
public int MaxImageSizeMB { get; init; } = HubConstants.MaxImageSizeBytes / (1024 * 1024);
public int MaxAudioSizeMB { get; init; } = HubConstants.MaxAudioFileSizeBytes / (1024 * 1024);
public int MaxAvatarSizeMB { get; init; } = HubConstants.MaxAvatarSizeBytes / (1024 * 1024);
public int MaxAttachmentsPerMessage { get; init; } = HubConstants.MaxAttachmentsPerMessage;
public long MaxFileSizeBytes => (long)MaxFileSizeMB * 1024 * 1024;
public long MaxImageSizeBytes => (long)MaxImageSizeMB * 1024 * 1024;
public long MaxAudioSizeBytes => (long)MaxAudioSizeMB * 1024 * 1024;
public long MaxAvatarSizeBytes => (long)MaxAvatarSizeMB * 1024 * 1024;
/// <summary>Maximum accepted size for a single attachment of the given kind.</summary>
public long MaxForKind(AttachmentKind kind) => kind switch
{
AttachmentKind.Image => MaxImageSizeBytes,
AttachmentKind.Audio => MaxAudioSizeBytes,
_ => MaxFileSizeBytes,
};
/// <summary>
/// Absolute ceiling for one message request body (largest file × the attachment cap). Used to
/// size the request-body and multipart limits so a configured increase actually takes effect.
/// </summary>
public long MaxRequestBodyBytes => MaxFileSizeBytes * MaxAttachmentsPerMessage;
}
@@ -5,9 +5,11 @@ using EchoHub.Core.Security;
using EchoHub.Core.Services; using EchoHub.Core.Services;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Server.Config;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using EchoHub.Server.Services; using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting; using Microsoft.AspNetCore.RateLimiting;
@@ -26,6 +28,7 @@ public class ChannelsController : ControllerBase
private readonly IHttpClientFactory _httpClientFactory; private readonly IHttpClientFactory _httpClientFactory;
private readonly IChatService _chatService; private readonly IChatService _chatService;
private readonly IMessageEncryptionService _encryption; private readonly IMessageEncryptionService _encryption;
private readonly UploadLimits _uploadLimits;
public ChannelsController( public ChannelsController(
IChannelService channelService, IChannelService channelService,
@@ -34,7 +37,8 @@ public class ChannelsController : ControllerBase
ImageToAsciiService asciiService, ImageToAsciiService asciiService,
IHttpClientFactory httpClientFactory, IHttpClientFactory httpClientFactory,
IChatService chatService, IChatService chatService,
IMessageEncryptionService encryption) IMessageEncryptionService encryption,
UploadLimits uploadLimits)
{ {
_channelService = channelService; _channelService = channelService;
_db = db; _db = db;
@@ -43,6 +47,7 @@ public class ChannelsController : ControllerBase
_httpClientFactory = httpClientFactory; _httpClientFactory = httpClientFactory;
_chatService = chatService; _chatService = chatService;
_encryption = encryption; _encryption = encryption;
_uploadLimits = uploadLimits;
} }
[HttpGet] [HttpGet]
@@ -93,6 +98,21 @@ public class ChannelsController : ControllerBase
return Ok(crypto); return Ok(crypto);
} }
/// <summary>
/// Human-facing summary of a channel (message count, unique posters, estimated size,
/// created date, room id). Available for encrypted channels too — these are metadata the
/// server tracks even though it cannot read the messages themselves.
/// </summary>
[HttpGet("{channel}/meta")]
public async Task<IActionResult> GetChannelMeta(string channel)
{
var meta = await _channelService.GetChannelMetaAsync(channel);
if (meta is null)
return NotFound(new ErrorResponse($"Channel '{channel}' does not exist."));
return Ok(meta);
}
/// <summary> /// <summary>
/// Changes an encrypted channel's passphrase by re-wrapping its room key. /// Changes an encrypted channel's passphrase by re-wrapping its room key.
/// The caller proves knowledge of the old passphrase via the old auth key; /// The caller proves knowledge of the old passphrase via the old auth key;
@@ -152,12 +172,18 @@ public class ChannelsController : ControllerBase
/// uploads ciphertext blobs and declares each file's kind (<c>kind</c>) and pre-rendered, /// uploads ciphertext blobs and declares each file's kind (<c>kind</c>) and pre-rendered,
/// room-encrypted preview (<c>preview</c>), aligned by file order — the server never inspects them. /// room-encrypted preview (<c>preview</c>), aligned by file order — the server never inspects them.
/// </summary> /// </summary>
// Request-body and multipart limits are applied at runtime from the configured UploadLimits
// (see below) rather than via [RequestSizeLimit]/[RequestFormLimits], which require
// compile-time constants and so couldn't honor the "Uploads" configuration section.
[HttpPost("{channel}/messages")] [HttpPost("{channel}/messages")]
[EnableRateLimiting("upload")] [EnableRateLimiting("upload")]
[RequestSizeLimit((long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
[RequestFormLimits(MultipartBodyLengthLimit = (long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
public async Task<IActionResult> SendMessageWithAttachments(string channel, [FromQuery] string? size = null) public async Task<IActionResult> SendMessageWithAttachments(string channel, [FromQuery] string? size = null)
{ {
// Raise this request's body ceiling to the configured maximum before the body is read.
var bodySizeFeature = HttpContext.Features.Get<IHttpMaxRequestBodySizeFeature>();
if (bodySizeFeature is not null && !bodySizeFeature.IsReadOnly)
bodySizeFeature.MaxRequestBodySize = _uploadLimits.MaxRequestBodyBytes;
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
var usernameClaim = User.FindFirstValue("username"); var usernameClaim = User.FindFirstValue("username");
if (userIdClaim is null || usernameClaim is null) if (userIdClaim is null || usernameClaim is null)
@@ -179,8 +205,8 @@ public class ChannelsController : ControllerBase
var files = Request.Form.Files; var files = Request.Form.Files;
if (files.Count == 0) if (files.Count == 0)
return BadRequest(new ErrorResponse("At least one attachment is required. Send plain text over the chat connection.")); return BadRequest(new ErrorResponse("At least one attachment is required. Send plain text over the chat connection."));
if (files.Count > HubConstants.MaxAttachmentsPerMessage) if (files.Count > _uploadLimits.MaxAttachmentsPerMessage)
return BadRequest(new ErrorResponse($"A message may carry at most {HubConstants.MaxAttachmentsPerMessage} attachments.")); return BadRequest(new ErrorResponse($"A message may carry at most {_uploadLimits.MaxAttachmentsPerMessage} attachments."));
var sender = await _db.Users.FindAsync(userId); var sender = await _db.Users.FindAsync(userId);
if (sender is not null && sender.IsMuted && (sender.MutedUntil is null || sender.MutedUntil > DateTimeOffset.UtcNow)) if (sender is not null && sender.IsMuted && (sender.MutedUntil is null || sender.MutedUntil > DateTimeOffset.UtcNow))
@@ -215,7 +241,7 @@ public class ChannelsController : ControllerBase
if (string.IsNullOrEmpty(previewPlain)) if (string.IsNullOrEmpty(previewPlain))
previewPlain = null; previewPlain = null;
if (file.Length > MaxForKind(kind)) if (file.Length > _uploadLimits.MaxForKind(kind))
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size.")); return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size."));
using var encryptedStream = file.OpenReadStream(); using var encryptedStream = file.OpenReadStream();
@@ -228,8 +254,8 @@ public class ChannelsController : ControllerBase
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName); var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
kind = isImage ? AttachmentKind.Image : isAudio ? AttachmentKind.Audio : AttachmentKind.File; kind = isImage ? AttachmentKind.Image : isAudio ? AttachmentKind.Audio : AttachmentKind.File;
if (file.Length > MaxForKind(kind)) if (file.Length > _uploadLimits.MaxForKind(kind))
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size of {MaxForKind(kind) / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size of {_uploadLimits.MaxForKind(kind) / (1024 * 1024)} MB."));
string filePath; string filePath;
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName); (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
@@ -296,13 +322,6 @@ public class ChannelsController : ControllerBase
_ => AttachmentKind.File, _ => AttachmentKind.File,
}; };
private static long MaxForKind(AttachmentKind kind) => kind switch
{
AttachmentKind.Image => HubConstants.MaxImageSizeBytes,
AttachmentKind.Audio => HubConstants.MaxAudioFileSizeBytes,
_ => HubConstants.MaxFileSizeBytes,
};
[HttpPost("{channel}/send-url")] [HttpPost("{channel}/send-url")]
[EnableRateLimiting("upload")] [EnableRateLimiting("upload")]
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null) public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
@@ -343,13 +362,13 @@ public class ChannelsController : ControllerBase
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength; var contentLength = response.Content.Headers.ContentLength;
if (contentLength > HubConstants.MaxImageSizeBytes) if (contentLength > _uploadLimits.MaxImageSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {_uploadLimits.MaxImageSizeBytes / (1024 * 1024)} MB."));
imageBytes = await response.Content.ReadAsByteArrayAsync(); imageBytes = await response.Content.ReadAsByteArrayAsync();
if (imageBytes.Length > HubConstants.MaxImageSizeBytes) if (imageBytes.Length > _uploadLimits.MaxImageSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {_uploadLimits.MaxImageSizeBytes / (1024 * 1024)} MB."));
fileName = Path.GetFileName(uri.LocalPath); fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.')) if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
@@ -3,6 +3,7 @@ using EchoHub.Core.Constants;
using EchoHub.Core.Contracts; using EchoHub.Core.Contracts;
using EchoHub.Core.Services; using EchoHub.Core.Services;
using EchoHub.Core.DTOs; using EchoHub.Core.DTOs;
using EchoHub.Server.Config;
using EchoHub.Server.Services; using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
@@ -18,11 +19,13 @@ public class UsersController : ControllerBase
{ {
private readonly IUserService _userService; private readonly IUserService _userService;
private readonly ImageToAsciiService _asciiService; private readonly ImageToAsciiService _asciiService;
private readonly UploadLimits _uploadLimits;
public UsersController(IUserService userService, ImageToAsciiService asciiService) public UsersController(IUserService userService, ImageToAsciiService asciiService, UploadLimits uploadLimits)
{ {
_userService = userService; _userService = userService;
_asciiService = asciiService; _asciiService = asciiService;
_uploadLimits = uploadLimits;
} }
[HttpGet("{username}/profile")] [HttpGet("{username}/profile")]
@@ -65,8 +68,8 @@ public class UsersController : ControllerBase
var file = Request.Form.Files[0]; var file = Request.Form.Files[0];
if (file.Length > HubConstants.MaxAvatarSizeBytes) if (file.Length > _uploadLimits.MaxAvatarSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {_uploadLimits.MaxAvatarSizeBytes / (1024 * 1024)} MB."));
using var stream = file.OpenReadStream(); using var stream = file.OpenReadStream();
+9
View File
@@ -5,6 +5,7 @@ using EchoHub.Core.Contracts;
using EchoHub.Core.Services; using EchoHub.Core.Services;
using EchoHub.Core.Models; using EchoHub.Core.Models;
using EchoHub.Server.Auth; using EchoHub.Server.Auth;
using EchoHub.Server.Config;
using EchoHub.Server.Data; using EchoHub.Server.Data;
using EchoHub.Server.Hubs; using EchoHub.Server.Hubs;
using EchoHub.Server.Irc; using EchoHub.Server.Irc;
@@ -102,6 +103,14 @@ while (true)
builder.Services.AddControllers(); builder.Services.AddControllers();
builder.Services.AddSignalR(); builder.Services.AddSignalR();
// ── Upload limits (admin-configurable via the "Uploads" section) ─────
var uploadLimits = builder.Configuration.GetSection("Uploads").Get<UploadLimits>() ?? new UploadLimits();
builder.Services.AddSingleton(uploadLimits);
// Raise the multipart form ceiling to match the configured limits; per-endpoint
// request-body limits are applied at the action from the same values.
builder.Services.Configure<Microsoft.AspNetCore.Http.Features.FormOptions>(o =>
o.MultipartBodyLengthLimit = uploadLimits.MaxRequestBodyBytes);
// ── Services ───────────────────────────────────────────────────────── // ── Services ─────────────────────────────────────────────────────────
builder.Services.AddSingleton<JwtTokenService>(); builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<PresenceTracker>(); builder.Services.AddSingleton<PresenceTracker>();
@@ -288,6 +288,42 @@ public class ChannelService : IChannelService
c.PasswordHash != null, c.WrappedRoomKey != null); c.PasswordHash != null, c.WrappedRoomKey != null);
} }
public async Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName)
{
channelName = channelName.ToLowerInvariant().Trim();
using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<EchoHubDbContext>();
var c = await db.Channels.FirstOrDefaultAsync(ch => ch.Name == channelName);
if (c is null) return null;
var messageCount = await db.Messages.CountAsync(m => m.ChannelId == c.Id);
// Distinct senders that have posted here. Works the same for encrypted channels —
// sender identity is metadata the server keeps even when it can't read the messages.
var uniqueUsers = await db.Messages
.Where(m => m.ChannelId == c.Id)
.Select(m => m.SenderUserId)
.Distinct()
.CountAsync();
// Estimated footprint: stored attachment blob sizes + message text length. For encrypted
// channels these are the ciphertext sizes, which is the server's real on-disk cost.
var attachmentBytes = await db.Messages
.Where(m => m.ChannelId == c.Id)
.SelectMany(m => m.Attachments)
.SumAsync(a => (long?)a.FileSize) ?? 0;
var textBytes = await db.Messages
.Where(m => m.ChannelId == c.Id)
.SumAsync(m => (long?)m.Content.Length) ?? 0;
return new ChannelMetaDto(
c.Id, c.Name, c.Topic,
c.WrappedRoomKey != null, c.PasswordHash != null,
messageCount, uniqueUsers, attachmentBytes + textBytes, c.CreatedAt);
}
public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName) public async Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName)
{ {
channelName = channelName.ToLowerInvariant().Trim(); channelName = channelName.ToLowerInvariant().Trim();
@@ -20,6 +20,13 @@
"CleanupIntervalHours": 1, "CleanupIntervalHours": 1,
"RetentionDays": 30 "RetentionDays": 30
}, },
"Uploads": {
"MaxFileSizeMB": 100,
"MaxImageSizeMB": 10,
"MaxAudioSizeMB": 10,
"MaxAvatarSizeMB": 2,
"MaxAttachmentsPerMessage": 10
},
"Encryption": { "Encryption": {
"Key": "", "Key": "",
"EncryptDatabase": false "EncryptDatabase": false
+18
View File
@@ -472,4 +472,22 @@ public class CommandHandlerTests
Assert.True(result.Handled); Assert.True(result.Handled);
Assert.True(quitCalled); Assert.True(quitCalled);
} }
// ── /meta ─────────────────────────────────────────────────────────
[Theory]
[InlineData("/meta")]
[InlineData("/info")]
public async Task HandleAsync_Meta_RaisesRoomInfo(string input)
{
var handler = CreateHandler();
var raised = false;
handler.OnRoomInfo += () => { raised = true; return Task.CompletedTask; };
var result = await handler.HandleAsync(input);
Assert.True(result.Handled);
Assert.False(result.IsError);
Assert.True(raised);
}
} }
+5
View File
@@ -270,6 +270,11 @@ internal sealed class FakeChannelService : IChannelService
public Task<ChannelDto?> GetChannelByNameAsync(string channelName) => public Task<ChannelDto?> GetChannelByNameAsync(string channelName) =>
Task.FromResult(ChannelByNameToReturn); Task.FromResult(ChannelByNameToReturn);
public ChannelMetaDto? ChannelMetaToReturn { get; set; }
public Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName) =>
Task.FromResult(ChannelMetaToReturn);
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);
} }
+66
View File
@@ -0,0 +1,66 @@
using EchoHub.Core.Constants;
using EchoHub.Core.Models;
using EchoHub.Server.Config;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace EchoHub.Tests;
public class UploadLimitsTests
{
[Fact]
public void Defaults_MirrorHubConstants()
{
var limits = new UploadLimits();
Assert.Equal(HubConstants.MaxFileSizeBytes, limits.MaxFileSizeBytes);
Assert.Equal(HubConstants.MaxImageSizeBytes, limits.MaxImageSizeBytes);
Assert.Equal(HubConstants.MaxAudioFileSizeBytes, limits.MaxAudioSizeBytes);
Assert.Equal(HubConstants.MaxAvatarSizeBytes, limits.MaxAvatarSizeBytes);
Assert.Equal(HubConstants.MaxAttachmentsPerMessage, limits.MaxAttachmentsPerMessage);
}
[Fact]
public void MaxForKind_MapsEachAttachmentKind()
{
var limits = new UploadLimits
{
MaxImageSizeMB = 5,
MaxAudioSizeMB = 7,
MaxFileSizeMB = 11,
};
Assert.Equal(5L * 1024 * 1024, limits.MaxForKind(AttachmentKind.Image));
Assert.Equal(7L * 1024 * 1024, limits.MaxForKind(AttachmentKind.Audio));
Assert.Equal(11L * 1024 * 1024, limits.MaxForKind(AttachmentKind.File));
}
[Fact]
public void MaxRequestBodyBytes_IsFileSizeTimesAttachmentCap()
{
var limits = new UploadLimits { MaxFileSizeMB = 20, MaxAttachmentsPerMessage = 4 };
Assert.Equal(20L * 1024 * 1024 * 4, limits.MaxRequestBodyBytes);
}
[Fact]
public void BoundFromConfiguration_OverridesDefaults()
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Uploads:MaxFileSizeMB"] = "250",
["Uploads:MaxImageSizeMB"] = "25",
["Uploads:MaxAttachmentsPerMessage"] = "3",
})
.Build();
var limits = config.GetSection("Uploads").Get<UploadLimits>()!;
Assert.Equal(250L * 1024 * 1024, limits.MaxFileSizeBytes);
Assert.Equal(25L * 1024 * 1024, limits.MaxImageSizeBytes);
Assert.Equal(3, limits.MaxAttachmentsPerMessage);
// Unspecified values keep their HubConstants-derived defaults.
Assert.Equal(HubConstants.MaxAudioFileSizeBytes, limits.MaxAudioSizeBytes);
}
}