diff --git a/docs/changelog/v0.2.5.md b/docs/changelog/v0.2.5.md index 156c96f..c8944a8 100644 --- a/docs/changelog/v0.2.5.md +++ b/docs/changelog/v0.2.5.md @@ -7,7 +7,7 @@ - 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 - 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 - 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` diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index ce8d919..87a771c 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -1022,8 +1022,9 @@ public sealed class MainWindow : Runnable case MessageType.Audio: var audioName = message.AttachmentFileName ?? "unknown"; + var audioSize = FormatFileSize(message.AttachmentFileSize); 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.AttachmentFileName = audioName; audioLine.Type = MessageType.Audio; @@ -1032,8 +1033,9 @@ public sealed class MainWindow : Runnable case MessageType.File: var fileName = message.AttachmentFileName ?? "unknown"; + var fileSize = FormatFileSize(message.AttachmentFileSize); var fileLine = BuildChatLineColored(time, senderName, senderColor, - $" [File: {fileName}] (Enter to download)", ChatColors.FileAttr); + $" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr); fileLine.AttachmentUrl = message.AttachmentUrl; fileLine.AttachmentFileName = fileName; fileLine.Type = MessageType.File; @@ -1203,4 +1205,18 @@ public sealed class MainWindow : Runnable 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" + }; + } } diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index 3358174..de149bf 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -6,8 +6,9 @@ public static class HubConstants public const string DefaultChannel = "general"; public const int DefaultHistoryCount = 100; public const int MaxMessageLength = 2000; - public const int MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB - public const int MaxAudioFileSizeBytes = 20 * 1024 * 1024; // 20 MB + public const int MaxImageSizeBytes = 10 * 1024 * 1024; // 10 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 MaxMessageNewlines = 30; public const int MaxConsecutiveNewlines = 1; diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index be772b2..ce742d9 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -12,6 +12,7 @@ public record MessageDto( string? AttachmentUrl, string? AttachmentFileName, DateTimeOffset SentAt, + long? AttachmentFileSize = null, List? Embeds = null); public record ChannelDto( diff --git a/src/EchoHub.Core/Models/Message.cs b/src/EchoHub.Core/Models/Message.cs index 2ace5ac..a574b16 100644 --- a/src/EchoHub.Core/Models/Message.cs +++ b/src/EchoHub.Core/Models/Message.cs @@ -7,6 +7,7 @@ public class Message public MessageType Type { get; set; } = MessageType.Text; public string? AttachmentUrl { get; set; } public string? AttachmentFileName { get; set; } + public long? AttachmentFileSize { get; set; } public string? EmbedJson { get; set; } public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 214d283..610b26c 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -107,6 +107,8 @@ public class ChannelsController : ControllerBase [HttpPost("{channel}/upload")] [EnableRateLimiting("upload")] + [RequestSizeLimit(HubConstants.MaxFileSizeBytes)] + [RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)] public async Task Upload(string channel, [FromQuery] string? size = null) { var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier); @@ -130,17 +132,17 @@ public class ChannelsController : ControllerBase var file = Request.Form.Files[0]; // Detect file type early so we can apply the correct size limit - var isAudioByExtension = FileValidationHelper.IsAudioFile(file.FileName); - var maxSize = isAudioByExtension ? HubConstants.MaxAudioFileSizeBytes : HubConstants.MaxFileSizeBytes; + using var stream = file.OpenReadStream(); + 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) 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 messageType = isImage ? MessageType.Image @@ -170,6 +172,7 @@ public class ChannelsController : ControllerBase Type = messageType, AttachmentUrl = attachmentUrl, AttachmentFileName = file.FileName, + AttachmentFileSize = file.Length, SentAt = DateTimeOffset.UtcNow, ChannelId = channelDto.Id, SenderUserId = userId, @@ -189,7 +192,8 @@ public class ChannelsController : ControllerBase messageType, attachmentUrl, file.FileName, - message.SentAt); + message.SentAt, + file.Length); await _chatService.BroadcastMessageAsync(channelName, messageDto); @@ -232,13 +236,13 @@ public class ChannelsController : ControllerBase response.EnsureSuccessStatusCode(); var contentLength = response.Content.Headers.ContentLength; - if (contentLength > HubConstants.MaxFileSizeBytes) - return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); + if (contentLength > HubConstants.MaxImageSizeBytes) + return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB.")); imageBytes = await response.Content.ReadAsByteArrayAsync(); - if (imageBytes.Length > HubConstants.MaxFileSizeBytes) - return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxFileSizeBytes / (1024 * 1024)} MB.")); + if (imageBytes.Length > HubConstants.MaxImageSizeBytes) + return BadRequest(new ErrorResponse($"File size exceeds maximum of {HubConstants.MaxImageSizeBytes / (1024 * 1024)} MB.")); fileName = Path.GetFileName(uri.LocalPath); if (string.IsNullOrWhiteSpace(fileName) || !fileName.Contains('.')) @@ -290,6 +294,7 @@ public class ChannelsController : ControllerBase Type = MessageType.Image, AttachmentUrl = attachmentUrl, AttachmentFileName = fileName, + AttachmentFileSize = imageBytes.Length, SentAt = DateTimeOffset.UtcNow, ChannelId = channelDto.Id, SenderUserId = userId, @@ -309,7 +314,8 @@ public class ChannelsController : ControllerBase MessageType.Image, attachmentUrl, fileName, - message.SentAt); + message.SentAt, + imageBytes.Length); await _chatService.BroadcastMessageAsync(channelName, messageDto); diff --git a/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.Designer.cs new file mode 100644 index 0000000..02d516e --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.Designer.cs @@ -0,0 +1,267 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("Topic") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Channels"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttachmentFileName") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("AttachmentFileSize") + .HasColumnType("INTEGER"); + + b.Property("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(16000) + .HasColumnType("TEXT"); + + b.Property("EmbedJson") + .HasMaxLength(32000) + .HasColumnType("TEXT"); + + b.Property("SenderUserId") + .HasColumnType("TEXT"); + + b.Property("SenderUsername") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("SentAt") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("ChannelId"); + + b.HasIndex("SentAt"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens"); + }); + + modelBuilder.Entity("EchoHub.Core.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarAscii") + .HasMaxLength(10000) + .HasColumnType("TEXT"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("IsBanned") + .HasColumnType("INTEGER"); + + b.Property("IsMuted") + .HasColumnType("INTEGER"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("MutedUntil") + .HasColumnType("INTEGER"); + + b.Property("NicknameColor") + .HasMaxLength(7) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("StatusMessage") + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.cs b/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.cs new file mode 100644 index 0000000..b9064a0 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260221193444_AddAttachmentFileSize.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddAttachmentFileSize : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AttachmentFileSize", + table: "Messages", + type: "INTEGER", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AttachmentFileSize", + table: "Messages"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index 715ed37..bf38795 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -79,6 +79,9 @@ namespace EchoHub.Server.Data.Migrations .HasMaxLength(255) .HasColumnType("TEXT"); + b.Property("AttachmentFileSize") + .HasColumnType("INTEGER"); + b.Property("AttachmentUrl") .HasMaxLength(500) .HasColumnType("TEXT"); diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index b8bb19c..b5010fd 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -213,7 +213,7 @@ public class ChatService : IChatService null, null, message.SentAt, - embeds); + Embeds: embeds); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); @@ -416,6 +416,7 @@ public class ChatService : IChatService x.m.AttachmentUrl, x.m.AttachmentFileName, x.m.SentAt, + x.m.AttachmentFileSize, embeds); }).ToList(); } diff --git a/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs index 07cc543..b845b50 100644 --- a/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs +++ b/src/EchoHub.Tests/Irc/IrcMessageFormatterTests.cs @@ -12,7 +12,7 @@ public class IrcMessageFormatterTests { return new MessageDto( 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",