From b46269bfc0d0aaefca3047cc456c6cc0e67d9ec5 Mon Sep 17 00:00:00 2001 From: Arne Moerman Date: Thu, 14 May 2026 14:05:18 +0200 Subject: [PATCH] Enhance admin listing & metadata extraction, update healthchecks - Show current admins in RegistryAdmin using new RegistryAdminDisplayModel and backend support - Improve RegistryMetadataService: better normalization, Amazon-specific extraction for images/prices, and URL normalization - Use normalized product URLs and improved metadata logic in RegistryAdmin - Add error handling for Blazored.TextEditor interop issues - Switch healthchecks in portainer-stack.yml to process checks (pidof) - Remove appsettings.Development.json contents from source control - Add RegistryAdminDisplayModel and NormalizedUrl to models --- .gitignore | 1 + deploy/portainer-stack.yml | 4 +- .../Components/Pages/RegistryAdmin.razor | 17 ++ .../Components/Pages/RegistryAdmin.razor.cs | 41 ++- .../Registries/RegistryMetadataService.cs | 237 +++++++++++++++++- .../Features/Registries/RegistryModels.cs | 7 + .../Features/Registries/RegistryService.cs | 14 ++ .../appsettings.Development.json | 38 --- 8 files changed, 302 insertions(+), 57 deletions(-) delete mode 100644 src/BirthList.Web/appsettings.Development.json diff --git a/.gitignore b/.gitignore index 51596e9..c51c70a 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ Thumbs.db # Docker overrides docker-compose.override.yml +/src/BirthList.Web/appsettings.Development.json diff --git a/deploy/portainer-stack.yml b/deploy/portainer-stack.yml index 1b01324..a108b5c 100644 --- a/deploy/portainer-stack.yml +++ b/deploy/portainer-stack.yml @@ -29,7 +29,7 @@ services: - caddy-net restart: unless-stopped healthcheck: - test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/"] + test: ["CMD-SHELL", "pidof dotnet > /dev/null || exit 1"] interval: 30s timeout: 10s retries: 3 @@ -52,7 +52,7 @@ services: - birthlist-network restart: unless-stopped healthcheck: - test: ["CMD", "/opt/mssql-tools/bin/sqlcmd", "-S", "localhost", "-U", "sa", "-P", "${SA_PASSWORD}", "-Q", "SELECT 1"] + test: ["CMD-SHELL", "pidof sqlservr > /dev/null || exit 1"] interval: 10s timeout: 3s retries: 10 diff --git a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor index c0aaf36..dea6fa9 100644 --- a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor +++ b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor @@ -20,6 +20,23 @@ else } +
+

Current admins

+ @if (Admins.Count == 0) + { +

No admins assigned yet.

+ } + else + { + + } +
+

Settings

diff --git a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs index afd2ead..c99f2e4 100644 --- a/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs +++ b/src/BirthList.Web/Components/Pages/RegistryAdmin.razor.cs @@ -4,6 +4,7 @@ using BirthList.Web.Services; using Blazored.TextEditor; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; namespace BirthList.Web.Components.Pages; @@ -23,6 +24,7 @@ public partial class RegistryAdmin : ComponentBase protected RegistrySettingsEditModel SettingsModel { get; } = new(); protected RegistryItemEditModel ItemModel { get; private set; } = new(); protected IReadOnlyList Items { get; private set; } = []; + protected IReadOnlyList Admins { get; private set; } = []; protected bool IsAuthorized { get; private set; } protected bool IsSmtpConfigured { get; private set; } protected string? InviteEmail { get; set; } @@ -50,22 +52,41 @@ public partial class RegistryAdmin : ComponentBase protected override async Task OnAfterRenderAsync(bool firstRender) { - if (!firstRender || TextEditor is null) + if (!firstRender || TextEditor is null || string.IsNullOrWhiteSpace(SettingsModel.HeaderContentHtml)) { return; } - if (!string.IsNullOrWhiteSpace(SettingsModel.HeaderContentHtml)) + try { await TextEditor.LoadHTMLContent(SettingsModel.HeaderContentHtml).ConfigureAwait(false); } + catch (JSException) + { + // Editor scripts may still be initializing on first render in some environments. + } + catch (InvalidOperationException) + { + // Ignore transient prerender/circuit timing issues. + } } protected async Task SaveSettingsAsync() { if (TextEditor is not null) { - SettingsModel.HeaderContentHtml = await TextEditor.GetHTML().ConfigureAwait(false); + try + { + SettingsModel.HeaderContentHtml = await TextEditor.GetHTML().ConfigureAwait(false); + } + catch (JSException) + { + // Preserve existing content when editor JS isn't ready yet. + } + catch (InvalidOperationException) + { + // Preserve existing content when interop isn't available. + } } await RegistryService.UpdateRegistrySettingsAsync(RegistryId, SettingsModel, CancellationToken.None).ConfigureAwait(false); @@ -125,12 +146,17 @@ public partial class RegistryAdmin : ComponentBase return; } - if (string.IsNullOrWhiteSpace(ItemModel.Name) && !string.IsNullOrWhiteSpace(metadata.Title)) + if (!string.IsNullOrWhiteSpace(metadata.NormalizedUrl)) + { + ItemModel.ProductUrl = metadata.NormalizedUrl; + } + + if ((string.IsNullOrWhiteSpace(ItemModel.Name) || string.Equals(ItemModel.Name, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Title)) { ItemModel.Name = metadata.Title; } - if (string.IsNullOrWhiteSpace(ItemModel.Description) && !string.IsNullOrWhiteSpace(metadata.Description)) + if ((string.IsNullOrWhiteSpace(ItemModel.Description) || string.Equals(ItemModel.Description, "Amazon", StringComparison.OrdinalIgnoreCase)) && !string.IsNullOrWhiteSpace(metadata.Description)) { ItemModel.Description = metadata.Description; } @@ -145,7 +171,7 @@ public partial class RegistryAdmin : ComponentBase ItemModel.PriceAmount = metadata.PriceAmount; } - if (string.IsNullOrWhiteSpace(ItemModel.CurrencyCode) && !string.IsNullOrWhiteSpace(metadata.CurrencyCode)) + if (!string.IsNullOrWhiteSpace(metadata.CurrencyCode)) { ItemModel.CurrencyCode = metadata.CurrencyCode; } @@ -165,6 +191,8 @@ public partial class RegistryAdmin : ComponentBase { await EmailSender.SendInviteAsync(InviteEmail, InviteLink).ConfigureAwait(false); } + + Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false); } private async Task LoadAsync() @@ -183,6 +211,7 @@ public partial class RegistryAdmin : ComponentBase SettingsModel.BankAccountBic = settings.BankAccountBic; } + Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false); Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false); ItemModel = new RegistryItemEditModel { diff --git a/src/BirthList.Web/Features/Registries/RegistryMetadataService.cs b/src/BirthList.Web/Features/Registries/RegistryMetadataService.cs index 7438695..da98676 100644 --- a/src/BirthList.Web/Features/Registries/RegistryMetadataService.cs +++ b/src/BirthList.Web/Features/Registries/RegistryMetadataService.cs @@ -10,14 +10,17 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto public async Task FetchAsync(string url, CancellationToken cancellationToken) { - if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + if (!Uri.TryCreate(url, UriKind.Absolute, out var sourceUri)) { return null; } + var normalizedUri = NormalizeProductUri(sourceUri); + var client = httpClientFactory.CreateClient("RegistryMetadata"); - using var request = new HttpRequestMessage(HttpMethod.Get, uri); - request.Headers.UserAgent.ParseAdd("BirthListBot/1.0"); + using var request = new HttpRequestMessage(HttpMethod.Get, normalizedUri); + request.Headers.UserAgent.ParseAdd("Mozilla/5.0 (compatible; BirthListBot/1.0)"); + request.Headers.AcceptLanguage.ParseAdd("en-US,en;q=0.9"); using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) @@ -32,25 +35,234 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto } var meta = ParseMeta(html); - meta.TryGetValue("product:price:amount", out var priceRaw); - meta.TryGetValue("product:price:currency", out var currency); + + var title = First(meta, + "product:title", + "og:title", + "twitter:title", + "title"); + + if (string.Equals(title, "Amazon", StringComparison.OrdinalIgnoreCase) || string.Equals(title, "Amazon.com", StringComparison.OrdinalIgnoreCase)) + { + title = First(meta, + "title", + "twitter:title", + "og:title"); + } + + var description = First(meta, + "og:description", + "twitter:description", + "description"); + + if (string.Equals(description, "Amazon", StringComparison.OrdinalIgnoreCase)) + { + description = null; + } + + var image = First(meta, + "twitter:image", + "og:image", + "image"); + + var priceRaw = First(meta, + "product:price:amount", + "og:price:amount", + "price", + "twitter:data1"); + + var currency = First(meta, + "product:price:currency", + "og:price:currency", + "currency"); decimal? price = null; - if (!string.IsNullOrWhiteSpace(priceRaw) && decimal.TryParse(priceRaw, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsedPrice)) + if (!string.IsNullOrWhiteSpace(priceRaw)) { - price = parsedPrice; + var cleanedPrice = Regex.Replace(priceRaw, "[^0-9.,]", string.Empty); + cleanedPrice = cleanedPrice.Replace(",", "."); + + if (decimal.TryParse(cleanedPrice, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsedPrice)) + { + price = parsedPrice; + } + } + + if (normalizedUri.Host.Contains("amazon", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(image) || IsGenericAmazonImage(image)) + { + image = ExtractAmazonImage(html) ?? image; + } + + if (!price.HasValue) + { + price = ExtractAmazonPrice(html); + } + + if (string.IsNullOrWhiteSpace(currency)) + { + currency = "EUR"; + } } return new UrlMetadataResult { - Title = First(meta, "og:title", "twitter:title", "title"), - Description = First(meta, "og:description", "twitter:description", "description"), - ImageUrl = First(meta, "og:image", "twitter:image"), + NormalizedUrl = normalizedUri.AbsoluteUri, + Title = title, + Description = description, + ImageUrl = image, PriceAmount = price, CurrencyCode = string.IsNullOrWhiteSpace(currency) ? null : currency.ToUpperInvariant() }; } + private static bool IsGenericAmazonImage(string image) + { + return image.Contains("amazon.png", StringComparison.OrdinalIgnoreCase) + || image.Contains("/images/G/", StringComparison.OrdinalIgnoreCase) + || image.Contains("share-icons", StringComparison.OrdinalIgnoreCase) + || image.Contains("logo", StringComparison.OrdinalIgnoreCase); + } + + private static string? ExtractAmazonImage(string html) + { + var patterns = new [] + { + "\\\"landingImageUrl\\\"\\s*:\\s*\\\"(?https?:\\\\/\\\\/[^\\\"]+)\\\"", + "\\\"hiRes\\\"\\s*:\\s*\\\"(?https?:\\\\/\\\\/[^\\\"]+)\\\"", + "]+id=['\"]landingImage['\"][^>]+src=['\"](?[^'\"]+)['\"]" + }; + + foreach (var pattern in patterns) + { + var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase); + if (!match.Success) + { + continue; + } + + var value = match.Groups["url"].Value; + if (string.IsNullOrWhiteSpace(value)) + { + continue; + } + + value = value.Replace("\\/", "/").Replace("\\u0026", "&"); + if (Uri.TryCreate(value, UriKind.Absolute, out _)) + { + return value; + } + } + + return null; + } + + private static decimal? ExtractAmazonPrice(string html) + { + var directPatterns = new [] + { + "\\\"priceAmount\\\"\\s*:\\s*\\\"?(?[0-9]+(?:[.,][0-9]{2})?)", + "id=['\"]priceblock_ourprice['\"][^>]*>\\s*(?[^<]+)<", + "id=['\"]priceblock_dealprice['\"][^>]*>\\s*(?[^<]+)<", + "id=['\"]price_inside_buybox['\"][^>]*>\\s*(?[^<]+)<" + }; + + foreach (var pattern in directPatterns) + { + var match = Regex.Match(html, pattern, RegexOptions.IgnoreCase); + if (!match.Success) + { + continue; + } + + var parsed = ParsePrice(match.Groups["price"].Value); + if (parsed.HasValue) + { + return parsed.Value; + } + } + + var whole = Regex.Match(html, "a-price-whole[^>]*>\\s*(?[0-9.,]+)\\s*<", RegexOptions.IgnoreCase); + var fraction = Regex.Match(html, "a-price-fraction[^>]*>\\s*(?[0-9]{2})\\s*<", RegexOptions.IgnoreCase); + + if (whole.Success) + { + var combined = whole.Groups["whole"].Value + (fraction.Success ? "." + fraction.Groups["fraction"].Value : string.Empty); + return ParsePrice(combined); + } + + return null; + } + + private static decimal? ParsePrice(string raw) + { + if (string.IsNullOrWhiteSpace(raw)) + { + return null; + } + + var cleaned = Regex.Replace(raw, "[^0-9.,]", string.Empty); + if (string.IsNullOrWhiteSpace(cleaned)) + { + return null; + } + + if (cleaned.Count(c => c == ',') > 1 && cleaned.Contains('.')) + { + cleaned = cleaned.Replace(",", string.Empty); + } + + if (cleaned.Contains(',') && cleaned.Contains('.')) + { + if (cleaned.LastIndexOf(',') > cleaned.LastIndexOf('.')) + { + cleaned = cleaned.Replace(".", string.Empty).Replace(',', '.'); + } + else + { + cleaned = cleaned.Replace(",", string.Empty); + } + } + else + { + cleaned = cleaned.Replace(',', '.'); + } + + if (decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var price)) + { + return price; + } + + return null; + } + + private static Uri NormalizeProductUri(Uri uri) + { + if (!uri.Host.Contains("amazon", StringComparison.OrdinalIgnoreCase)) + { + return uri; + } + + var path = uri.AbsolutePath; + var match = Regex.Match(path, @"(?/.*?/dp/(?[A-Z0-9]{10})/?)", RegexOptions.IgnoreCase); + + if (!match.Success) + { + return new UriBuilder(uri) + { + Query = string.Empty + }.Uri; + } + + var normalizedPath = match.Groups["prefix"].Value; + return new UriBuilder(uri) + { + Path = normalizedPath, + Query = string.Empty + }.Uri; + } + private static Dictionary ParseMeta(string html) { var map = new Dictionary(StringComparer.OrdinalIgnoreCase); @@ -89,7 +301,10 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto var title = html.Substring(titleStart + 7, titleEnd - (titleStart + 7)).Trim(); if (!string.IsNullOrWhiteSpace(title)) { - map["title"] = title; + var cleanedTitle = title.Replace("- Amazon.com.be", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace("- Amazon", string.Empty, StringComparison.OrdinalIgnoreCase) + .Trim(); + map["title"] = string.IsNullOrWhiteSpace(cleanedTitle) ? title : cleanedTitle; } } diff --git a/src/BirthList.Web/Features/Registries/RegistryModels.cs b/src/BirthList.Web/Features/Registries/RegistryModels.cs index 431bf0b..7b72045 100644 --- a/src/BirthList.Web/Features/Registries/RegistryModels.cs +++ b/src/BirthList.Web/Features/Registries/RegistryModels.cs @@ -82,8 +82,15 @@ public sealed class RegistryPublicItemViewModel public bool IsGiven { get; init; } } +public sealed class RegistryAdminDisplayModel +{ + public string UserId { get; init; } = string.Empty; + public string DisplayName { get; init; } = string.Empty; +} + public sealed class UrlMetadataResult { + public string? NormalizedUrl { get; init; } public string? Title { get; init; } public string? Description { get; init; } public string? ImageUrl { get; init; } diff --git a/src/BirthList.Web/Features/Registries/RegistryService.cs b/src/BirthList.Web/Features/Registries/RegistryService.cs index ebecd60..d823385 100644 --- a/src/BirthList.Web/Features/Registries/RegistryService.cs +++ b/src/BirthList.Web/Features/Registries/RegistryService.cs @@ -84,6 +84,20 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext) .ConfigureAwait(false); } + public async Task> GetRegistryAdminsAsync(Guid registryId, CancellationToken cancellationToken) + { + return await registryDbContext.RegistryAdmins + .Where(x => x.RegistryId == registryId) + .OrderBy(x => x.AddedAtUtc) + .Select(x => new RegistryAdminDisplayModel + { + UserId = x.UserId, + DisplayName = x.UserId + }) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + } + public async Task GetPublicRegistryByCodeAsync(string code, string? userId, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(code)) diff --git a/src/BirthList.Web/appsettings.Development.json b/src/BirthList.Web/appsettings.Development.json deleted file mode 100644 index b0915dd..0000000 --- a/src/BirthList.Web/appsettings.Development.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "ConnectionStrings": { - "DefaultConnection": "Server=.;Database=BirthList;TrustServerCertificate=True;Encrypt=False;Integrated Security=true" - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "Authentication": { - "Google": { - // Google OAuth credentials: create in Google Cloud Console > APIs & Services > Credentials > OAuth 2.0 Client IDs - // For local dev redirect URI usually: https://localhost:xxxx/signin-google - "ClientId": "", - "ClientSecret": "" - }, - "Microsoft": { - // Microsoft OAuth credentials: create in Microsoft Entra ID > App registrations > New registration - // For local dev redirect URI usually: https://localhost:xxxx/signin-microsoft - "ClientId": "", - "ClientSecret": "" - } - }, - "Smtp": { - // Gmail SMTP: use smtp.gmail.com + app password (Google account with 2FA enabled) - "Host": "smtp.gmail.com", - "Port": 587, - "EnableSsl": true, - // UserName is your sending mailbox address, e.g. yourname@gmail.com - "UserName": "", - // Password should be an app password, not your normal account password - "Password": "", - // FromAddress is the sender email address displayed to recipients - "FromAddress": "", - "FromName": "Birth Registry" - } -}