From 7167b40013dc0b7d50aaa913fd00b2ce4a085f33 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 19 Feb 2026 21:21:51 +0100 Subject: [PATCH] feat: implement link embed functionality to enhance message previews with metadata and thumbnails --- src/EchoHub.Client/UI/ChatRenderer.cs | 3 + src/EchoHub.Client/UI/MainWindow.cs | 67 +++++ src/EchoHub.Core/Constants/HubConstants.cs | 7 + src/EchoHub.Core/DTOs/ChatDtos.cs | 10 +- src/EchoHub.Core/Models/Message.cs | 1 + src/EchoHub.Server.Irc/IrcMessageFormatter.cs | 31 ++ .../Controllers/AuthController.cs | 1 + .../Controllers/ChannelsController.cs | 1 + .../Controllers/FilesController.cs | 1 + .../Controllers/ServerController.cs | 1 + .../Controllers/UsersController.cs | 1 + src/EchoHub.Server/Data/EchoHubDbContext.cs | 1 + ...20260219201704_AddMessageEmbed.Designer.cs | 264 +++++++++++++++++ .../20260219201704_AddMessageEmbed.cs | 29 ++ .../EchoHubDbContextModelSnapshot.cs | 4 + src/EchoHub.Server/Program.cs | 8 + src/EchoHub.Server/Services/ChatService.cs | 57 +++- .../Services/LinkEmbedService.cs | 268 ++++++++++++++++++ 18 files changed, 740 insertions(+), 15 deletions(-) create mode 100644 src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.Designer.cs create mode 100644 src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.cs create mode 100644 src/EchoHub.Server/Services/LinkEmbedService.cs diff --git a/src/EchoHub.Client/UI/ChatRenderer.cs b/src/EchoHub.Client/UI/ChatRenderer.cs index ef6330a..b8b91a4 100644 --- a/src/EchoHub.Client/UI/ChatRenderer.cs +++ b/src/EchoHub.Client/UI/ChatRenderer.cs @@ -455,6 +455,9 @@ public static partial class ChatColors public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black); public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0)); public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.Black); + public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.Black); + public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.Black); + public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.Black); /// /// Split text around @mentions, giving each @word the MentionTextAttr accent color. diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 0d70ebe..ffc2a87 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -890,6 +890,10 @@ public sealed class MainWindow : Runnable var contText = $"{indent}{contentLines[i].TrimEnd('\r')}"; lines.Add(new ChatLine(ChatColors.SplitMentions(contText))); } + + // Render link embed if present + if (message.Embed is not null) + lines.AddRange(FormatEmbed(message.Embed, indent)); break; } @@ -938,4 +942,67 @@ public sealed class MainWindow : Runnable segments.AddRange(ChatColors.SplitMentions(suffix)); return new ChatLine(segments); } + + /// + /// Format a link embed as indented chat lines with a left border bar. + /// + private static List FormatEmbed(EmbedDto embed, string indent) + { + var lines = new List(); + const string border = "\u258f "; // ▏ + space + + // Site name + if (!string.IsNullOrWhiteSpace(embed.SiteName)) + { + lines.Add(new ChatLine(new List + { + new(indent, null), + new(border, ChatColors.EmbedBorderAttr), + new(embed.SiteName, ChatColors.EmbedBorderAttr) + })); + } + + // Title + if (!string.IsNullOrWhiteSpace(embed.Title)) + { + lines.Add(new ChatLine(new List + { + new(indent, null), + new(border, ChatColors.EmbedBorderAttr), + new(embed.Title, ChatColors.EmbedTitleAttr) + })); + } + + // Description (truncated) + if (!string.IsNullOrWhiteSpace(embed.Description)) + { + var desc = embed.Description.Length > 120 + ? embed.Description[..117] + "..." + : embed.Description; + + lines.Add(new ChatLine(new List + { + new(indent, null), + new(border, ChatColors.EmbedBorderAttr), + new(desc, ChatColors.EmbedDescAttr) + })); + } + + // ASCII image thumbnail + if (!string.IsNullOrWhiteSpace(embed.ImageAscii)) + { + foreach (var artLine in embed.ImageAscii.Split('\n')) + { + var trimmed = artLine.TrimEnd('\r'); + if (string.IsNullOrEmpty(trimmed)) continue; + + if (ChatLine.HasColorTags(trimmed)) + lines.Add(ChatLine.FromColoredText(indent + border + trimmed)); + else + lines.Add(new ChatLine(indent + border + trimmed)); + } + } + + return lines; + } } diff --git a/src/EchoHub.Core/Constants/HubConstants.cs b/src/EchoHub.Core/Constants/HubConstants.cs index e79e431..5b2120d 100644 --- a/src/EchoHub.Core/Constants/HubConstants.cs +++ b/src/EchoHub.Core/Constants/HubConstants.cs @@ -13,4 +13,11 @@ public static class HubConstants public const int AsciiArtWidth = 80; public const int AsciiArtHeight = 40; public const int AsciiArtHeightHalfBlock = 80; + + // Link embed constants + public const int EmbedThumbnailWidth = 24; + public const int EmbedThumbnailHeight = 12; + public const int EmbedMaxDescriptionLength = 200; + public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB + public const int EmbedFetchTimeoutSeconds = 3; } diff --git a/src/EchoHub.Core/DTOs/ChatDtos.cs b/src/EchoHub.Core/DTOs/ChatDtos.cs index d24b1f1..5c35564 100644 --- a/src/EchoHub.Core/DTOs/ChatDtos.cs +++ b/src/EchoHub.Core/DTOs/ChatDtos.cs @@ -11,7 +11,8 @@ public record MessageDto( MessageType Type, string? AttachmentUrl, string? AttachmentFileName, - DateTimeOffset SentAt); + DateTimeOffset SentAt, + EmbedDto? Embed = null); public record ChannelDto( Guid Id, @@ -36,3 +37,10 @@ public record CreateChannelRequest(string Name, string? Topic = null, bool IsPub public record UpdateTopicRequest(string? Topic); public record SendUrlRequest(string Url); + +public record EmbedDto( + string? SiteName, + string? Title, + string? Description, + string? ImageAscii, + string Url); diff --git a/src/EchoHub.Core/Models/Message.cs b/src/EchoHub.Core/Models/Message.cs index 7c35de3..2ace5ac 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 string? EmbedJson { get; set; } public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow; public Guid ChannelId { get; set; } diff --git a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs index e0963d1..774b081 100644 --- a/src/EchoHub.Server.Irc/IrcMessageFormatter.cs +++ b/src/EchoHub.Server.Irc/IrcMessageFormatter.cs @@ -23,6 +23,10 @@ public static partial class IrcMessageFormatter case MessageType.Text: foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes)) lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}"); + + // Append embed preview if present + if (message.Embed is not null) + lines.AddRange(FormatEmbed(prefix, ircChannel, message.Embed)); break; case MessageType.Image: @@ -46,6 +50,33 @@ public static partial class IrcMessageFormatter return lines; } + /// + /// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail). + /// + private static List FormatEmbed(string prefix, string ircChannel, EmbedDto embed) + { + var lines = new List(); + + var header = new List(); + if (!string.IsNullOrWhiteSpace(embed.SiteName)) + header.Add(embed.SiteName); + if (!string.IsNullOrWhiteSpace(embed.Title)) + header.Add(embed.Title); + + if (header.Count > 0) + lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {string.Join(" \u2014 ", header)}"); + + if (!string.IsNullOrWhiteSpace(embed.Description)) + { + var desc = embed.Description.Length > 200 + ? embed.Description[..197] + "..." + : embed.Description; + lines.Add($"{prefix} PRIVMSG {ircChannel} :\u2502 {desc}"); + } + + return lines; + } + /// /// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients. /// Also passes through content that already uses ANSI codes unchanged. diff --git a/src/EchoHub.Server/Controllers/AuthController.cs b/src/EchoHub.Server/Controllers/AuthController.cs index 39702e3..ce61426 100644 --- a/src/EchoHub.Server/Controllers/AuthController.cs +++ b/src/EchoHub.Server/Controllers/AuthController.cs @@ -22,6 +22,7 @@ public class AuthController : ControllerBase _db = db; _jwt = jwt; } + [HttpPost("register")] public async Task Register([FromBody] RegisterRequest request) { diff --git a/src/EchoHub.Server/Controllers/ChannelsController.cs b/src/EchoHub.Server/Controllers/ChannelsController.cs index 6631e50..5db6ccf 100644 --- a/src/EchoHub.Server/Controllers/ChannelsController.cs +++ b/src/EchoHub.Server/Controllers/ChannelsController.cs @@ -37,6 +37,7 @@ public class ChannelsController : ControllerBase _httpClientFactory = httpClientFactory; _chatService = chatService; } + [HttpGet] public async Task GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50) { diff --git a/src/EchoHub.Server/Controllers/FilesController.cs b/src/EchoHub.Server/Controllers/FilesController.cs index 2364171..2bcacf9 100644 --- a/src/EchoHub.Server/Controllers/FilesController.cs +++ b/src/EchoHub.Server/Controllers/FilesController.cs @@ -18,6 +18,7 @@ public class FilesController : ControllerBase { _fileStorage = fileStorage; } + [HttpGet("{fileId}")] public IActionResult GetFile(string fileId) { diff --git a/src/EchoHub.Server/Controllers/ServerController.cs b/src/EchoHub.Server/Controllers/ServerController.cs index 383840b..8b47906 100644 --- a/src/EchoHub.Server/Controllers/ServerController.cs +++ b/src/EchoHub.Server/Controllers/ServerController.cs @@ -17,6 +17,7 @@ public class ServerController : ControllerBase _db = db; _config = config; } + [HttpGet("info")] public async Task GetInfo() { diff --git a/src/EchoHub.Server/Controllers/UsersController.cs b/src/EchoHub.Server/Controllers/UsersController.cs index 6909a0f..9c0d4c8 100644 --- a/src/EchoHub.Server/Controllers/UsersController.cs +++ b/src/EchoHub.Server/Controllers/UsersController.cs @@ -24,6 +24,7 @@ public class UsersController : ControllerBase _db = db; _asciiService = asciiService; } + [HttpGet("{username}/profile")] public async Task GetProfile(string username) { diff --git a/src/EchoHub.Server/Data/EchoHubDbContext.cs b/src/EchoHub.Server/Data/EchoHubDbContext.cs index f4a32ae..4eff95c 100644 --- a/src/EchoHub.Server/Data/EchoHubDbContext.cs +++ b/src/EchoHub.Server/Data/EchoHubDbContext.cs @@ -59,6 +59,7 @@ public class EchoHubDbContext : DbContext entity.Property(m => m.SenderUsername).IsRequired().HasMaxLength(50); entity.Property(m => m.AttachmentUrl).HasMaxLength(500); entity.Property(m => m.AttachmentFileName).HasMaxLength(255); + entity.Property(m => m.EmbedJson).HasMaxLength(8000); }); modelBuilder.Entity(entity => diff --git a/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.Designer.cs b/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.Designer.cs new file mode 100644 index 0000000..493ab2c --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.Designer.cs @@ -0,0 +1,264 @@ +// +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("20260219201704_AddMessageEmbed")] + partial class AddMessageEmbed + { + /// + 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("AttachmentUrl") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("ChannelId") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(2000) + .HasColumnType("TEXT"); + + b.Property("EmbedJson") + .HasMaxLength(8000) + .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/20260219201704_AddMessageEmbed.cs b/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.cs new file mode 100644 index 0000000..ede5f16 --- /dev/null +++ b/src/EchoHub.Server/Data/Migrations/20260219201704_AddMessageEmbed.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace EchoHub.Server.Data.Migrations +{ + /// + public partial class AddMessageEmbed : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "EmbedJson", + table: "Messages", + type: "TEXT", + maxLength: 8000, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EmbedJson", + table: "Messages"); + } + } +} diff --git a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs index adbf800..66f12ce 100644 --- a/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs +++ b/src/EchoHub.Server/Data/Migrations/EchoHubDbContextModelSnapshot.cs @@ -91,6 +91,10 @@ namespace EchoHub.Server.Data.Migrations .HasMaxLength(2000) .HasColumnType("TEXT"); + b.Property("EmbedJson") + .HasMaxLength(8000) + .HasColumnType("TEXT"); + b.Property("SenderUserId") .HasColumnType("TEXT"); diff --git a/src/EchoHub.Server/Program.cs b/src/EchoHub.Server/Program.cs index f5a38a9..4aa6972 100644 --- a/src/EchoHub.Server/Program.cs +++ b/src/EchoHub.Server/Program.cs @@ -106,6 +106,7 @@ while (true) builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddSingleton(); builder.Services.AddHostedService(); // ── Chat Service + Broadcasters ───────────────────────────────────── @@ -121,6 +122,13 @@ while (true) client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB }); + builder.Services.AddHttpClient("OgFetch", client => + { + client.Timeout = TimeSpan.FromSeconds(5); + client.MaxResponseContentBufferSize = 256 * 1024; // 256 KB + client.DefaultRequestHeaders.UserAgent.ParseAdd("EchoHub/1.0 (Link Preview Bot)"); + }); + // ── Rate Limiting ──────────────────────────────────────────────────── builder.Services.AddRateLimiter(options => { diff --git a/src/EchoHub.Server/Services/ChatService.cs b/src/EchoHub.Server/Services/ChatService.cs index 3232033..8dd3844 100644 --- a/src/EchoHub.Server/Services/ChatService.cs +++ b/src/EchoHub.Server/Services/ChatService.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using EchoHub.Core.Constants; using EchoHub.Core.Contracts; using EchoHub.Core.DTOs; @@ -14,17 +15,20 @@ public class ChatService : IChatService private readonly IServiceScopeFactory _scopeFactory; private readonly PresenceTracker _presenceTracker; private readonly IEnumerable _broadcasters; + private readonly LinkEmbedService _embedService; private readonly ILogger _logger; public ChatService( IServiceScopeFactory scopeFactory, PresenceTracker presenceTracker, IEnumerable broadcasters, + LinkEmbedService embedService, ILogger logger) { _scopeFactory = scopeFactory; _presenceTracker = presenceTracker; _broadcasters = broadcasters; + _embedService = embedService; _logger = logger; } @@ -171,6 +175,17 @@ public class ChatService : IChatService } } + // Attempt to fetch link embed for URLs in the message + EmbedDto? embed = null; + try + { + embed = await _embedService.TryGetEmbedAsync(content); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to fetch link embed for message in '{Channel}'", channelName); + } + var message = new Message { Id = Guid.NewGuid(), @@ -180,6 +195,7 @@ public class ChatService : IChatService ChannelId = channel.Id, SenderUserId = userId, SenderUsername = username, + EmbedJson = embed is not null ? JsonSerializer.Serialize(embed) : null, }; db.Messages.Add(message); @@ -194,7 +210,8 @@ public class ChatService : IChatService MessageType.Text, null, null, - message.SentAt); + message.SentAt, + embed); await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto)); @@ -387,26 +404,38 @@ public class ChatService : IChatService if (channel is null) return []; - var messages = await db.Messages + var raw = await db.Messages .Where(m => m.ChannelId == channel.Id) .OrderByDescending(m => m.SentAt) .Take(count) .Join(db.Users, m => m.SenderUserId, u => u.Id, - (m, u) => new MessageDto( - m.Id, - m.Content, - m.SenderUsername, - u.NicknameColor, - channelName, - m.Type, - m.AttachmentUrl, - m.AttachmentFileName, - m.SentAt)) + (m, u) => new { m, u.NicknameColor }) .ToListAsync(); - messages.Reverse(); - return messages; + raw.Reverse(); + + return raw.Select(x => + { + EmbedDto? embed = null; + if (x.m.EmbedJson is not null) + { + try { embed = JsonSerializer.Deserialize(x.m.EmbedJson); } + catch { /* ignore malformed JSON */ } + } + + return new MessageDto( + x.m.Id, + x.m.Content, + x.m.SenderUsername, + x.NicknameColor, + channelName, + x.m.Type, + x.m.AttachmentUrl, + x.m.AttachmentFileName, + x.m.SentAt, + embed); + }).ToList(); } } diff --git a/src/EchoHub.Server/Services/LinkEmbedService.cs b/src/EchoHub.Server/Services/LinkEmbedService.cs new file mode 100644 index 0000000..a854b8c --- /dev/null +++ b/src/EchoHub.Server/Services/LinkEmbedService.cs @@ -0,0 +1,268 @@ +using System.Net; +using System.Text; +using System.Text.RegularExpressions; +using EchoHub.Core.Constants; +using EchoHub.Core.DTOs; +using Microsoft.Extensions.Logging; + +namespace EchoHub.Server.Services; + +public partial class LinkEmbedService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly ImageToAsciiService _asciiService; + private readonly ILogger _logger; + + public LinkEmbedService( + IHttpClientFactory httpClientFactory, + ImageToAsciiService asciiService, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _asciiService = asciiService; + _logger = logger; + } + + /// + /// Detect the first URL in message content and attempt to fetch OG embed data. + /// Returns null if no URL found, fetch fails, or no useful OG data. + /// Never throws — all errors are caught internally. + /// + public async Task TryGetEmbedAsync(string content) + { + try + { + var url = ExtractFirstUrl(content); + if (url is null) + return null; + + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return null; + + if (uri.Scheme is not ("http" or "https")) + return null; + + if (IsPrivateHost(uri)) + return null; + + using var cts = new CancellationTokenSource( + TimeSpan.FromSeconds(HubConstants.EmbedFetchTimeoutSeconds)); + + var client = _httpClientFactory.CreateClient("OgFetch"); + + using var request = new HttpRequestMessage(HttpMethod.Get, uri); + using var response = await client.SendAsync(request, + HttpCompletionOption.ResponseHeadersRead, cts.Token); + + if (!response.IsSuccessStatusCode) + return null; + + var contentType = response.Content.Headers.ContentType?.MediaType; + if (contentType is null || !contentType.StartsWith("text/html", StringComparison.OrdinalIgnoreCase)) + return null; + + var html = await ReadLimitedAsync(response, HubConstants.EmbedMaxHtmlBytes, cts.Token); + if (string.IsNullOrWhiteSpace(html)) + return null; + + var ogTags = ParseOgTags(html); + + // Try og:title, fallback to tag + var title = ogTags.GetValueOrDefault("title"); + if (string.IsNullOrWhiteSpace(title)) + { + var titleMatch = TitleTagRegex().Match(html); + if (titleMatch.Success) + title = WebUtility.HtmlDecode(titleMatch.Groups[1].Value.Trim()); + } + + // If no title at all, nothing useful to show + if (string.IsNullOrWhiteSpace(title)) + return null; + + var siteName = ogTags.GetValueOrDefault("site_name"); + var description = ogTags.GetValueOrDefault("description"); + + // Truncate description + if (description is not null && description.Length > HubConstants.EmbedMaxDescriptionLength) + description = description[..(HubConstants.EmbedMaxDescriptionLength - 3)] + "..."; + + // HTML decode text fields + title = WebUtility.HtmlDecode(title); + siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null; + description = description is not null ? WebUtility.HtmlDecode(description) : null; + + // Attempt to fetch OG image thumbnail + string? imageAscii = null; + var imageUrl = ogTags.GetValueOrDefault("image"); + if (!string.IsNullOrWhiteSpace(imageUrl)) + { + imageAscii = await FetchImageThumbnailAsync(imageUrl, uri, cts.Token); + } + + return new EmbedDto(siteName, title, description, imageAscii, url); + } + catch (OperationCanceledException) + { + _logger.LogDebug("Embed fetch timed out for message content"); + return null; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to fetch embed"); + return null; + } + } + + private static string? ExtractFirstUrl(string content) + { + var match = UrlRegex().Match(content); + if (!match.Success) + return null; + + var url = match.Value; + + // Strip trailing punctuation that's likely not part of the URL + url = url.TrimEnd('.', ',', '!', '?', ')', ']', ';', ':'); + + return url; + } + + private static bool IsPrivateHost(Uri uri) + { + if (uri.IsLoopback) + return true; + + if (IPAddress.TryParse(uri.Host, out var ip)) + { + var bytes = ip.GetAddressBytes(); + if (bytes.Length == 4) + { + if (bytes[0] == 10) return true; + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; + if (bytes[0] == 192 && bytes[1] == 168) return true; + if (bytes[0] == 127) return true; + if (bytes[0] == 0) return true; + } + } + + // Also check hostname-based loopback + if (uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) + return true; + + return false; + } + + private static Dictionary<string, string> ParseOgTags(string html) + { + var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + + // Match: <meta property="og:key" content="value" /> + foreach (Match match in OgTagRegex().Matches(html)) + { + var key = match.Groups[1].Value; + var value = match.Groups[2].Value; + tags.TryAdd(key, value); + } + + // Match reversed order: <meta content="value" property="og:key" /> + foreach (Match match in OgTagReversedRegex().Matches(html)) + { + var value = match.Groups[1].Value; + var key = match.Groups[2].Value; + tags.TryAdd(key, value); + } + + return tags; + } + + private async Task<string?> FetchImageThumbnailAsync(string imageUrl, Uri pageUri, CancellationToken ct) + { + try + { + // Resolve relative image URLs against the page URI + if (!Uri.TryCreate(imageUrl, UriKind.Absolute, out var imageUri)) + { + if (!Uri.TryCreate(pageUri, imageUrl, out imageUri)) + return null; + } + + if (imageUri.Scheme is not ("http" or "https")) + return null; + + if (IsPrivateHost(imageUri)) + return null; + + var client = _httpClientFactory.CreateClient("OgFetch"); + using var response = await client.GetAsync(imageUri, ct); + + if (!response.IsSuccessStatusCode) + return null; + + var contentType = response.Content.Headers.ContentType?.MediaType; + if (contentType is null || !contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + return null; + + await using var stream = await response.Content.ReadAsStreamAsync(ct); + + // Buffer into a MemoryStream for validation + conversion + using var memoryStream = new MemoryStream(); + await stream.CopyToAsync(memoryStream, ct); + + if (memoryStream.Length == 0 || memoryStream.Length > HubConstants.MaxFileSizeBytes) + return null; + + memoryStream.Position = 0; + + if (!FileValidationHelper.IsValidImage(memoryStream)) + return null; + + memoryStream.Position = 0; + + return _asciiService.ConvertToAscii(memoryStream, + HubConstants.EmbedThumbnailWidth, + HubConstants.EmbedThumbnailHeight); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to fetch OG image thumbnail from {Url}", imageUrl); + return null; + } + } + + private static async Task<string> ReadLimitedAsync(HttpResponseMessage response, int maxBytes, CancellationToken ct) + { + await using var stream = await response.Content.ReadAsStreamAsync(ct); + var buffer = new byte[maxBytes]; + var totalRead = 0; + + while (totalRead < maxBytes) + { + var read = await stream.ReadAsync(buffer.AsMemory(totalRead, maxBytes - totalRead), ct); + if (read == 0) break; + totalRead += read; + } + + // Try to detect encoding from Content-Type, default to UTF-8 + var charset = response.Content.Headers.ContentType?.CharSet; + var encoding = charset is not null + ? Encoding.GetEncoding(charset) + : Encoding.UTF8; + + return encoding.GetString(buffer, 0, totalRead); + } + + [GeneratedRegex(@"https?://[^\s<>""')\]]+", RegexOptions.IgnoreCase)] + private static partial Regex UrlRegex(); + + [GeneratedRegex(@"<meta\s+[^>]*?property\s*=\s*[""']og:(\w+)[""'][^>]*?content\s*=\s*[""']([^""']*)[""'][^>]*/?>", + RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex OgTagRegex(); + + [GeneratedRegex(@"<meta\s+[^>]*?content\s*=\s*[""']([^""']*)[""'][^>]*?property\s*=\s*[""']og:(\w+)[""'][^>]*/?>", + RegexOptions.IgnoreCase | RegexOptions.Singleline)] + private static partial Regex OgTagReversedRegex(); + + [GeneratedRegex(@"<title[^>]*>([^<]+)", RegexOptions.IgnoreCase)] + private static partial Regex TitleTagRegex(); +}