mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
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:
@@ -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 =>
|
||||
|
||||
+331
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user