mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +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,2 @@
|
||||
**/obj/
|
||||
**/bin/
|
||||
@@ -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+)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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