feat: implement link embed functionality to enhance message previews with metadata and thumbnails

This commit is contained in:
HueByte
2026-02-19 21:21:51 +01:00
parent 6965ba573e
commit 7167b40013
18 changed files with 740 additions and 15 deletions
@@ -22,6 +22,7 @@ public class AuthController : ControllerBase
_db = db;
_jwt = jwt;
}
[HttpPost("register")]
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
{
@@ -37,6 +37,7 @@ public class ChannelsController : ControllerBase
_httpClientFactory = httpClientFactory;
_chatService = chatService;
}
[HttpGet]
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
{
@@ -18,6 +18,7 @@ public class FilesController : ControllerBase
{
_fileStorage = fileStorage;
}
[HttpGet("{fileId}")]
public IActionResult GetFile(string fileId)
{
@@ -17,6 +17,7 @@ public class ServerController : ControllerBase
_db = db;
_config = config;
}
[HttpGet("info")]
public async Task<IActionResult> GetInfo()
{
@@ -24,6 +24,7 @@ public class UsersController : ControllerBase
_db = db;
_asciiService = asciiService;
}
[HttpGet("{username}/profile")]
public async Task<IActionResult> GetProfile(string username)
{
@@ -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<ChannelMembership>(entity =>
@@ -0,0 +1,264 @@
// <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("20260219201704_AddMessageEmbed")]
partial class AddMessageEmbed
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.3");
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("CreatedByUserId")
.HasColumnType("TEXT");
b.Property<bool>("IsPublic")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Topic")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Name")
.IsUnique();
b.ToTable("Channels");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<long>("JoinedAt")
.HasColumnType("INTEGER");
b.HasKey("UserId", "ChannelId");
b.HasIndex("ChannelId");
b.HasIndex("UserId");
b.ToTable("ChannelMemberships");
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AttachmentFileName")
.HasMaxLength(255)
.HasColumnType("TEXT");
b.Property<string>("AttachmentUrl")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<Guid>("ChannelId")
.HasColumnType("TEXT");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(8000)
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
b.Property<string>("SenderUsername")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<long>("SentAt")
.HasColumnType("INTEGER");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("ChannelId");
b.HasIndex("SentAt");
b.ToTable("Messages");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("TokenHash");
b.HasIndex("UserId");
b.ToTable("RefreshTokens");
});
modelBuilder.Entity("EchoHub.Core.Models.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarAscii")
.HasMaxLength(10000)
.HasColumnType("TEXT");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<bool>("IsBanned")
.HasColumnType("INTEGER");
b.Property<bool>("IsMuted")
.HasColumnType("INTEGER");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("MutedUntil")
.HasColumnType("INTEGER");
b.Property<string>("NicknameColor")
.HasMaxLength(7)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("StatusMessage")
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("Username")
.IsUnique();
b.ToTable("Users");
});
modelBuilder.Entity("EchoHub.Core.Models.ChannelMembership", b =>
{
b.HasOne("EchoHub.Core.Models.Channel", null)
.WithMany()
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("EchoHub.Core.Models.User", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("EchoHub.Core.Models.Message", b =>
{
b.HasOne("EchoHub.Core.Models.Channel", "Channel")
.WithMany("Messages")
.HasForeignKey("ChannelId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Channel");
});
modelBuilder.Entity("EchoHub.Core.Models.RefreshToken", b =>
{
b.HasOne("EchoHub.Core.Models.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("EchoHub.Core.Models.Channel", b =>
{
b.Navigation("Messages");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace EchoHub.Server.Data.Migrations
{
/// <inheritdoc />
public partial class AddMessageEmbed : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "EmbedJson",
table: "Messages",
type: "TEXT",
maxLength: 8000,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "EmbedJson",
table: "Messages");
}
}
}
@@ -91,6 +91,10 @@ namespace EchoHub.Server.Data.Migrations
.HasMaxLength(2000)
.HasColumnType("TEXT");
b.Property<string>("EmbedJson")
.HasMaxLength(8000)
.HasColumnType("TEXT");
b.Property<Guid>("SenderUserId")
.HasColumnType("TEXT");
+8
View File
@@ -106,6 +106,7 @@ while (true)
builder.Services.AddSingleton<PresenceTracker>();
builder.Services.AddSingleton<ImageToAsciiService>();
builder.Services.AddSingleton<FileStorageService>();
builder.Services.AddSingleton<LinkEmbedService>();
builder.Services.AddHostedService<ServerDirectoryService>();
// ── 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 =>
{
+43 -14
View File
@@ -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<IChatBroadcaster> _broadcasters;
private readonly LinkEmbedService _embedService;
private readonly ILogger<ChatService> _logger;
public ChatService(
IServiceScopeFactory scopeFactory,
PresenceTracker presenceTracker,
IEnumerable<IChatBroadcaster> broadcasters,
LinkEmbedService embedService,
ILogger<ChatService> 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<EmbedDto>(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();
}
}
@@ -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<LinkEmbedService> _logger;
public LinkEmbedService(
IHttpClientFactory httpClientFactory,
ImageToAsciiService asciiService,
ILogger<LinkEmbedService> logger)
{
_httpClientFactory = httpClientFactory;
_asciiService = asciiService;
_logger = logger;
}
/// <summary>
/// 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.
/// </summary>
public async Task<EmbedDto?> 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 <title> 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[^>]*>([^<]+)</title>", RegexOptions.IgnoreCase)]
private static partial Regex TitleTagRegex();
}