mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-07 07:36:01 +02:00
feat: implement link embed functionality to enhance message previews with metadata and thumbnails
This commit is contained in:
@@ -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 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 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 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);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
|
||||||
|
|||||||
@@ -890,6 +890,10 @@ public sealed class MainWindow : Runnable
|
|||||||
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
|
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
|
||||||
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
|
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;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -938,4 +942,67 @@ public sealed class MainWindow : Runnable
|
|||||||
segments.AddRange(ChatColors.SplitMentions(suffix));
|
segments.AddRange(ChatColors.SplitMentions(suffix));
|
||||||
return new ChatLine(segments);
|
return new ChatLine(segments);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format a link embed as indented chat lines with a left border bar.
|
||||||
|
/// </summary>
|
||||||
|
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent)
|
||||||
|
{
|
||||||
|
var lines = new List<ChatLine>();
|
||||||
|
const string border = "\u258f "; // ▏ + space
|
||||||
|
|
||||||
|
// Site name
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||||
|
{
|
||||||
|
lines.Add(new ChatLine(new List<ChatSegment>
|
||||||
|
{
|
||||||
|
new(indent, null),
|
||||||
|
new(border, ChatColors.EmbedBorderAttr),
|
||||||
|
new(embed.SiteName, ChatColors.EmbedBorderAttr)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title
|
||||||
|
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
|
{
|
||||||
|
lines.Add(new ChatLine(new List<ChatSegment>
|
||||||
|
{
|
||||||
|
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<ChatSegment>
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,4 +13,11 @@ public static class HubConstants
|
|||||||
public const int AsciiArtWidth = 80;
|
public const int AsciiArtWidth = 80;
|
||||||
public const int AsciiArtHeight = 40;
|
public const int AsciiArtHeight = 40;
|
||||||
public const int AsciiArtHeightHalfBlock = 80;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ public record MessageDto(
|
|||||||
MessageType Type,
|
MessageType Type,
|
||||||
string? AttachmentUrl,
|
string? AttachmentUrl,
|
||||||
string? AttachmentFileName,
|
string? AttachmentFileName,
|
||||||
DateTimeOffset SentAt);
|
DateTimeOffset SentAt,
|
||||||
|
EmbedDto? Embed = null);
|
||||||
|
|
||||||
public record ChannelDto(
|
public record ChannelDto(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
@@ -36,3 +37,10 @@ public record CreateChannelRequest(string Name, string? Topic = null, bool IsPub
|
|||||||
public record UpdateTopicRequest(string? Topic);
|
public record UpdateTopicRequest(string? Topic);
|
||||||
|
|
||||||
public record SendUrlRequest(string Url);
|
public record SendUrlRequest(string Url);
|
||||||
|
|
||||||
|
public record EmbedDto(
|
||||||
|
string? SiteName,
|
||||||
|
string? Title,
|
||||||
|
string? Description,
|
||||||
|
string? ImageAscii,
|
||||||
|
string Url);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public class Message
|
|||||||
public MessageType Type { get; set; } = MessageType.Text;
|
public MessageType Type { get; set; } = MessageType.Text;
|
||||||
public string? AttachmentUrl { get; set; }
|
public string? AttachmentUrl { get; set; }
|
||||||
public string? AttachmentFileName { get; set; }
|
public string? AttachmentFileName { get; set; }
|
||||||
|
public string? EmbedJson { get; set; }
|
||||||
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
public DateTimeOffset SentAt { get; set; } = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
public Guid ChannelId { get; set; }
|
public Guid ChannelId { get; set; }
|
||||||
|
|||||||
@@ -23,6 +23,10 @@ public static partial class IrcMessageFormatter
|
|||||||
case MessageType.Text:
|
case MessageType.Text:
|
||||||
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
foreach (var chunk in SplitMessage(message.Content, MaxIrcLineContentBytes))
|
||||||
lines.Add($"{prefix} PRIVMSG {ircChannel} :{chunk}");
|
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;
|
break;
|
||||||
|
|
||||||
case MessageType.Image:
|
case MessageType.Image:
|
||||||
@@ -46,6 +50,33 @@ public static partial class IrcMessageFormatter
|
|||||||
return lines;
|
return lines;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Format a link embed as IRC PRIVMSG lines (text-only, no ASCII thumbnail).
|
||||||
|
/// </summary>
|
||||||
|
private static List<string> FormatEmbed(string prefix, string ircChannel, EmbedDto embed)
|
||||||
|
{
|
||||||
|
var lines = new List<string>();
|
||||||
|
|
||||||
|
var header = new List<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Convert printable color tags ({F:RRGGBB}, {B:RRGGBB}, {X}) to ANSI escape codes for IRC clients.
|
/// 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.
|
/// Also passes through content that already uses ANSI codes unchanged.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class AuthController : ControllerBase
|
|||||||
_db = db;
|
_db = db;
|
||||||
_jwt = jwt;
|
_jwt = jwt;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("register")]
|
[HttpPost("register")]
|
||||||
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
public async Task<IActionResult> Register([FromBody] RegisterRequest request)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public class ChannelsController : ControllerBase
|
|||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_chatService = chatService;
|
_chatService = chatService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
|
public async Task<IActionResult> GetChannels([FromQuery] int offset = 0, [FromQuery] int limit = 50)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ public class FilesController : ControllerBase
|
|||||||
{
|
{
|
||||||
_fileStorage = fileStorage;
|
_fileStorage = fileStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{fileId}")]
|
[HttpGet("{fileId}")]
|
||||||
public IActionResult GetFile(string fileId)
|
public IActionResult GetFile(string fileId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ public class ServerController : ControllerBase
|
|||||||
_db = db;
|
_db = db;
|
||||||
_config = config;
|
_config = config;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("info")]
|
[HttpGet("info")]
|
||||||
public async Task<IActionResult> GetInfo()
|
public async Task<IActionResult> GetInfo()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public class UsersController : ControllerBase
|
|||||||
_db = db;
|
_db = db;
|
||||||
_asciiService = asciiService;
|
_asciiService = asciiService;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("{username}/profile")]
|
[HttpGet("{username}/profile")]
|
||||||
public async Task<IActionResult> GetProfile(string username)
|
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.SenderUsername).IsRequired().HasMaxLength(50);
|
||||||
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
|
entity.Property(m => m.AttachmentUrl).HasMaxLength(500);
|
||||||
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
entity.Property(m => m.AttachmentFileName).HasMaxLength(255);
|
||||||
|
entity.Property(m => m.EmbedJson).HasMaxLength(8000);
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity<ChannelMembership>(entity =>
|
modelBuilder.Entity<ChannelMembership>(entity =>
|
||||||
|
|||||||
+264
@@ -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)
|
.HasMaxLength(2000)
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
b.Property<string>("EmbedJson")
|
||||||
|
.HasMaxLength(8000)
|
||||||
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
b.Property<Guid>("SenderUserId")
|
b.Property<Guid>("SenderUserId")
|
||||||
.HasColumnType("TEXT");
|
.HasColumnType("TEXT");
|
||||||
|
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ while (true)
|
|||||||
builder.Services.AddSingleton<PresenceTracker>();
|
builder.Services.AddSingleton<PresenceTracker>();
|
||||||
builder.Services.AddSingleton<ImageToAsciiService>();
|
builder.Services.AddSingleton<ImageToAsciiService>();
|
||||||
builder.Services.AddSingleton<FileStorageService>();
|
builder.Services.AddSingleton<FileStorageService>();
|
||||||
|
builder.Services.AddSingleton<LinkEmbedService>();
|
||||||
builder.Services.AddHostedService<ServerDirectoryService>();
|
builder.Services.AddHostedService<ServerDirectoryService>();
|
||||||
|
|
||||||
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
// ── Chat Service + Broadcasters ─────────────────────────────────────
|
||||||
@@ -121,6 +122,13 @@ while (true)
|
|||||||
client.MaxResponseContentBufferSize = 10 * 1024 * 1024; // 10 MB
|
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 ────────────────────────────────────────────────────
|
// ── Rate Limiting ────────────────────────────────────────────────────
|
||||||
builder.Services.AddRateLimiter(options =>
|
builder.Services.AddRateLimiter(options =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using EchoHub.Core.Constants;
|
using EchoHub.Core.Constants;
|
||||||
using EchoHub.Core.Contracts;
|
using EchoHub.Core.Contracts;
|
||||||
using EchoHub.Core.DTOs;
|
using EchoHub.Core.DTOs;
|
||||||
@@ -14,17 +15,20 @@ public class ChatService : IChatService
|
|||||||
private readonly IServiceScopeFactory _scopeFactory;
|
private readonly IServiceScopeFactory _scopeFactory;
|
||||||
private readonly PresenceTracker _presenceTracker;
|
private readonly PresenceTracker _presenceTracker;
|
||||||
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
private readonly IEnumerable<IChatBroadcaster> _broadcasters;
|
||||||
|
private readonly LinkEmbedService _embedService;
|
||||||
private readonly ILogger<ChatService> _logger;
|
private readonly ILogger<ChatService> _logger;
|
||||||
|
|
||||||
public ChatService(
|
public ChatService(
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
PresenceTracker presenceTracker,
|
PresenceTracker presenceTracker,
|
||||||
IEnumerable<IChatBroadcaster> broadcasters,
|
IEnumerable<IChatBroadcaster> broadcasters,
|
||||||
|
LinkEmbedService embedService,
|
||||||
ILogger<ChatService> logger)
|
ILogger<ChatService> logger)
|
||||||
{
|
{
|
||||||
_scopeFactory = scopeFactory;
|
_scopeFactory = scopeFactory;
|
||||||
_presenceTracker = presenceTracker;
|
_presenceTracker = presenceTracker;
|
||||||
_broadcasters = broadcasters;
|
_broadcasters = broadcasters;
|
||||||
|
_embedService = embedService;
|
||||||
_logger = logger;
|
_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
|
var message = new Message
|
||||||
{
|
{
|
||||||
Id = Guid.NewGuid(),
|
Id = Guid.NewGuid(),
|
||||||
@@ -180,6 +195,7 @@ public class ChatService : IChatService
|
|||||||
ChannelId = channel.Id,
|
ChannelId = channel.Id,
|
||||||
SenderUserId = userId,
|
SenderUserId = userId,
|
||||||
SenderUsername = username,
|
SenderUsername = username,
|
||||||
|
EmbedJson = embed is not null ? JsonSerializer.Serialize(embed) : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
db.Messages.Add(message);
|
db.Messages.Add(message);
|
||||||
@@ -194,7 +210,8 @@ public class ChatService : IChatService
|
|||||||
MessageType.Text,
|
MessageType.Text,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
message.SentAt);
|
message.SentAt,
|
||||||
|
embed);
|
||||||
|
|
||||||
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
await BroadcastToAllAsync(b => b.SendMessageToChannelAsync(channelName, messageDto));
|
||||||
|
|
||||||
@@ -387,26 +404,38 @@ public class ChatService : IChatService
|
|||||||
if (channel is null)
|
if (channel is null)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
var messages = await db.Messages
|
var raw = await db.Messages
|
||||||
.Where(m => m.ChannelId == channel.Id)
|
.Where(m => m.ChannelId == channel.Id)
|
||||||
.OrderByDescending(m => m.SentAt)
|
.OrderByDescending(m => m.SentAt)
|
||||||
.Take(count)
|
.Take(count)
|
||||||
.Join(db.Users,
|
.Join(db.Users,
|
||||||
m => m.SenderUserId,
|
m => m.SenderUserId,
|
||||||
u => u.Id,
|
u => u.Id,
|
||||||
(m, u) => new MessageDto(
|
(m, u) => new { m, u.NicknameColor })
|
||||||
m.Id,
|
|
||||||
m.Content,
|
|
||||||
m.SenderUsername,
|
|
||||||
u.NicknameColor,
|
|
||||||
channelName,
|
|
||||||
m.Type,
|
|
||||||
m.AttachmentUrl,
|
|
||||||
m.AttachmentFileName,
|
|
||||||
m.SentAt))
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
messages.Reverse();
|
raw.Reverse();
|
||||||
return messages;
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user