Enhance admin listing & metadata extraction, update healthchecks
Build and Push Docker Image / build-and-push (push) Successful in 19s
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:
@@ -50,3 +50,4 @@ Thumbs.db
|
|||||||
|
|
||||||
# Docker overrides
|
# Docker overrides
|
||||||
docker-compose.override.yml
|
docker-compose.override.yml
|
||||||
|
/src/BirthList.Web/appsettings.Development.json
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ services:
|
|||||||
- caddy-net
|
- caddy-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8080/"]
|
test: ["CMD-SHELL", "pidof dotnet > /dev/null || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
@@ -52,7 +52,7 @@ services:
|
|||||||
- birthlist-network
|
- birthlist-network
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
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
|
interval: 10s
|
||||||
timeout: 3s
|
timeout: 3s
|
||||||
retries: 10
|
retries: 10
|
||||||
|
|||||||
@@ -20,6 +20,23 @@ else
|
|||||||
</div>
|
</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">
|
<section class="mb-4">
|
||||||
<h2>Settings</h2>
|
<h2>Settings</h2>
|
||||||
<EditForm Model="SettingsModel" OnValidSubmit="SaveSettingsAsync" FormName="registry-settings-form">
|
<EditForm Model="SettingsModel" OnValidSubmit="SaveSettingsAsync" FormName="registry-settings-form">
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using BirthList.Web.Services;
|
|||||||
using Blazored.TextEditor;
|
using Blazored.TextEditor;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
namespace BirthList.Web.Components.Pages;
|
namespace BirthList.Web.Components.Pages;
|
||||||
|
|
||||||
@@ -23,6 +24,7 @@ public partial class RegistryAdmin : ComponentBase
|
|||||||
protected RegistrySettingsEditModel SettingsModel { get; } = new();
|
protected RegistrySettingsEditModel SettingsModel { get; } = new();
|
||||||
protected RegistryItemEditModel ItemModel { get; private set; } = new();
|
protected RegistryItemEditModel ItemModel { get; private set; } = new();
|
||||||
protected IReadOnlyList<RegistryItemEditModel> Items { get; private set; } = [];
|
protected IReadOnlyList<RegistryItemEditModel> Items { get; private set; } = [];
|
||||||
|
protected IReadOnlyList<RegistryAdminDisplayModel> Admins { get; private set; } = [];
|
||||||
protected bool IsAuthorized { get; private set; }
|
protected bool IsAuthorized { get; private set; }
|
||||||
protected bool IsSmtpConfigured { get; private set; }
|
protected bool IsSmtpConfigured { get; private set; }
|
||||||
protected string? InviteEmail { get; set; }
|
protected string? InviteEmail { get; set; }
|
||||||
@@ -50,22 +52,41 @@ public partial class RegistryAdmin : ComponentBase
|
|||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (!firstRender || TextEditor is null)
|
if (!firstRender || TextEditor is null || string.IsNullOrWhiteSpace(SettingsModel.HeaderContentHtml))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(SettingsModel.HeaderContentHtml))
|
try
|
||||||
{
|
{
|
||||||
await TextEditor.LoadHTMLContent(SettingsModel.HeaderContentHtml).ConfigureAwait(false);
|
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()
|
protected async Task SaveSettingsAsync()
|
||||||
{
|
{
|
||||||
if (TextEditor is not null)
|
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);
|
await RegistryService.UpdateRegistrySettingsAsync(RegistryId, SettingsModel, CancellationToken.None).ConfigureAwait(false);
|
||||||
@@ -125,12 +146,17 @@ public partial class RegistryAdmin : ComponentBase
|
|||||||
return;
|
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;
|
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;
|
ItemModel.Description = metadata.Description;
|
||||||
}
|
}
|
||||||
@@ -145,7 +171,7 @@ public partial class RegistryAdmin : ComponentBase
|
|||||||
ItemModel.PriceAmount = metadata.PriceAmount;
|
ItemModel.PriceAmount = metadata.PriceAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(ItemModel.CurrencyCode) && !string.IsNullOrWhiteSpace(metadata.CurrencyCode))
|
if (!string.IsNullOrWhiteSpace(metadata.CurrencyCode))
|
||||||
{
|
{
|
||||||
ItemModel.CurrencyCode = metadata.CurrencyCode;
|
ItemModel.CurrencyCode = metadata.CurrencyCode;
|
||||||
}
|
}
|
||||||
@@ -165,6 +191,8 @@ public partial class RegistryAdmin : ComponentBase
|
|||||||
{
|
{
|
||||||
await EmailSender.SendInviteAsync(InviteEmail, InviteLink).ConfigureAwait(false);
|
await EmailSender.SendInviteAsync(InviteEmail, InviteLink).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task LoadAsync()
|
private async Task LoadAsync()
|
||||||
@@ -183,6 +211,7 @@ public partial class RegistryAdmin : ComponentBase
|
|||||||
SettingsModel.BankAccountBic = settings.BankAccountBic;
|
SettingsModel.BankAccountBic = settings.BankAccountBic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Admins = await RegistryService.GetRegistryAdminsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
||||||
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
Items = await RegistryService.GetRegistryItemsAsync(RegistryId, CancellationToken.None).ConfigureAwait(false);
|
||||||
ItemModel = new RegistryItemEditModel
|
ItemModel = new RegistryItemEditModel
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -10,14 +10,17 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
|
|||||||
|
|
||||||
public async Task<UrlMetadataResult?> FetchAsync(string url, CancellationToken cancellationToken)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var normalizedUri = NormalizeProductUri(sourceUri);
|
||||||
|
|
||||||
var client = httpClientFactory.CreateClient("RegistryMetadata");
|
var client = httpClientFactory.CreateClient("RegistryMetadata");
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
|
using var request = new HttpRequestMessage(HttpMethod.Get, normalizedUri);
|
||||||
request.Headers.UserAgent.ParseAdd("BirthListBot/1.0");
|
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);
|
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
@@ -32,25 +35,234 @@ internal sealed class RegistryMetadataService(IHttpClientFactory httpClientFacto
|
|||||||
}
|
}
|
||||||
|
|
||||||
var meta = ParseMeta(html);
|
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;
|
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
|
return new UrlMetadataResult
|
||||||
{
|
{
|
||||||
Title = First(meta, "og:title", "twitter:title", "title"),
|
NormalizedUrl = normalizedUri.AbsoluteUri,
|
||||||
Description = First(meta, "og:description", "twitter:description", "description"),
|
Title = title,
|
||||||
ImageUrl = First(meta, "og:image", "twitter:image"),
|
Description = description,
|
||||||
|
ImageUrl = image,
|
||||||
PriceAmount = price,
|
PriceAmount = price,
|
||||||
CurrencyCode = string.IsNullOrWhiteSpace(currency) ? null : currency.ToUpperInvariant()
|
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)
|
private static Dictionary<string, string> ParseMeta(string html)
|
||||||
{
|
{
|
||||||
var map = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
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();
|
var title = html.Substring(titleStart + 7, titleEnd - (titleStart + 7)).Trim();
|
||||||
if (!string.IsNullOrWhiteSpace(title))
|
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 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 sealed class UrlMetadataResult
|
||||||
{
|
{
|
||||||
|
public string? NormalizedUrl { get; init; }
|
||||||
public string? Title { get; init; }
|
public string? Title { get; init; }
|
||||||
public string? Description { get; init; }
|
public string? Description { get; init; }
|
||||||
public string? ImageUrl { get; init; }
|
public string? ImageUrl { get; init; }
|
||||||
|
|||||||
@@ -84,6 +84,20 @@ internal sealed class RegistryService(RegistryDbContext registryDbContext)
|
|||||||
.ConfigureAwait(false);
|
.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)
|
public async Task<RegistryPublicViewModel?> GetPublicRegistryByCodeAsync(string code, string? userId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(code))
|
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user