feat: Add support for message attachments

- Introduced Attachment model to handle file attachments associated with messages.
- Updated ModerationController to manage message deletions and attachment cleanup.
- Enhanced ChatService to include attachments in message retrieval.
- Implemented migration for legacy single-attachment messages to the new Attachments model.
- Added unit tests for attachment handling in message formatting and parsing.
- Updated database context and migrations to support new Attachments table.
This commit is contained in:
HueByte
2026-07-16 05:02:12 +02:00
parent e05b420ce9
commit 2d33773c24
33 changed files with 1843 additions and 454 deletions
@@ -1,6 +1,7 @@
using System.Security.Claims;
using EchoHub.Core.Constants;
using EchoHub.Core.Contracts;
using EchoHub.Core.Security;
using EchoHub.Core.Services;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
@@ -144,11 +145,18 @@ public class ChannelsController : ControllerBase
return NoContent();
}
[HttpPost("{channel}/upload")]
/// <summary>
/// Sends one message carrying optional text (<c>content</c> form field) plus zero or more
/// file attachments (Discord-style). For non-encrypted channels the server sniffs each file's
/// kind and renders ASCII previews for images. For end-to-end encrypted channels the client
/// 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>
[HttpPost("{channel}/messages")]
[EnableRateLimiting("upload")]
[RequestSizeLimit(HubConstants.MaxFileSizeBytes)]
[RequestFormLimits(MultipartBodyLengthLimit = HubConstants.MaxFileSizeBytes)]
public async Task<IActionResult> Upload(string channel, [FromQuery] string? size = null)
[RequestSizeLimit((long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
[RequestFormLimits(MultipartBodyLengthLimit = (long)HubConstants.MaxFileSizeBytes * HubConstants.MaxAttachmentsPerMessage)]
public async Task<IActionResult> SendMessageWithAttachments(string channel, [FromQuery] string? size = null)
{
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
var usernameClaim = User.FindFirstValue("username");
@@ -165,114 +173,136 @@ public class ChannelsController : ControllerBase
if (channelDto is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
if (!Request.HasFormContentType || Request.Form.Files.Count == 0)
return BadRequest(new ErrorResponse("No file uploaded."));
if (!Request.HasFormContentType)
return BadRequest(new ErrorResponse("Expected multipart form data."));
var file = Request.Form.Files[0];
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."));
MessageType messageType;
string content;
string fileId;
var sender = await _db.Users.FindAsync(userId);
if (sender is not null && sender.IsMuted && (sender.MutedUntil is null || sender.MutedUntil > DateTimeOffset.UtcNow))
return StatusCode(403, new ErrorResponse("You are muted and cannot send messages."));
if (channelDto.IsEncrypted)
// Caption: plaintext for normal channels, $RC1$ room-ciphertext for encrypted ones.
// Decrypt() is a pass-through when there is no transport prefix.
var content = _encryption.Decrypt(Request.Form["content"].ToString());
var isRoomCiphertext = RoomCrypto.IsRoomCiphertext(content);
if (!isRoomCiphertext && content.Length > HubConstants.MaxMessageLength)
return BadRequest(new ErrorResponse($"Message exceeds maximum length of {HubConstants.MaxMessageLength} characters."));
var declaredKinds = Request.Form["kind"];
var declaredPreviews = Request.Form["preview"];
var attachmentEntities = new List<Attachment>();
var attachmentDtos = new List<AttachmentDto>();
for (var i = 0; i < files.Count; i++)
{
// E2E-encrypted channel: the blob is ciphertext the server cannot inspect.
// The client declares the type and supplies pre-rendered, room-encrypted
// content (ASCII art for images, encrypted filename otherwise).
messageType = Request.Form["type"].ToString().ToLowerInvariant() switch
var file = files[i];
AttachmentKind kind;
string? previewPlain;
string fileId;
if (channelDto.IsEncrypted)
{
"image" => MessageType.Image,
"audio" => MessageType.Audio,
_ => MessageType.File,
};
// Ciphertext blob — trust the client's declared kind + room-encrypted preview.
// Client sends one kind + preview per file in order; empty preview means none.
kind = ParseKind(i < declaredKinds.Count ? declaredKinds[i] : null);
previewPlain = i < declaredPreviews.Count ? declaredPreviews[i] : null;
if (string.IsNullOrEmpty(previewPlain))
previewPlain = null;
var declaredMax = messageType switch
{
MessageType.Image => HubConstants.MaxImageSizeBytes,
MessageType.Audio => HubConstants.MaxAudioFileSizeBytes,
_ => HubConstants.MaxFileSizeBytes,
};
if (file.Length > declaredMax)
return BadRequest(new ErrorResponse($"File size exceeds maximum of {declaredMax / (1024 * 1024)} MB."));
if (file.Length > MaxForKind(kind))
return BadRequest(new ErrorResponse($"'{file.FileName}' exceeds the maximum size."));
var clientContent = Request.Form["content"].ToString();
content = string.IsNullOrEmpty(clientContent) ? file.FileName : clientContent;
using var encryptedStream = file.OpenReadStream();
(fileId, _) = await _fileStorage.SaveFileAsync(encryptedStream, file.FileName);
}
else
{
// Detect file type early so we can apply the correct size limit
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."));
string filePath;
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
messageType = isImage ? MessageType.Image
: isAudio ? MessageType.Audio
: MessageType.File;
if (isImage)
{
var (w, h) = ImageToAsciiService.GetDimensions(size);
using var imageStream = System.IO.File.OpenRead(filePath);
content = _asciiService.ConvertToAscii(imageStream, w, h);
using var encryptedStream = file.OpenReadStream();
(fileId, _) = await _fileStorage.SaveFileAsync(encryptedStream, file.FileName);
}
else
{
content = file.FileName;
using var stream = file.OpenReadStream();
var isImage = FileValidationHelper.IsValidImage(stream);
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."));
string filePath;
(fileId, filePath) = await _fileStorage.SaveFileAsync(stream, file.FileName);
if (isImage)
{
var (w, h) = ImageToAsciiService.GetDimensions(size);
using var imageStream = System.IO.File.OpenRead(filePath);
previewPlain = _asciiService.ConvertToAscii(imageStream, w, h);
}
else
{
previewPlain = null;
}
}
var url = $"/api/files/{fileId}";
attachmentEntities.Add(new Attachment
{
Id = Guid.NewGuid(),
Kind = kind,
Url = url,
FileName = file.FileName,
FileSize = file.Length,
AsciiPreview = _encryption.EncryptDatabaseEnabled ? _encryption.EncryptNullable(previewPlain) : previewPlain,
});
attachmentDtos.Add(new AttachmentDto(kind, url, file.FileName, file.Length,
_encryption.EncryptNullable(previewPlain)));
}
var attachmentUrl = $"/api/files/{fileId}";
var sender = await _db.Users.FindAsync(userId);
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
var message = new Message
{
Id = Guid.NewGuid(),
Content = dbContent,
Type = messageType,
AttachmentUrl = attachmentUrl,
AttachmentFileName = file.FileName,
AttachmentFileSize = file.Length,
SentAt = DateTimeOffset.UtcNow,
ChannelId = channelDto.Id,
SenderUserId = userId,
SenderUsername = usernameClaim,
Attachments = attachmentEntities,
};
_db.Messages.Add(message);
await _db.SaveChangesAsync();
// Encrypt for transport — clients decrypt
var messageDto = new MessageDto(
message.Id,
_encryption.Encrypt(content),
message.SenderUsername,
sender?.NicknameColor,
channelName,
messageType,
attachmentUrl,
file.FileName,
message.SentAt,
file.Length);
attachmentDtos);
await _chatService.BroadcastMessageAsync(channelName, messageDto);
return Ok(messageDto);
}
private static AttachmentKind ParseKind(string? kind) => kind?.ToLowerInvariant() switch
{
"image" => AttachmentKind.Image,
"audio" => AttachmentKind.Audio,
_ => 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)
@@ -353,46 +383,49 @@ public class ChannelsController : ControllerBase
// Save file and convert to ASCII
var (fileId, filePath) = await _fileStorage.SaveFileAsync(memoryStream, fileName);
string content;
string preview;
var (w, h) = ImageToAsciiService.GetDimensions(size);
using (var imageStream = System.IO.File.OpenRead(filePath))
{
content = _asciiService.ConvertToAscii(imageStream, w, h);
preview = _asciiService.ConvertToAscii(imageStream, w, h);
}
var attachmentUrl = $"/api/files/{fileId}";
var sender = await _db.Users.FindAsync(userId);
var dbContent = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(content) : content;
// A URL-shared image is a message with no caption and one image attachment.
var attachment = new Attachment
{
Id = Guid.NewGuid(),
Kind = AttachmentKind.Image,
Url = attachmentUrl,
FileName = fileName,
FileSize = imageBytes.Length,
AsciiPreview = _encryption.EncryptDatabaseEnabled ? _encryption.Encrypt(preview) : preview,
};
var message = new Message
{
Id = Guid.NewGuid(),
Content = dbContent,
Type = MessageType.Image,
AttachmentUrl = attachmentUrl,
AttachmentFileName = fileName,
AttachmentFileSize = imageBytes.Length,
Content = string.Empty,
SentAt = DateTimeOffset.UtcNow,
ChannelId = channelDto.Id,
SenderUserId = userId,
SenderUsername = usernameClaim,
Attachments = [attachment],
};
_db.Messages.Add(message);
await _db.SaveChangesAsync();
// Encrypt for transport — clients decrypt
var messageDto = new MessageDto(
message.Id,
_encryption.Encrypt(content),
_encryption.Encrypt(string.Empty),
message.SenderUsername,
sender?.NicknameColor,
channelName,
MessageType.Image,
attachmentUrl,
fileName,
message.SentAt,
imageBytes.Length);
[new AttachmentDto(AttachmentKind.Image, attachmentUrl, fileName, imageBytes.Length, _encryption.Encrypt(preview))]);
await _chatService.BroadcastMessageAsync(channelName, messageDto);
@@ -20,17 +20,20 @@ public class ModerationController : ControllerBase
private readonly EchoHubDbContext _db;
private readonly IChatService _chatService;
private readonly PresenceTracker _presenceTracker;
private readonly FileStorageService _fileStorage;
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
public ModerationController(
EchoHubDbContext db,
IChatService chatService,
PresenceTracker presenceTracker,
FileStorageService fileStorage,
IEnumerable<IChatBroadcaster> broadcasters)
{
_db = db;
_chatService = chatService;
_presenceTracker = presenceTracker;
_fileStorage = fileStorage;
_broadcasters = broadcasters;
}
@@ -170,17 +173,47 @@ public class ModerationController : ControllerBase
[HttpDelete("messages/{messageId:guid}")]
public async Task<IActionResult> DeleteMessage(Guid messageId)
{
var (_, error) = await GetCallerAsync(ServerRole.Mod);
if (error is not null) return error;
// Any authenticated user may reach this; permission depends on authorship + role hierarchy.
var userIdClaim = User.FindFirstValue(ClaimTypes.NameIdentifier);
if (userIdClaim is null)
return Unauthorized(new ErrorResponse("Authentication required."));
var caller = await _db.Users.FindAsync(Guid.Parse(userIdClaim));
if (caller is null)
return Unauthorized(new ErrorResponse("User not found."));
var message = await _db.Messages
.Include(m => m.Channel)
.Include(m => m.Attachments)
.FirstOrDefaultAsync(m => m.Id == messageId);
if (message is null)
return NotFound(new ErrorResponse("Message not found."));
var isOwnMessage = message.SenderUserId == caller.Id;
if (!isOwnMessage)
{
// Deleting someone else's message requires Mod+ AND a strictly higher role than
// the message author (so a mod can't delete an admin's/owner's message).
if (caller.Role < ServerRole.Mod)
return StatusCode(403, new ErrorResponse("You can only delete your own messages."));
var author = await _db.Users.FindAsync(message.SenderUserId);
var authorRole = author?.Role ?? ServerRole.Member;
if (authorRole >= caller.Role)
return StatusCode(403, new ErrorResponse("You cannot delete a message from a user with an equal or higher role."));
}
var channelName = message.Channel!.Name;
// Remove attachment blobs from disk before the DB rows cascade away.
foreach (var attachment in message.Attachments)
{
var fileId = attachment.Url.Split('/').LastOrDefault();
if (!string.IsNullOrEmpty(fileId))
_fileStorage.DeleteFile(fileId);
}
_db.Messages.Remove(message);
await _db.SaveChangesAsync();
@@ -200,7 +233,19 @@ public class ModerationController : ControllerBase
if (dbChannel is null)
return NotFound(new ErrorResponse($"Channel '{channelName}' does not exist."));
var messages = await _db.Messages.Where(m => m.ChannelId == dbChannel.Id).ToListAsync();
var messages = await _db.Messages
.Where(m => m.ChannelId == dbChannel.Id)
.Include(m => m.Attachments)
.ToListAsync();
foreach (var fileId in messages
.SelectMany(m => m.Attachments)
.Select(a => a.Url.Split('/').LastOrDefault())
.Where(id => !string.IsNullOrEmpty(id)))
{
_fileStorage.DeleteFile(fileId!);
}
_db.Messages.RemoveRange(messages);
await _db.SaveChangesAsync();
@@ -10,6 +10,7 @@ public class EchoHubDbContext : DbContext
public DbSet<User> Users => Set<User>();
public DbSet<Channel> Channels => Set<Channel>();
public DbSet<Message> Messages => Set<Message>();
public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<RefreshToken> RefreshTokens => Set<RefreshToken>();
public DbSet<ChannelMembership> ChannelMemberships => Set<ChannelMembership>();
@@ -63,6 +64,21 @@ public class EchoHubDbContext : DbContext
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
entity.Property(m => m.EmbedJson).HasMaxLength(32000); // Increased for encrypted embed JSON
entity.HasMany(m => m.Attachments)
.WithOne(a => a.Message)
.HasForeignKey(a => a.MessageId)
.OnDelete(DeleteBehavior.Cascade);
});
modelBuilder.Entity<Attachment>(entity =>
{
entity.HasKey(a => a.Id);
entity.HasIndex(a => a.MessageId);
entity.Property(a => a.Kind).HasConversion<int>();
entity.Property(a => a.Url).IsRequired().HasMaxLength(500);
entity.Property(a => a.FileName).IsRequired().HasMaxLength(255);
entity.Property(a => a.AsciiPreview).HasMaxLength(64000); // color-tag ASCII art, encrypted-at-rest overhead
});
modelBuilder.Entity<ChannelMembership>(entity =>
@@ -0,0 +1,331 @@
// <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("20260716020211_AddMessageAttachments")]
partial class AddMessageAttachments
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AsciiPreview")
.HasMaxLength(64000)
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<Guid>("MessageId")
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MessageId");
b.ToTable("Attachments");
});
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<string>("EncryptionSalt")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("WrappedRoomKey")
.HasMaxLength(200)
.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.Attachment", b =>
{
b.HasOne("EchoHub.Core.Models.Message", "Message")
.WithMany("Attachments")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Message");
});
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");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Navigation("Attachments");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddMessageAttachments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Attachments",
columns: table => new
{
Id = table.Column<Guid>(type: "TEXT", nullable: false),
MessageId = table.Column<Guid>(type: "TEXT", nullable: false),
Kind = table.Column<int>(type: "INTEGER", nullable: false),
Url = table.Column<string>(type: "TEXT", maxLength: 500, nullable: false),
FileName = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
FileSize = table.Column<long>(type: "INTEGER", nullable: false),
AsciiPreview = table.Column<string>(type: "TEXT", maxLength: 64000, nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Attachments", x => x.Id);
table.ForeignKey(
name: "FK_Attachments_Messages_MessageId",
column: x => x.MessageId,
principalTable: "Messages",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Attachments_MessageId",
table: "Attachments",
column: "MessageId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "Attachments");
}
}
}
@@ -17,6 +17,42 @@ namespace EchoHub.Server.Data.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AsciiPreview")
.HasMaxLength(64000)
.HasColumnType("TEXT");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<long>("FileSize")
.HasColumnType("INTEGER");
b.Property<int>("Kind")
.HasColumnType("INTEGER");
b.Property<Guid>("MessageId")
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("MessageId");
b.ToTable("Attachments");
});
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{
b.Property<Guid>("Id")
@@ -229,6 +265,17 @@ namespace EchoHub.Server.Data.Migrations
b.ToTable("Users");
});
modelBuilder.Entity("EchoHub.Core.Models.Attachment", b =>
{
b.HasOne("EchoHub.Core.Models.Message", "Message")
.WithMany("Attachments")
.HasForeignKey("MessageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Message");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.HasOne("EchoHub.Core.Models.Channel", null)
@@ -270,6 +317,11 @@ namespace EchoHub.Server.Data.Migrations
{
b.Navigation("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Navigation("Attachments");
});
#pragma warning restore 612, 618
}
}
+20 -8
View File
@@ -214,7 +214,6 @@ public class ChatService : IChatService
{
Id = Guid.NewGuid(),
Content = dbContent,
Type = MessageType.Text,
SentAt = DateTimeOffset.UtcNow,
ChannelId = channel.Id,
SenderUserId = userId,
@@ -233,9 +232,6 @@ public class ChatService : IChatService
message.SenderUsername,
sender?.NicknameColor,
channelName,
MessageType.Text,
null,
null,
message.SentAt,
Embeds: embeds);
@@ -386,6 +382,13 @@ public class ChatService : IChatService
raw.Reverse();
var messageIds = raw.Select(x => x.m.Id).ToList();
var attachmentsByMessage = (await db.Attachments
.Where(a => messageIds.Contains(a.MessageId))
.ToListAsync())
.GroupBy(a => a.MessageId)
.ToDictionary(g => g.Key, g => g.ToList());
return raw.Select(x =>
{
// Decrypt DB content (handles both encrypted and plaintext via prefix detection)
@@ -399,6 +402,18 @@ public class ChatService : IChatService
catch { /* ignore malformed JSON */ }
}
List<AttachmentDto>? attachments = null;
if (attachmentsByMessage.TryGetValue(x.m.Id, out var atts) && atts.Count > 0)
{
attachments = atts.Select(a => new AttachmentDto(
a.Kind,
a.Url,
a.FileName,
a.FileSize,
// Preview re-encrypted for transport; client decrypts (and room-decrypts for E2E)
_encryption.EncryptNullable(_encryption.DecryptNullable(a.AsciiPreview)))).ToList();
}
// Encrypt for transport — client decrypts
return new MessageDto(
x.m.Id,
@@ -406,11 +421,8 @@ public class ChatService : IChatService
x.m.SenderUsername,
x.NicknameColor,
channelName,
x.m.Type,
x.m.AttachmentUrl,
x.m.AttachmentFileName,
x.m.SentAt,
x.m.AttachmentFileSize,
attachments,
embeds);
}).ToList();
}
@@ -20,6 +20,7 @@ public static partial class DataMigrationService
await EnsureDefaultChannelsPublicAsync(db, logger);
await MigrateAnsiMessagesAsync(db, logger);
await MigrateEmbedJsonToArrayAsync(db, logger);
await MigrateLegacyAttachmentsAsync(db, logger);
await EnsureConfiguredAdminsAsync(db, config, logger);
}
@@ -97,6 +98,59 @@ public static partial class DataMigrationService
[GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2|48;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")]
private static partial Regex AnsiColorRegex();
/// <summary>
/// Fold legacy single-attachment messages (which stored the file on the message row and,
/// for images, the ASCII art in Content) into the new Attachments model. Idempotent:
/// only migrates messages that still have a legacy AttachmentUrl and no Attachment rows.
/// After migrating, Content becomes empty (the ASCII art moves to the attachment preview)
/// and the legacy columns are nulled out.
/// </summary>
private static async Task MigrateLegacyAttachmentsAsync(EchoHubDbContext db, ILogger logger)
{
var legacy = await db.Messages
.Where(m => m.AttachmentUrl != null && m.Attachments.Count == 0)
.ToListAsync();
if (legacy.Count == 0)
return;
logger.LogInformation("Migrating {Count} legacy single-attachment messages to the attachments model...", legacy.Count);
foreach (var message in legacy)
{
var kind = message.Type switch
{
Core.Models.MessageType.Image => AttachmentKind.Image,
Core.Models.MessageType.Audio => AttachmentKind.Audio,
_ => AttachmentKind.File,
};
// For images the ASCII art lived in Content; for audio/file Content was just the
// filename (now redundant with the attachment). Either way the caption becomes empty.
var preview = kind == AttachmentKind.Image ? message.Content : null;
db.Attachments.Add(new Attachment
{
Id = Guid.NewGuid(),
MessageId = message.Id,
Kind = kind,
Url = message.AttachmentUrl!,
FileName = message.AttachmentFileName ?? "file",
FileSize = message.AttachmentFileSize ?? 0,
AsciiPreview = preview,
});
message.Content = string.Empty;
message.AttachmentUrl = null;
message.AttachmentFileName = null;
message.AttachmentFileSize = null;
message.Type = Core.Models.MessageType.Text;
}
await db.SaveChangesAsync();
logger.LogInformation("Migrated {Count} legacy attachments.", legacy.Count);
}
/// <summary>
/// Ensure usernames listed in Server:Admins config are at least Admin role.
/// Acts as a safety net in case the first registered user didn't get Owner role.