mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: add test sound command and enable notifications by default
This commit is contained in:
@@ -8,6 +8,8 @@
|
|||||||
- `ModerationController` with full REST API for role assignment, kicks, bans, mutes, message deletion, and channel nuking
|
- `ModerationController` with full REST API for role assignment, kicks, bans, mutes, message deletion, and channel nuking
|
||||||
- Mutes support optional duration (auto-expire) and blocked users cannot log in
|
- Mutes support optional duration (auto-expire) and blocked users cannot log in
|
||||||
- Role claim included in JWT tokens; role badges shown in the online users panel
|
- Role claim included in JWT tokens; role badges shown in the online users panel
|
||||||
|
- Kicked and banned users are forcibly disconnected in real time — server cleans up presence, broadcasts departures, and signals client disconnect
|
||||||
|
- Works for both SignalR and IRC connections; client shows an error dialog with the reason
|
||||||
|
|
||||||
### Private Channels
|
### Private Channels
|
||||||
- Channels can be created as public or private via a checkbox in the Create Channel dialog
|
- Channels can be created as public or private via a checkbox in the Create Channel dialog
|
||||||
@@ -18,13 +20,13 @@
|
|||||||
|
|
||||||
### OpenGraph Link Embeds
|
### OpenGraph Link Embeds
|
||||||
- Messages containing URLs now show a rich preview below the message text
|
- Messages containing URLs now show a rich preview below the message text
|
||||||
- Server-side fetching: detects the first URL in a message, fetches the page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:image`, `og:site_name`)
|
- Multiple URLs per message supported (up to 3) — each gets its own embed
|
||||||
- OG images are converted to a small 24x12 colored ASCII thumbnail using the existing half-block renderer
|
- Server-side fetching: detects URLs in a message, fetches each page, and parses OpenGraph meta tags (`og:title`, `og:description`, `og:site_name`)
|
||||||
- Embeds are persisted in the database and included in channel history
|
- Embeds are persisted in the database as a JSON array and included in channel history
|
||||||
- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray, followed by the ASCII thumbnail
|
- TUI client renders embeds with a `▏` left border bar — site name and border in blue, title in white, description in gray; text word-wraps at actual viewport width
|
||||||
- IRC gateway receives a text-only embed preview (site name, title, description) — no ASCII thumbnail to keep IRC output clean
|
- IRC gateway receives a text-only embed preview (site name, title, description)
|
||||||
- Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found
|
- Falls back to `<title>` tag when no OG tags are present; gracefully skips if no useful metadata is found
|
||||||
- 3-second fetch timeout ensures message delivery is never significantly delayed
|
- 5-second fetch timeout ensures message delivery is never significantly delayed
|
||||||
- SSRF protection rejects private/loopback IP addresses before fetching
|
- SSRF protection rejects private/loopback IP addresses before fetching
|
||||||
|
|
||||||
### Notification Sounds
|
### Notification Sounds
|
||||||
@@ -67,13 +69,16 @@
|
|||||||
- Emoji-to-text shortcode conversion for consistent cross-platform rendering
|
- Emoji-to-text shortcode conversion for consistent cross-platform rendering
|
||||||
- Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted
|
- Fixed `/send` and `/avatar` commands not handling file paths with spaces correctly, even when quoted
|
||||||
- Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30
|
- Server-side newline spam protection — consecutive blank/whitespace-only lines collapsed to 1 and total lines capped at 30
|
||||||
|
- Fixed OG tag regex truncating descriptions containing apostrophes (e.g. `"HueByte's portfolio"` was cut to `"HueByte"`) — switched to backreference-based quote pairing
|
||||||
|
|
||||||
## Infrastructure
|
## Infrastructure
|
||||||
|
|
||||||
- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via regex, image thumbnail generation
|
- New `LinkEmbedService` on the server — URL detection, HTML fetching (first 64KB), OG tag parsing via compiled regex
|
||||||
- `EmbedDto` record added to shared Core DTOs; `MessageDto` extended with optional `Embed` field (backward-compatible)
|
- `EmbedDto` record added to shared Core DTOs; `MessageDto.Embeds` list for multiple embeds per message
|
||||||
- `EmbedJson` nullable column on the `Message` table stores serialized embed data (max 8KB)
|
- `EmbedJson` nullable column on the `Message` table stores serialized embed data as JSON array (max 8KB); `DataMigrationService` auto-migrates old single-object format
|
||||||
- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout
|
- Dedicated `"OgFetch"` named HttpClient with bot User-Agent header and 5-second timeout
|
||||||
|
- `PresenceTracker.ForceRemoveUser()` for atomic user cleanup on kick/ban
|
||||||
|
- `IChatBroadcaster.ForceDisconnectUserAsync()` and `IEchoHubClient.ForceDisconnect` for force-disconnect signaling
|
||||||
- `NotificationSoundService` for cross-platform audio playback of embedded notification sounds
|
- `NotificationSoundService` for cross-platform audio playback of embedded notification sounds
|
||||||
- Startup `DataMigrationService` automatically converts old ANSI-format messages to the new color tag format on server boot, logging the count of migrated records
|
- Startup `DataMigrationService` automatically converts old ANSI-format messages to the new color tag format on server boot, logging the count of migrated records
|
||||||
- `EmojiHelper` utility for emoji-to-shortcode conversion
|
- `EmojiHelper` utility for emoji-to-shortcode conversion
|
||||||
|
|||||||
@@ -347,6 +347,11 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
await _apiClient!.NukeChannelAsync(channel);
|
await _apiClient!.NukeChannelAsync(channel);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
_commandHandler.OnTestSound += async () =>
|
||||||
|
{
|
||||||
|
await _notificationSound.PlayTestAsync();
|
||||||
|
};
|
||||||
|
|
||||||
_commandHandler.OnQuit += () =>
|
_commandHandler.OnQuit += () =>
|
||||||
{
|
{
|
||||||
InvokeUI(() => _app.RequestStop());
|
InvokeUI(() => _app.RequestStop());
|
||||||
@@ -788,7 +793,6 @@ public sealed class AppOrchestrator : IDisposable
|
|||||||
InvokeUI(() => _mainWindow.AddMessage(message));
|
InvokeUI(() => _mainWindow.AddMessage(message));
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(_currentUsername)
|
if (!string.IsNullOrEmpty(_currentUsername)
|
||||||
&& !message.SenderUsername.Equals(_currentUsername, StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& message.Content.Contains($"@{_currentUsername}", StringComparison.OrdinalIgnoreCase))
|
&& message.Content.Contains($"@{_currentUsername}", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
_ = _notificationSound.PlayAsync();
|
_ = _notificationSound.PlayAsync();
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public class CommandHandler
|
|||||||
public event Func<string, Task>? OnUnmuteUser;
|
public event Func<string, Task>? OnUnmuteUser;
|
||||||
public event Func<string, string, Task>? OnAssignRole;
|
public event Func<string, string, Task>? OnAssignRole;
|
||||||
public event Func<Task>? OnNukeChannel;
|
public event Func<Task>? OnNukeChannel;
|
||||||
|
public event Func<Task>? OnTestSound;
|
||||||
public event Func<Task>? OnQuit;
|
public event Func<Task>? OnQuit;
|
||||||
public event Func<Task>? OnHelp;
|
public event Func<Task>? OnHelp;
|
||||||
|
|
||||||
@@ -60,6 +61,7 @@ public class CommandHandler
|
|||||||
"unmute" => await HandleUnmute(args),
|
"unmute" => await HandleUnmute(args),
|
||||||
"role" => await HandleRole(args),
|
"role" => await HandleRole(args),
|
||||||
"nuke" => await HandleNuke(),
|
"nuke" => await HandleNuke(),
|
||||||
|
"test-sound" => await HandleTestSound(),
|
||||||
"quit" or "exit" => await HandleQuit(),
|
"quit" or "exit" => await HandleQuit(),
|
||||||
"help" or "?" => await HandleHelp(),
|
"help" or "?" => await HandleHelp(),
|
||||||
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
|
_ => new CommandResult(true, $"Unknown command: /{command}. Type /help for available commands.", IsError: true),
|
||||||
@@ -319,6 +321,13 @@ public class CommandHandler
|
|||||||
return new CommandResult(true, "Nuking channel history...");
|
return new CommandResult(true, "Nuking channel history...");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<CommandResult> HandleTestSound()
|
||||||
|
{
|
||||||
|
if (OnTestSound is not null)
|
||||||
|
await OnTestSound();
|
||||||
|
return new CommandResult(true, "Playing notification sound...");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task<CommandResult> HandleHelp()
|
private async Task<CommandResult> HandleHelp()
|
||||||
{
|
{
|
||||||
if (OnHelp is not null)
|
if (OnHelp is not null)
|
||||||
@@ -346,6 +355,7 @@ public class CommandHandler
|
|||||||
/unmute <user> - Unmute a user (Mod+)
|
/unmute <user> - Unmute a user (Mod+)
|
||||||
/role <user> <admin|mod|member> - Assign role (Admin+)
|
/role <user> <admin|mod|member> - Assign role (Admin+)
|
||||||
/nuke - Clear channel history (Mod+)
|
/nuke - Clear channel history (Mod+)
|
||||||
|
/test-sound - Play notification sound
|
||||||
/quit - Exit the app
|
/quit - Exit the app
|
||||||
""");
|
""");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ public class ClientConfig
|
|||||||
|
|
||||||
public class NotificationConfig
|
public class NotificationConfig
|
||||||
{
|
{
|
||||||
public bool Enabled { get; set; } = false;
|
public bool Enabled { get; set; } = true;
|
||||||
public byte Volume { get; set; } = 30;
|
public byte Volume { get; set; } = 30;
|
||||||
public string? SoundFile { get; set; }
|
public string? SoundFile { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,13 +25,29 @@ public class NotificationSoundService
|
|||||||
if (!_config.Enabled || _resolvedSoundPath is null)
|
if (!_config.Enabled || _resolvedSoundPath is null)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
await PlayInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plays the notification sound regardless of the Enabled setting (for /test-sound).
|
||||||
|
/// </summary>
|
||||||
|
public async Task PlayTestAsync()
|
||||||
|
{
|
||||||
|
if (_resolvedSoundPath is null)
|
||||||
|
return;
|
||||||
|
|
||||||
|
await PlayInternal();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PlayInternal()
|
||||||
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (_player.Playing)
|
if (_player.Playing)
|
||||||
await _player.Stop();
|
await _player.Stop();
|
||||||
|
|
||||||
await _player.SetVolume(_config.Volume);
|
await _player.SetVolume(_config.Volume);
|
||||||
await _player.Play(_resolvedSoundPath);
|
await _player.Play(_resolvedSoundPath!);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ public sealed class MainWindow : Runnable
|
|||||||
"/status", "/nick", "/color", "/theme", "/send",
|
"/status", "/nick", "/color", "/theme", "/send",
|
||||||
"/avatar", "/profile", "/servers", "/join", "/leave",
|
"/avatar", "/profile", "/servers", "/join", "/leave",
|
||||||
"/topic", "/users", "/kick", "/ban", "/unban",
|
"/topic", "/users", "/kick", "/ban", "/unban",
|
||||||
"/mute", "/unmute", "/role", "/nuke", "/quit", "/help"
|
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
|
||||||
];
|
];
|
||||||
|
|
||||||
private readonly List<string> _channelNames = [];
|
private readonly List<string> _channelNames = [];
|
||||||
@@ -923,8 +923,9 @@ public sealed class MainWindow : Runnable
|
|||||||
// Render link embeds if present
|
// Render link embeds if present
|
||||||
if (message.Embeds is { Count: > 0 })
|
if (message.Embeds is { Count: > 0 })
|
||||||
{
|
{
|
||||||
|
var chatWidth = _lastChatWidth > 0 ? _lastChatWidth : 80;
|
||||||
foreach (var embed in message.Embeds)
|
foreach (var embed in message.Embeds)
|
||||||
lines.AddRange(FormatEmbed(embed, indent));
|
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -977,115 +978,45 @@ public sealed class MainWindow : Runnable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Format a link embed as indented chat lines with a left border bar,
|
/// Format a link embed as indented chat lines with a left border bar,
|
||||||
/// optional description wrapping, and a small icon on the right (Discord-style).
|
/// text at full width, and optional icon below (preview image).
|
||||||
/// Layout: ▏ {text column} {icon column}
|
/// Each line is pre-wrapped to fit chatWidth so ChatLine.Wrap won't break layout.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent)
|
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
|
||||||
{
|
{
|
||||||
var lines = new List<ChatLine>();
|
var lines = new List<ChatLine>();
|
||||||
const string border = "\u258f "; // ▏ + space
|
const string border = "\u258f "; // ▏ + space
|
||||||
const int borderCols = 2; // ▏ = 1 col + space = 1 col
|
const int borderCols = 2;
|
||||||
const int iconGap = 1; // space between text and icon
|
int indentCols = indent.GetColumns();
|
||||||
|
int textWidth = chatWidth - indentCols - borderCols;
|
||||||
|
if (textWidth < 20) textWidth = 20;
|
||||||
|
|
||||||
// Parse icon lines if present
|
// Helper: create a bordered text line
|
||||||
var iconLines = new List<string>();
|
void AddTextLine(string text, Attribute? color)
|
||||||
int iconWidth = 0;
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.ImageAscii))
|
|
||||||
{
|
{
|
||||||
foreach (var artLine in embed.ImageAscii.Split('\n'))
|
lines.Add(new ChatLine(
|
||||||
{
|
[
|
||||||
var trimmed = artLine.TrimEnd('\r');
|
new ChatSegment(indent, null),
|
||||||
if (!string.IsNullOrEmpty(trimmed))
|
new ChatSegment(border, ChatColors.EmbedBorderAttr),
|
||||||
iconLines.Add(trimmed);
|
new ChatSegment(text, color)
|
||||||
}
|
]));
|
||||||
if (iconLines.Count > 0)
|
|
||||||
{
|
|
||||||
// Measure icon width from the first line (strip color tags for measurement)
|
|
||||||
var stripped = ChatLine.StripColorTags(iconLines[0]);
|
|
||||||
iconWidth = stripped.GetColumns();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool hasIcon = iconLines.Count > 0 && iconWidth > 0;
|
// Site name
|
||||||
int indentCols = indent.GetColumns();
|
|
||||||
|
|
||||||
// We don't know the terminal width at format time, so use a reasonable default
|
|
||||||
// for text wrapping. The ChatListSource.Render will handle final clipping.
|
|
||||||
const int estimatedWidth = 80;
|
|
||||||
int availableForText = estimatedWidth - indentCols - borderCols;
|
|
||||||
int textColWidth = hasIcon
|
|
||||||
? availableForText - iconWidth - iconGap
|
|
||||||
: availableForText;
|
|
||||||
if (textColWidth < 20) textColWidth = 20;
|
|
||||||
|
|
||||||
// Collect all text rows (site name, title, wrapped description, URL)
|
|
||||||
var textRows = new List<(string Text, Attribute? Color)>();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
if (!string.IsNullOrWhiteSpace(embed.SiteName))
|
||||||
textRows.Add((embed.SiteName, ChatColors.EmbedBorderAttr));
|
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
|
||||||
|
|
||||||
|
// Title
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Title))
|
if (!string.IsNullOrWhiteSpace(embed.Title))
|
||||||
textRows.Add((embed.Title, ChatColors.EmbedTitleAttr));
|
{
|
||||||
|
foreach (var wrapped in WordWrap(embed.Title, textWidth))
|
||||||
|
AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
|
||||||
|
}
|
||||||
|
|
||||||
// Word-wrap description
|
// Description (word-wrapped at full available width)
|
||||||
if (!string.IsNullOrWhiteSpace(embed.Description))
|
if (!string.IsNullOrWhiteSpace(embed.Description))
|
||||||
{
|
{
|
||||||
foreach (var wrappedLine in WordWrap(embed.Description, textColWidth))
|
foreach (var wrapped in WordWrap(embed.Description, textWidth))
|
||||||
textRows.Add((wrappedLine, ChatColors.EmbedDescAttr));
|
AddTextLine(wrapped, ChatColors.EmbedDescAttr);
|
||||||
}
|
|
||||||
|
|
||||||
// Dim URL at the bottom
|
|
||||||
textRows.Add((embed.Url, ChatColors.EmbedUrlAttr));
|
|
||||||
|
|
||||||
// Merge text rows with icon rows side-by-side
|
|
||||||
int totalLines = Math.Max(textRows.Count, iconLines.Count);
|
|
||||||
for (int i = 0; i < totalLines; i++)
|
|
||||||
{
|
|
||||||
var segments = new List<ChatSegment>();
|
|
||||||
segments.Add(new ChatSegment(indent, null));
|
|
||||||
segments.Add(new ChatSegment(border, ChatColors.EmbedBorderAttr));
|
|
||||||
|
|
||||||
if (i < textRows.Count)
|
|
||||||
{
|
|
||||||
var (text, color) = textRows[i];
|
|
||||||
segments.Add(new ChatSegment(text, color));
|
|
||||||
|
|
||||||
// Pad to align icon column
|
|
||||||
if (hasIcon && i < iconLines.Count)
|
|
||||||
{
|
|
||||||
int textCols = text.GetColumns();
|
|
||||||
int padding = textColWidth - textCols + iconGap;
|
|
||||||
if (padding > 0)
|
|
||||||
segments.Add(new ChatSegment(new string(' ', padding), null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (hasIcon && i < iconLines.Count)
|
|
||||||
{
|
|
||||||
// No text row, pad the full text column + gap
|
|
||||||
segments.Add(new ChatSegment(new string(' ', textColWidth + iconGap), null));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append icon line
|
|
||||||
if (hasIcon && i < iconLines.Count)
|
|
||||||
{
|
|
||||||
var iconLine = iconLines[i];
|
|
||||||
if (ChatLine.HasColorTags(iconLine))
|
|
||||||
{
|
|
||||||
// Build a composite: plain segments + colored icon
|
|
||||||
var plainPart = new ChatLine(segments);
|
|
||||||
var iconPart = ChatLine.FromColoredText(iconLine);
|
|
||||||
var merged = new List<ChatSegment>(plainPart.Segments);
|
|
||||||
merged.AddRange(iconPart.Segments);
|
|
||||||
lines.Add(new ChatLine(merged));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
segments.Add(new ChatSegment(iconLine, null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.Add(new ChatLine(segments));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return lines;
|
return lines;
|
||||||
|
|||||||
@@ -15,10 +15,8 @@ public static class HubConstants
|
|||||||
public const int AsciiArtHeightHalfBlock = 80;
|
public const int AsciiArtHeightHalfBlock = 80;
|
||||||
|
|
||||||
// Link embed constants
|
// Link embed constants
|
||||||
public const int EmbedIconWidth = 12;
|
|
||||||
public const int EmbedIconHeight = 6;
|
|
||||||
public const int EmbedMaxDescriptionLength = 500;
|
public const int EmbedMaxDescriptionLength = 500;
|
||||||
public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB
|
public const int EmbedMaxHtmlBytes = 64 * 1024; // 64 KB
|
||||||
public const int EmbedFetchTimeoutSeconds = 3;
|
public const int EmbedFetchTimeoutSeconds = 5;
|
||||||
public const int EmbedMaxUrlsPerMessage = 3;
|
public const int EmbedMaxUrlsPerMessage = 3;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,16 +10,13 @@ namespace EchoHub.Server.Services;
|
|||||||
public partial class LinkEmbedService
|
public partial class LinkEmbedService
|
||||||
{
|
{
|
||||||
private readonly IHttpClientFactory _httpClientFactory;
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private readonly ImageToAsciiService _asciiService;
|
|
||||||
private readonly ILogger<LinkEmbedService> _logger;
|
private readonly ILogger<LinkEmbedService> _logger;
|
||||||
|
|
||||||
public LinkEmbedService(
|
public LinkEmbedService(
|
||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
ImageToAsciiService asciiService,
|
|
||||||
ILogger<LinkEmbedService> logger)
|
ILogger<LinkEmbedService> logger)
|
||||||
{
|
{
|
||||||
_httpClientFactory = httpClientFactory;
|
_httpClientFactory = httpClientFactory;
|
||||||
_asciiService = asciiService;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,15 +108,7 @@ public partial class LinkEmbedService
|
|||||||
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
|
siteName = siteName is not null ? WebUtility.HtmlDecode(siteName) : null;
|
||||||
description = description is not null ? WebUtility.HtmlDecode(description) : null;
|
description = description is not null ? WebUtility.HtmlDecode(description) : null;
|
||||||
|
|
||||||
// Attempt to fetch OG image as small icon
|
return new EmbedDto(siteName, title, description, null, url);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<string> ExtractUrls(string content)
|
private static List<string> ExtractUrls(string content)
|
||||||
@@ -169,78 +158,26 @@ public partial class LinkEmbedService
|
|||||||
var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
var tags = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
// Match: <meta property="og:key" content="value" />
|
// Match: <meta property="og:key" content="value" />
|
||||||
|
// Groups: 1=prop quote, 2=key, 3=content quote, 4=value
|
||||||
foreach (Match match in OgTagRegex().Matches(html))
|
foreach (Match match in OgTagRegex().Matches(html))
|
||||||
{
|
{
|
||||||
var key = match.Groups[1].Value;
|
var key = match.Groups[2].Value;
|
||||||
var value = match.Groups[2].Value;
|
var value = match.Groups[4].Value;
|
||||||
tags.TryAdd(key, value);
|
tags.TryAdd(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Match reversed order: <meta content="value" property="og:key" />
|
// Match reversed order: <meta content="value" property="og:key" />
|
||||||
|
// Groups: 1=content quote, 2=value, 3=prop quote, 4=key
|
||||||
foreach (Match match in OgTagReversedRegex().Matches(html))
|
foreach (Match match in OgTagReversedRegex().Matches(html))
|
||||||
{
|
{
|
||||||
var value = match.Groups[1].Value;
|
var value = match.Groups[2].Value;
|
||||||
var key = match.Groups[2].Value;
|
var key = match.Groups[4].Value;
|
||||||
tags.TryAdd(key, value);
|
tags.TryAdd(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return tags;
|
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.EmbedIconWidth,
|
|
||||||
HubConstants.EmbedIconHeight);
|
|
||||||
}
|
|
||||||
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)
|
private static async Task<string> ReadLimitedAsync(HttpResponseMessage response, int maxBytes, CancellationToken ct)
|
||||||
{
|
{
|
||||||
await using var stream = await response.Content.ReadAsStreamAsync(ct);
|
await using var stream = await response.Content.ReadAsStreamAsync(ct);
|
||||||
@@ -263,17 +200,17 @@ public partial class LinkEmbedService
|
|||||||
return encoding.GetString(buffer, 0, totalRead);
|
return encoding.GetString(buffer, 0, totalRead);
|
||||||
}
|
}
|
||||||
|
|
||||||
[GeneratedRegex(@"https?://[^\s<>""')\]]+", RegexOptions.IgnoreCase)]
|
[GeneratedRegex(@"https?://[^\s<>""')\]]+", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||||
private static partial Regex UrlRegex();
|
private static partial Regex UrlRegex();
|
||||||
|
|
||||||
[GeneratedRegex(@"<meta\s+[^>]*?property\s*=\s*[""']og:(\w+)[""'][^>]*?content\s*=\s*[""']([^""']*)[""'][^>]*/?>",
|
[GeneratedRegex(@"<meta\s+[^>]*?property\s*=\s*([""'])og:(\w+)\1[^>]*?content\s*=\s*([""'])(.*?)\3[^>]*/?>",
|
||||||
RegexOptions.IgnoreCase | RegexOptions.Singleline)]
|
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
|
||||||
private static partial Regex OgTagRegex();
|
private static partial Regex OgTagRegex();
|
||||||
|
|
||||||
[GeneratedRegex(@"<meta\s+[^>]*?content\s*=\s*[""']([^""']*)[""'][^>]*?property\s*=\s*[""']og:(\w+)[""'][^>]*/?>",
|
[GeneratedRegex(@"<meta\s+[^>]*?content\s*=\s*([""'])(.*?)\1[^>]*?property\s*=\s*([""'])og:(\w+)\3[^>]*/?>",
|
||||||
RegexOptions.IgnoreCase | RegexOptions.Singleline)]
|
RegexOptions.IgnoreCase | RegexOptions.Singleline | RegexOptions.Compiled)]
|
||||||
private static partial Regex OgTagReversedRegex();
|
private static partial Regex OgTagReversedRegex();
|
||||||
|
|
||||||
[GeneratedRegex(@"<title[^>]*>([^<]+)</title>", RegexOptions.IgnoreCase)]
|
[GeneratedRegex(@"<title[^>]*>([^<]+)</title>", RegexOptions.IgnoreCase | RegexOptions.Compiled)]
|
||||||
private static partial Regex TitleTagRegex();
|
private static partial Regex TitleTagRegex();
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user