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
+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.OnSetTopic += HandleCmdSetTopic;
_commandHandler.OnListUsers += HandleCmdListUsers;
_commandHandler.OnRoomInfo += HandleCmdMeta;
_commandHandler.OnKickUser += HandleCmdKickUser;
_commandHandler.OnBanUser += HandleCmdBanUser;
_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)
{
if (!_conn.IsAuthenticated) return;
@@ -21,6 +21,7 @@ public class CommandHandler
public event Func<Task>? OnLeaveChannel;
public event Func<string, Task>? OnSetTopic;
public event Func<Task>? OnListUsers;
public event Func<Task>? OnRoomInfo;
public event Func<string, Task>? OnSetAvatar;
public event Func<string, string?, Task>? OnKickUser;
public event Func<string, string?, Task>? OnBanUser;
@@ -62,6 +63,7 @@ public class CommandHandler
"leave" => await HandleLeave(),
"topic" => await HandleTopic(args),
"users" => await HandleUsers(),
"meta" or "info" => await HandleMeta(),
"kick" => await HandleKick(args),
"ban" => await HandleBan(args),
"unban" => await HandleUnban(args),
@@ -273,6 +275,13 @@ public class CommandHandler
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()
{
if (OnQuit is not null)
@@ -403,6 +412,7 @@ public class CommandHandler
/leave - Leave current channel
/topic <text> - Set channel topic
/users - List online users
/meta - Show room info (size, messages, users, created, id)
Moderation:
/kick <user> [reason] - Kick a user (Mod+)
/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>();
}
/// <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)
{
EnsureAuthenticated();
+36 -4
View File
@@ -1,3 +1,5 @@
using System.Text;
using AlwaysUpToDate;
using EchoHub.Client.UI.Dialogs;
@@ -88,6 +90,10 @@ public sealed class UpdateChecker : IDisposable
private async Task ApplyUpdateAsync()
{
_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($"Updating EchoHub to v{_pendingVersion}...");
@@ -106,23 +112,49 @@ public sealed class UpdateChecker : IDisposable
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)
{
// 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)
return;
// Finish the previous step's line so each step keeps its completed bar.
if (step != _lastStep)
{
Console.WriteLine();
if (_lastStep != (UpdateStep)(-1))
Console.WriteLine();
_lastStep = step;
}
var pct = progressPercentage ?? 0;
Console.Write($"\r {step}: {itemsProcessed}/{totalItems ?? 0} ({pct:F0}%) ");
var label = Humanize(step);
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)
{
Log.Information("Update started: v{Version}", version);
@@ -451,10 +451,17 @@ public sealed class ChatMessageManager
private static string FormatDateTime(DateTimeOffset timestamp)
{
if (timestamp.Date == DateTimeOffset.Now.Date)
return timestamp.ToLocalTime().ToString("t");
else
return timestamp.ToLocalTime().ToString("g");
// Server timestamps arrive in UTC; convert to local before deciding the calendar day,
// otherwise a "today" message near midnight is misclassified against the local date.
var local = timestamp.ToLocalTime();
// 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)
@@ -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<List<ChannelListItem>> GetChannelListAsync();
Task<ChannelDto?> GetChannelByNameAsync(string channelName);
Task<ChannelMetaDto?> GetChannelMetaAsync(string channelName);
Task<ChannelCryptoDto?> GetChannelCryptoAsync(string channelName);
Task<(string? EncryptionSalt, string? WrappedRoomKey)> GetChannelKeyEnvelopeAsync(string channelName);
+17
View File
@@ -58,6 +58,23 @@ public record CreateChannelRequest(
/// </summary>
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>
/// 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.
+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.DTOs;
using EchoHub.Core.Models;
using EchoHub.Server.Config;
using EchoHub.Server.Data;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
@@ -26,6 +28,7 @@ public class ChannelsController : ControllerBase
private readonly IHttpClientFactory _httpClientFactory;
private readonly IChatService _chatService;
private readonly IMessageEncryptionService _encryption;
private readonly UploadLimits _uploadLimits;
public ChannelsController(
IChannelService channelService,
@@ -34,7 +37,8 @@ public class ChannelsController : ControllerBase
ImageToAsciiService asciiService,
IHttpClientFactory httpClientFactory,
IChatService chatService,
IMessageEncryptionService encryption)
IMessageEncryptionService encryption,
UploadLimits uploadLimits)
{
_channelService = channelService;
_db = db;
@@ -43,6 +47,7 @@ public class ChannelsController : ControllerBase
_httpClientFactory = httpClientFactory;
_chatService = chatService;
_encryption = encryption;
_uploadLimits = uploadLimits;
}
[HttpGet]
@@ -93,6 +98,21 @@ public class ChannelsController : ControllerBase
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>
/// 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;
@@ -152,12 +172,18 @@ public class ChannelsController : ControllerBase
/// 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.
/// </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")]
[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)
{
// 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 usernameClaim = User.FindFirstValue("username");
if (userIdClaim is null || usernameClaim is null)
@@ -179,8 +205,8 @@ public class ChannelsController : ControllerBase
var files = Request.Form.Files;
if (files.Count == 0)
return BadRequest(new ErrorResponse("At least one attachment is required. Send plain text over the chat connection."));
if (files.Count > HubConstants.MaxAttachmentsPerMessage)
return BadRequest(new ErrorResponse($"A message may carry at most {HubConstants.MaxAttachmentsPerMessage} attachments."));
if (files.Count > _uploadLimits.MaxAttachmentsPerMessage)
return BadRequest(new ErrorResponse($"A message may carry at most {_uploadLimits.MaxAttachmentsPerMessage} attachments."));
var sender = await _db.Users.FindAsync(userId);
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))
previewPlain = null;
if (file.Length > MaxForKind(kind))
if (file.Length > _uploadLimits.MaxForKind(kind))
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size."));
using var encryptedStream = file.OpenReadStream();
@@ -228,8 +254,8 @@ public class ChannelsController : ControllerBase
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
kind = isImage ? AttachmentKind.Image : isAudio ? AttachmentKind.Audio : AttachmentKind.File;
if (file.Length > MaxForKind(kind))
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size of {MaxForKind(kind) / (1024 * 1024)} MB."));
if (file.Length > _uploadLimits.MaxForKind(kind))
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size of {_uploadLimits.MaxForKind(kind) / (1024 * 1024)} MB."));
string filePath;
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
@@ -296,13 +322,6 @@ public class ChannelsController : ControllerBase
_ => AttachmentKind.File,
};
private static long MaxForKind(AttachmentKind kind) => kind switch
{
AttachmentKind.Image => HubConstants.MaxImageSizeBytes,
AttachmentKind.Audio => HubConstants.MaxAudioFileSizeBytes,
_ => HubConstants.MaxFileSizeBytes,
};
[HttpPost("{channel}/send-url")]
[EnableRateLimiting("upload")]
public async Task<IActionResult> SendUrl(string channel, [FromBody] SendUrlRequest request, [FromQuery] string? size = null)
@@ -343,13 +362,13 @@ public class ChannelsController : ControllerBase
response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength;
if (contentLength > HubConstants.MaxImageSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
if (contentLength > _uploadLimits.MaxImageSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {_uploadLimits.MaxImageSizeBytes / (1024 * 1024)} MB."));
imageBytes = await response.Content.ReadAsByteArrayAsync();
if (imageBytes.Length > HubConstants.MaxImageSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
if (imageBytes.Length > _uploadLimits.MaxImageSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {_uploadLimits.MaxImageSizeBytes / (1024 * 1024)} MB."));
fileName = Path.GetFileName(uri.LocalPath);
if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.'))
@@ -3,6 +3,7 @@ using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.Services;
using EchoHub.Core.DTOs;
using EchoHub.Server.Config;
using EchoHub.Server.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
@@ -18,11 +19,13 @@ public class UsersController : ControllerBase
{
private readonly IUserService _userService;
private readonly ImageToAsciiService _asciiService;
private readonly UploadLimits _uploadLimits;
public UsersController(IUserService userService, ImageToAsciiService asciiService)
public UsersController(IUserService userService, ImageToAsciiService asciiService, UploadLimits uploadLimits)
{
_userService = userService;
_asciiService = asciiService;
_uploadLimits = uploadLimits;
}
[HttpGet("{username}/profile")]
@@ -65,8 +68,8 @@ public class UsersController : ControllerBase
var file = Request.Form.Files[0];
if (file.Length > HubConstants.MaxAvatarSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxAvatarSizeBytes / (1024 * 1024)} MB."));
if (file.Length > _uploadLimits.MaxAvatarSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {_uploadLimits.MaxAvatarSizeBytes / (1024 * 1024)} MB."));
using var stream = file.OpenReadStream();
+9
View File
@@ -5,6 +5,7 @@ using EchoHub.Core.Contracts;
using EchoHub.Core.Services;
using EchoHub.Core.Models;
using EchoHub.Server.Auth;
using EchoHub.Server.Config;
using EchoHub.Server.Data;
using EchoHub.Server.Hubs;
using EchoHub.Server.Irc;
@@ -102,6 +103,14 @@ while (true)
builder.Services.AddControllers();
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 ─────────────────────────────────────────────────────────
builder.Services.AddSingleton<JwtTokenService>();
builder.Services.AddSingleton<PresenceTracker>();
@@ -288,6 +288,42 @@ public class ChannelService : IChannelService
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)
{
channelName = channelName.ToLowerInvariant().Trim();
@@ -20,6 +20,13 @@
"CleanupIntervalHours": 1,
"RetentionDays": 30
},
"Uploads": {
"MaxFileSizeMB": 100,
"MaxImageSizeMB": 10,
"MaxAudioSizeMB": 10,
"MaxAvatarSizeMB": 2,
"MaxAttachmentsPerMessage": 10
},
"Encryption": {
"Key": "",
"EncryptDatabase": false
+18
View File
@@ -472,4 +472,22 @@ public class CommandHandlerTests
Assert.True(result.Handled);
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) =>
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) =>
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);
}
}