Enhance admin listing & metadata extraction, update healthchecks
Build and Push Docker Image / build-and-push (push) Successful in 19s

- 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
This commit is contained in:
Arne Moerman
2026-05-14 14:05:18 +02:00
parent c1b11603e8
commit b46269bfc0
8 changed files with 302 additions and 57 deletions
+1
View File
@@ -50,3 +50,4 @@ Thumbs.db
# Docker overrides
docker-compose.override.yml
/src/BirthList.Web/appsettings.Development.json
+2 -2
View File
@@ -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
@@ -20,6 +20,23 @@ else
</div>
}
<section class="mb-4">
<h2>Current admins</h2>
@if (Admins.Count == 0)
{
<p>No admins assigned yet.</p>
}
else
{
<ul>
@foreach (var admin in Admins)
{
<li>@admin.DisplayName</li>
}
</ul>
}
</section>
<section class="mb-4">
<h2>Settings</h2>
<EditForm Model="SettingsModel" OnValidSubmit="SaveSettingsAsync" FormName="registry-settings-form">
@@ -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<RegistryItemEditModel> Items { get; private set; } = [];
protected IReadOnlyList<RegistryAdminDisplayModel> 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
{
@@ -10,14 +10,17 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
public async Task<UrlMetadataResult?> 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*\\\"(?<url>https?:\\\\/\\\\/[^\\\"]+)\\\"",
"\\\"hiRes\\\"\\s*:\\s*\\\"(?<url>https?:\\\\/\\\\/[^\\\"]+)\\\"",
"<img[^>]+id=['\"]landingImage['\"][^>]+src=['\"](?<url>[^'\"]+)['\"]"
};
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*\\\"?(?<price>[0-9]+(?:[.,][0-9]{2})?)",
"id=['\"]priceblock_ourprice['\"][^>]*>\\s*(?<price>[^<]+)<",
"id=['\"]priceblock_dealprice['\"][^>]*>\\s*(?<price>[^<]+)<",
"id=['\"]price_inside_buybox['\"][^>]*>\\s*(?<price>[^<]+)<"
};
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*(?<whole>[0-9.,]+)\\s*<", RegexOptions.IgnoreCase);
var fraction = Regex.Match(html, "a-price-fraction[^>]*>\\s*(?<fraction>[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, @"(?<prefix>/.*?/dp/(?<asin>[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<string, string> ParseMeta(string html)
{
var map = new Dictionary<string, string>(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;
}
}
@@ -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; }
@@ -84,6 +84,20 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
.ConfigureAwait(false);
}
public async Task<IReadOnlyList<RegistryAdminDisplayModel>> 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<RegistryPublicViewModel?> GetPublicRegistryByCodeAsync(string code, string? userId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(code))
@@ -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"
}
}