mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 16:46:08 +02:00
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:
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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`
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
**/obj/
|
||||||
|
**/bin/
|
||||||
@@ -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+)
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user