feat: add attachment file size support for messages and update upload limits

This commit is contained in:
HueByte
2026-02-21 20:37:13 +01:00
parent 35a162d013
commit bc8e2fd7da
11 changed files with 344 additions and 20 deletions
+1 -1
View File
@@ -7,7 +7,7 @@
- New `Audio` message type — uploaded audio files (`.mp3`, `.wav`, `.ogg`, `.flac`, `.aac`, `.m4a`, `.wma`) are automatically detected and categorized - New `Audio` message type — uploaded audio files (`.mp3`, `.wav`, `.ogg`, `.flac`, `.aac`, `.m4a`, `.wma`) are automatically detected and categorized
- TUI client renders audio messages with `♪ [Audio: filename] (Enter to play)` indicator - TUI client renders audio messages with `♪ [Audio: filename] (Enter to play)` indicator
- Press Enter on an audio message to open the **Audio Player dialog** with animated wave visualization, play/pause/stop controls, and volume slider - Press Enter on an audio message to open the **Audio Player dialog** with animated wave visualization, play/pause/stop controls, and volume slider
- Audio file upload limit raised to **20 MB** (separate from the 10 MB general file limit) - Per-type upload limits: **10 MB** images, **10 MB** audio, **100 MB** generic files (with Kestrel request size configured to match)
- `AudioPlaybackService` enhanced with pause/resume, volume control, and playback-finished events - `AudioPlaybackService` enhanced with pause/resume, volume control, and playback-finished events
- Fixed: wrapped audio/file messages now remain clickable on all lines (attachment metadata propagated through word-wrap) - Fixed: wrapped audio/file messages now remain clickable on all lines (attachment metadata propagated through word-wrap)
- IRC gateway formats audio messages as `♪ [Audio: filename] url` - IRC gateway formats audio messages as `♪ [Audio: filename] url`
+18 -2
View File
@@ -1022,8 +1022,9 @@ public sealed class MainWindow : Runnable
case MessageType.Audio: case MessageType.Audio:
var audioName = message.AttachmentFileName ?? "unknown"; var audioName = message.AttachmentFileName ?? "unknown";
var audioSize = FormatFileSize(message.AttachmentFileSize);
var audioLine = BuildChatLineColored(time, senderName, senderColor, var audioLine = BuildChatLineColored(time, senderName, senderColor,
$" \u266a [Audio: {audioName}] (Enter to play)", ChatColors.AudioAttr); $" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr);
audioLine.AttachmentUrl = message.AttachmentUrl; audioLine.AttachmentUrl = message.AttachmentUrl;
audioLine.AttachmentFileName = audioName; audioLine.AttachmentFileName = audioName;
audioLine.Type = MessageType.Audio; audioLine.Type = MessageType.Audio;
@@ -1032,8 +1033,9 @@ public sealed class MainWindow : Runnable
case MessageType.File: case MessageType.File:
var fileName = message.AttachmentFileName ?? "unknown"; var fileName = message.AttachmentFileName ?? "unknown";
var fileSize = FormatFileSize(message.AttachmentFileSize);
var fileLine = BuildChatLineColored(time, senderName, senderColor, var fileLine = BuildChatLineColored(time, senderName, senderColor,
$" [File: {fileName}] (Enter to download)", ChatColors.FileAttr); $" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr);
fileLine.AttachmentUrl = message.AttachmentUrl; fileLine.AttachmentUrl = message.AttachmentUrl;
fileLine.AttachmentFileName = fileName; fileLine.AttachmentFileName = fileName;
fileLine.Type = MessageType.File; fileLine.Type = MessageType.File;
@@ -1203,4 +1205,18 @@ public sealed class MainWindow : Runnable
return result; return result;
} }
private static string FormatFileSize(long? bytes)
{
if (bytes is null or 0)
return "?";
return bytes.Value switch
{
< 1024 => $"{bytes.Value} B",
< 1024 * 1024 => $"{bytes.Value / 1024.0:F1} KB",
< 1024 * 1024 * 1024 => $"{bytes.Value / (1024.0 * 1024.0):F1} MB",
_ => $"{bytes.Value / (1024.0 * 1024.0 * 1024.0):F1} GB"
};
}
} }
+3 -2
View File
@@ -6,8 +6,9 @@ public static class HubConstants
public const string DefaultChannel = "general"; public const string DefaultChannel = "general";
public const int DefaultHistoryCount = 100; public const int DefaultHistoryCount = 100;
public const int MaxMessageLength = 2000; public const int MaxMessageLength = 2000;
public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 MB
public const int MaxAudioFileSizeBytes = 20 * 1024 * 1024; // 20 MB public const int MaxAudioFileSizeBytes = 10 * 1024 * 1024; // 10 MB
public const int MaxFileSizeBytes = 100 * 1024 * 1024; // 100 MB
public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB public const int MaxAvatarSizeBytes = 2 * 1024 * 1024; // 2 MB
public const int MaxMessageNewlines = 30; public const int MaxMessageNewlines = 30;
public const int MaxConsecutiveNewlines = 1; public const int MaxConsecutiveNewlines = 1;
+1
View File
@@ -12,6 +12,7 @@ public record MessageDto(
string? AttachmentUrl, string? AttachmentUrl,
string? AttachmentFileName, string? AttachmentFileName,
DateTimeOffset SentAt, DateTimeOffset SentAt,
long? AttachmentFileSize = null,
List<EmbedDto>? Embeds = null); List<EmbedDto>? Embeds = null);
public record ChannelDto( public record ChannelDto(
+1
View File
@@ -7,6 +7,7 @@ public class Message
public MessageType Type { get; set; } = MessageType.Text; public MessageType Type { get; set; } = MessageType.Text;
public string? AttachmentUrl { get; set; } public string? AttachmentUrl { get; set; }
public string? AttachmentFileName { get; set; } public string? AttachmentFileName { get; set; }
public long? AttachmentFileSize { get; set; }
public string? EmbedJson { get; set; } public string? EmbedJson { get; set; }
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
@@ -107,6 +107,8 @@ public class ChannelsController : ControllerBase
[HttpPost("{channel}/upload")] [HttpPost("{channel}/upload")]
[EnableRateLimiting("upload")] [EnableRateLimiting("upload")]
[RequestSizeLimit(HubConstants.MaxFileSizeBytes)]
[RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)]
public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null) public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
{ {
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
@@ -130,17 +132,17 @@ public class ChannelsController : ControllerBase
var file = Request.Form.Files[0]; var file = Request.Form.Files[0];
// Detect file type early so we can apply the correct size limit // Detect file type early so we can apply the correct size limit
var isAudioByExtension = FileValidationHelper.IsAudioFile(file.FileName); using var stream = file.OpenReadStream();
var maxSize = isAudioByExtension ? HubConstants.MaxAudioFileSizeBytes : HubConstants.MaxFileSizeBytes; var isImage = FileValidationHelper.IsValidImage(stream);
var isAudio = !isImage && FileValidationHelper.IsAudioFile(file.FileName);
var maxSize = isImage ? HubConstants.MaxImageSizeBytes
: isAudio ? HubConstants.MaxAudioFileSizeBytes
: HubConstants.MaxFileSizeBytes;
if (file.Length > maxSize) if (file.Length > maxSize)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {maxSize / (1024 * 1024)} MB."));
// Detect file type: image (magic bytes), audio (extension), or generic file
using var stream = file.OpenReadStream();
var isImage = FileValidationHelper.IsValidImage(stream);
var isAudio = !isImage && isAudioByExtension;
var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName); var (fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
var messageType = isImage ? MessageType.Image var messageType = isImage ? MessageType.Image
@@ -170,6 +172,7 @@ public class ChannelsController : ControllerBase
Type = messageType, Type = messageType,
AttachmentUrl = attachmentUrl, AttachmentUrl = attachmentUrl,
AttachmentFileName = file.FileName, AttachmentFileName = file.FileName,
AttachmentFileSize = file.Length,
SentAt = DateTimeOffset.UtcNow, SentAt = DateTimeOffset.UtcNow,
ChannelId = channelDto.Id, ChannelId = channelDto.Id,
SenderUserId = userId, SenderUserId = userId,
@@ -189,7 +192,8 @@ public class ChannelsController : ControllerBase
messageType, messageType,
attachmentUrl, attachmentUrl,
file.FileName, file.FileName,
message.SentAt); message.SentAt,
file.Length);
await _chatService.BroadcastMessageAsync(channelName, messageDto); await _chatService.BroadcastMessageAsync(channelName, messageDto);
@@ -232,13 +236,13 @@ public class ChannelsController : ControllerBase
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var contentLength = response.Content.Headers.ContentLength; var contentLength = response.Content.Headers.ContentLength;
if (contentLength > HubConstants.MaxFileSizeBytes) if (contentLength > HubConstants.MaxImageSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB."));
imageBytes = await response.Content.ReadAsByteArrayAsync(); imageBytes = await response.Content.ReadAsByteArrayAsync();
if (imageBytes.Length > HubConstants.MaxFileSizeBytes) if (imageBytes.Length > HubConstants.MaxImageSizeBytes)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.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('.'))
@@ -290,6 +294,7 @@ public class ChannelsController : ControllerBase
Type = MessageType.Image, Type = MessageType.Image,
AttachmentUrl = attachmentUrl, AttachmentUrl = attachmentUrl,
AttachmentFileName = fileName, AttachmentFileName = fileName,
AttachmentFileSize = imageBytes.Length,
SentAt = DateTimeOffset.UtcNow, SentAt = DateTimeOffset.UtcNow,
ChannelId = channelDto.Id, ChannelId = channelDto.Id,
SenderUserId = userId, SenderUserId = userId,
@@ -309,7 +314,8 @@ public class ChannelsController : ControllerBase
MessageType.Image, MessageType.Image,
attachmentUrl, attachmentUrl,
fileName, fileName,
message.SentAt); message.SentAt,
imageBytes.Length);
await _chatService.BroadcastMessageAsync(channelName, messageDto); await _chatService.BroadcastMessageAsync(channelName, messageDto);
@@ -0,0 +1,267 @@
// <auto-generated />
using System;
using EchoHub.Server.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
[DbContext(typeof(EchoHubDbContext))]
[Migration("20260221193444_AddAttachmentFileSize")]
partial class AddAttachmentFileSize
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<long>("JoinedAt")
.HasColumnType("INTEGER");
b.HasKey("UserId", "ChannelId");
b.HasIndex("ChannelId");
b.HasIndex("UserId");
b.ToTable("ChannelMemberships");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AttachmentFileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long?>("AttachmentFileSize")
.HasColumnType("INTEGER");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(16000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(32000)
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
b.Property<string>("SenderUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long>("SentAt")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenHash");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarAscii")
.HasMaxLength(10000)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<bool>("IsMuted")
.HasColumnType("INTEGER");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("MutedUntil")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor")
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("StatusMessage")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.HasOne("EchoHub.Core.Models.Channel", null)
.WithMany()
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EchoHub.Core.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
.WithMany("Messages")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Channel");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.HasOne("EchoHub.Core.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddAttachmentFileSize : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "AttachmentFileSize",
table: "Messages",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AttachmentFileSize",
table: "Messages");
}
}
}
@@ -79,6 +79,9 @@ namespace EchoHub.Server.Data.Migrations
.HasMaxLength(255) .HasMaxLength(255)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<long?>("AttachmentFileSize")
.HasColumnType("INTEGER");
b.Property<string>("AttachmentUrl") b.Property<string>("AttachmentUrl")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("TEXT"); .HasColumnType("TEXT");
+2 -1
View File
@@ -213,7 +213,7 @@ public class ChatService : IChatService
null, null,
null, null,
message.SentAt, message.SentAt,
embeds); Embeds: embeds);
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
@@ -416,6 +416,7 @@ public class ChatService : IChatService
x.m.AttachmentUrl, x.m.AttachmentUrl,
x.m.AttachmentFileName, x.m.AttachmentFileName,
x.m.SentAt, x.m.SentAt,
x.m.AttachmentFileSize,
embeds); embeds);
}).ToList(); }).ToList();
} }
@@ -12,7 +12,7 @@ public class IrcMessageFormatterTests
{ {
return new MessageDto( return new MessageDto(
Guid.NewGuid(), content, sender, null, channel, Guid.NewGuid(), content, sender, null, channel,
MessageType.Text, null, null, DateTimeOffset.UtcNow, embeds); MessageType.Text, null, null, DateTimeOffset.UtcNow, Embeds: embeds);
} }
private static MessageDto CreateImageMessage(string asciiArt, string fileName = "image.png", private static MessageDto CreateImageMessage(string asciiArt, string fileName = "image.png",