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,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
|
||||
|
||||
Reference in New Issue
Block a user