From 33148aafcd8ffed36017901dbfea17e8bbb7c3ea Mon Sep 17 00:00:00 2001 From: r1ckstardev Date: Sun, 10 May 2026 18:52:16 +0000 Subject: [PATCH] BTCMaps v1: directory-only submission API Supersedes PR #211. Per-store OSM OAuth moves to the BTC Map plugin side (rollforsats/BTCPayServerPlugins PR #5); the plugin-builder side keeps only the directory PR submission. Drops vs PR #211: - TagOnOsmAsync / UnlistFromOsmAsync service paths and all OSM XML + changeset infrastructure (~430 lines) - TagOnOsm / UnlistFromOsm / OsmNodeId / OsmNodeType / Latitude / Longitude / OsmCategory / AcceptsLightning request fields - BtcMapsOsmResult response shape + Address sub-model - OSM-specific validators Keeps: - POST /apis/btcmaps/v1/submit opens a PR against btcpayserver/directory.btcpayserver.org's merchants.json - GET /apis/btcmaps/v1/ping - Rate limit: 5 submissions / 24h per source IP - Validation for name / url / description / type / subType / country / twitter / github / onionUrl Build clean (Release); 20 unit tests cover validation, slug, URL normalization. --- PluginBuilder.Tests/BtcMapsServiceTests.cs | 181 ++++++++++ .../APIModels/BtcMapsSubmitRequest.cs | 15 + .../APIModels/BtcMapsSubmitResponse.cs | 14 + .../Controllers/BtcMapsController.cs | 53 +++ PluginBuilder/Policies.cs | 1 + PluginBuilder/Program.cs | 15 + PluginBuilder/Services/BtcMapsService.cs | 338 ++++++++++++++++++ 7 files changed, 617 insertions(+) create mode 100644 PluginBuilder.Tests/BtcMapsServiceTests.cs create mode 100644 PluginBuilder/APIModels/BtcMapsSubmitRequest.cs create mode 100644 PluginBuilder/APIModels/BtcMapsSubmitResponse.cs create mode 100644 PluginBuilder/Controllers/BtcMapsController.cs create mode 100644 PluginBuilder/Services/BtcMapsService.cs diff --git a/PluginBuilder.Tests/BtcMapsServiceTests.cs b/PluginBuilder.Tests/BtcMapsServiceTests.cs new file mode 100644 index 0000000..f4c5b67 --- /dev/null +++ b/PluginBuilder.Tests/BtcMapsServiceTests.cs @@ -0,0 +1,181 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using PluginBuilder.APIModels; +using PluginBuilder.Services; +using Xunit; + +namespace PluginBuilder.Tests; + +public class BtcMapsServiceTests +{ + private static BtcMapsService MakeService() => + new BtcMapsService( + configuration: new ConfigurationBuilder().Build(), + logger: NullLogger.Instance); + + private static BtcMapsSubmitRequest MakeValid() => new() + { + Name = "Good Shop", + Url = "https://goodshop.example", + Description = "A very good shop.", + Type = "merchants" + }; + + [Fact] + public void Validate_AcceptsMinimalDirectoryRequest() + { + Assert.Empty(MakeService().Validate(MakeValid())); + } + + [Fact] + public void Validate_RejectsMissingName() + { + var req = MakeValid(); + req.Name = null; + Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Name)); + } + + [Fact] + public void Validate_RejectsOverlongName() + { + var req = MakeValid(); + req.Name = new string('x', 201); + Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Name)); + } + + [Fact] + public void Validate_RejectsNonHttpsUrl() + { + var req = MakeValid(); + req.Url = "http://plain.example"; + Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Url)); + } + + [Fact] + public void Validate_RejectsMissingDescription() + { + var req = MakeValid(); + req.Description = null; + Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description)); + } + + [Fact] + public void Validate_RejectsOverlongDescription() + { + var req = MakeValid(); + req.Description = new string('x', 1001); + Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description)); + } + + [Fact] + public void Validate_RejectsMissingType() + { + var req = MakeValid(); + req.Type = null; + Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Type)); + } + + [Fact] + public void Validate_RejectsInvalidType() + { + var req = MakeValid(); + req.Type = "shops"; + Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Type)); + } + + [Fact] + public void Validate_RejectsInvalidMerchantSubType() + { + var req = MakeValid(); + req.SubType = "not-a-real-subtype"; + Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.SubType)); + } + + [Fact] + public void Validate_AcceptsValidMerchantSubType() + { + var req = MakeValid(); + req.SubType = "books"; + Assert.Empty(MakeService().Validate(req)); + } + + [Fact] + public void Validate_AcceptsIsoAlpha2Country() + { + var req = MakeValid(); + req.Country = "DE"; + Assert.Empty(MakeService().Validate(req)); + } + + [Fact] + public void Validate_AcceptsGlobalCountry() + { + var req = MakeValid(); + req.Country = "GLOBAL"; + Assert.Empty(MakeService().Validate(req)); + } + + [Fact] + public void Validate_RejectsLowerCaseCountry() + { + var req = MakeValid(); + req.Country = "de"; + Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Country)); + } + + [Fact] + public void Validate_RejectsThreeLetterCountry() + { + var req = MakeValid(); + req.Country = "DEU"; + Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Country)); + } + + [Fact] + public void Validate_AcceptsOnionHttpsUrl() + { + var req = MakeValid(); + req.OnionUrl = "https://abc123.onion"; + Assert.Empty(MakeService().Validate(req)); + } + + [Fact] + public void Validate_AcceptsOnionHttpUrl() + { + // Onion v3 addresses are commonly served over http (Tor provides the transport + // encryption); the validator allows http on a .onion host explicitly. + var req = MakeValid(); + req.OnionUrl = "http://abc123.onion"; + Assert.Empty(MakeService().Validate(req)); + } + + [Fact] + public void Validate_RejectsNonOnionOnionUrl() + { + var req = MakeValid(); + req.OnionUrl = "https://example.com"; + Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.OnionUrl)); + } + + [Fact] + public void NormalizeUrl_TrimsTrailingSlashAndLowercases() + { + Assert.Equal("https://example.com", BtcMapsService.NormalizeUrl("HTTPS://Example.com/")); + Assert.Equal("https://example.com", BtcMapsService.NormalizeUrl(" https://example.com ")); + } + + [Fact] + public void Slugify_ProducesUrlSafeSegment() + { + Assert.Equal("good-shop", BtcMapsService.Slugify("Good Shop!")); + Assert.Equal("merchant", BtcMapsService.Slugify("!!!")); + } + + [Fact] + public void Slugify_CapsLengthAtFortyChars() + { + var input = new string('a', 80); + var slug = BtcMapsService.Slugify(input); + Assert.True(slug.Length <= 40); + } +} diff --git a/PluginBuilder/APIModels/BtcMapsSubmitRequest.cs b/PluginBuilder/APIModels/BtcMapsSubmitRequest.cs new file mode 100644 index 0000000..0480ccf --- /dev/null +++ b/PluginBuilder/APIModels/BtcMapsSubmitRequest.cs @@ -0,0 +1,15 @@ +namespace PluginBuilder.APIModels; + +public sealed class BtcMapsSubmitRequest +{ + public string? Name { get; set; } + public string? Url { get; set; } + public string? Description { get; set; } + + public string? Type { get; set; } + public string? SubType { get; set; } + public string? Country { get; set; } + public string? Twitter { get; set; } + public string? Github { get; set; } + public string? OnionUrl { get; set; } +} diff --git a/PluginBuilder/APIModels/BtcMapsSubmitResponse.cs b/PluginBuilder/APIModels/BtcMapsSubmitResponse.cs new file mode 100644 index 0000000..0a6c6a4 --- /dev/null +++ b/PluginBuilder/APIModels/BtcMapsSubmitResponse.cs @@ -0,0 +1,14 @@ +namespace PluginBuilder.APIModels; + +public sealed class BtcMapsSubmitResponse +{ + public BtcMapsDirectoryResult? Directory { get; set; } +} + +public sealed class BtcMapsDirectoryResult +{ + public string? PrUrl { get; set; } + public int? PrNumber { get; set; } + public string? Branch { get; set; } + public string? Skipped { get; set; } +} diff --git a/PluginBuilder/Controllers/BtcMapsController.cs b/PluginBuilder/Controllers/BtcMapsController.cs new file mode 100644 index 0000000..4b79400 --- /dev/null +++ b/PluginBuilder/Controllers/BtcMapsController.cs @@ -0,0 +1,53 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using PluginBuilder.APIModels; +using PluginBuilder.Services; + +namespace PluginBuilder.Controllers; + +[ApiController] +[AllowAnonymous] +[Route("~/apis/btcmaps/v1")] +public sealed class BtcMapsController( + BtcMapsService btcMapsService, + ILogger logger) + : ControllerBase +{ + [HttpGet("ping")] + public IActionResult Ping() => Ok(new { ok = true, service = "btcmaps", version = "v1" }); + + [HttpPost("submit")] + [EnableRateLimiting(Policies.BtcMapsSubmitRateLimit)] + public async Task Submit( + [FromBody] BtcMapsSubmitRequest? request, + CancellationToken cancellationToken) + { + if (request is null) + return BadRequest(new { errors = new[] { new ValidationError("body", "Request body is required.") } }); + + var errors = btcMapsService.Validate(request); + if (errors.Count > 0) + return BadRequest(new { errors }); + + var correlationId = Guid.NewGuid().ToString("N"); + BtcMapsDirectoryResult directory; + + try + { + directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "BTCMaps directory submission failed (correlationId={CorrelationId}) for {Name} ({Url})", + correlationId, request.Name, request.Url); + return StatusCode(StatusCodes.Status502BadGateway, new + { + error = "directory-upstream-failed", + correlationId + }); + } + + return Ok(new BtcMapsSubmitResponse { Directory = directory }); + } +} diff --git a/PluginBuilder/Policies.cs b/PluginBuilder/Policies.cs index edea65f..177d87f 100644 --- a/PluginBuilder/Policies.cs +++ b/PluginBuilder/Policies.cs @@ -4,4 +4,5 @@ public class Policies { public const string OwnPlugin = "OwnPlugin"; public const string PublicApiRateLimit = "PublicApiRateLimit"; + public const string BtcMapsSubmitRateLimit = "BtcMapsSubmitRateLimit"; } diff --git a/PluginBuilder/Program.cs b/PluginBuilder/Program.cs index 4b6bad8..5f539a3 100644 --- a/PluginBuilder/Program.cs +++ b/PluginBuilder/Program.cs @@ -241,6 +241,7 @@ public class Program }); services.AddScoped(); services.AddScoped(); + services.AddSingleton(); services.AddRateLimiter(options => { @@ -266,6 +267,20 @@ public class Program QueueLimit = 0 }); }); + options.AddPolicy(Policies.BtcMapsSubmitRateLimit, httpContext => + { + // Per-source-IP fixed window: 5 submissions per 24h. Caps automation + // abuse of /apis/btcmaps/v1/submit without throttling honest single + // submissions from a merchant. + var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(clientIp, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 5, + Window = TimeSpan.FromHours(24), + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + QueueLimit = 0 + }); + }); }); services.AddOutputCache(options => diff --git a/PluginBuilder/Services/BtcMapsService.cs b/PluginBuilder/Services/BtcMapsService.cs new file mode 100644 index 0000000..05a8b78 --- /dev/null +++ b/PluginBuilder/Services/BtcMapsService.cs @@ -0,0 +1,338 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using PluginBuilder.APIModels; + +namespace PluginBuilder.Services; + +public sealed class BtcMapsService +{ + private const string DefaultDirectoryRepo = "btcpayserver/directory.btcpayserver.org"; + private const string DefaultDirectoryMerchantsPath = "src/data/merchants.json"; + private const string UserAgent = "PluginBuilder-BtcMaps/1.0"; + + private static readonly HashSet ValidTypes = new(StringComparer.OrdinalIgnoreCase) + { + "merchants", "apps", "hosted-btcpay", "non-profits" + }; + + private static readonly HashSet ValidMerchantSubTypes = new(StringComparer.OrdinalIgnoreCase) + { + "3d-printing", "adult", "appliances-furniture", "art", "books", + "cryptocurrency-paraphernalia", "domains-hosting-vpns", "education", + "electronics", "fashion", "food", "gambling", "gift-cards", + "health-household", "holiday-travel", "jewelry", "payment-services", + "pets", "services", "software-video-games", "sports", "tools" + }; + + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public BtcMapsService( + IConfiguration configuration, + ILogger logger) + { + _configuration = configuration; + _logger = logger; + } + + public IReadOnlyList Validate(BtcMapsSubmitRequest request) + { + var errors = new List(); + + var name = (request.Name ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(name) || name.Length > 200) + errors.Add(new ValidationError(nameof(request.Name), "Required, 1-200 characters.")); + + var url = (request.Url ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(url)) + errors.Add(new ValidationError(nameof(request.Url), "Required.")); + else if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed) || parsed.Scheme != Uri.UriSchemeHttps) + errors.Add(new ValidationError(nameof(request.Url), "Must be a valid https:// URL.")); + + var description = (request.Description ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(description) || description.Length > 1000) + errors.Add(new ValidationError(nameof(request.Description), "Required, 1-1000 characters.")); + + var type = (request.Type ?? string.Empty).Trim(); + if (string.IsNullOrEmpty(type) || !ValidTypes.Contains(type)) + errors.Add(new ValidationError(nameof(request.Type), + $"Required. One of: {string.Join(", ", ValidTypes)}.")); + + if (!string.IsNullOrWhiteSpace(request.SubType)) + { + var subType = request.SubType.Trim(); + if (string.Equals(type, "merchants", StringComparison.OrdinalIgnoreCase) && + !ValidMerchantSubTypes.Contains(subType)) + { + errors.Add(new ValidationError(nameof(request.SubType), + "Invalid merchant subtype.")); + } + } + + if (!string.IsNullOrWhiteSpace(request.Country)) + { + var country = request.Country.Trim(); + if (!(country == "GLOBAL" || (country.Length == 2 && country.All(char.IsUpper)))) + errors.Add(new ValidationError(nameof(request.Country), + "Must be ISO 3166-1 alpha-2 or GLOBAL.")); + } + + if (!string.IsNullOrWhiteSpace(request.OnionUrl)) + { + if (!Uri.TryCreate(request.OnionUrl.Trim(), UriKind.Absolute, out var onionUri) || + (onionUri.Scheme != Uri.UriSchemeHttp && onionUri.Scheme != Uri.UriSchemeHttps) || + !onionUri.Host.EndsWith(".onion", StringComparison.OrdinalIgnoreCase)) + { + errors.Add(new ValidationError(nameof(request.OnionUrl), + "Must be an http(s) .onion URL.")); + } + } + + return errors; + } + + public async Task SubmitToDirectoryAsync( + BtcMapsSubmitRequest request, + CancellationToken cancellationToken = default) + { + var token = _configuration["BTCMAPS:DirectoryGithubToken"]; + if (string.IsNullOrWhiteSpace(token)) + return new BtcMapsDirectoryResult { Skipped = "directory-github-token-not-configured" }; + + var repo = _configuration["BTCMAPS:DirectoryRepo"] ?? DefaultDirectoryRepo; + var merchantsPath = _configuration["BTCMAPS:DirectoryMerchantsPath"] ?? DefaultDirectoryMerchantsPath; + + using var client = new HttpClient(); + client.BaseAddress = new Uri("https://api.github.com/"); + client.DefaultRequestHeaders.Add("User-Agent", UserAgent); + client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28"); + client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json")); + client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + + var repoInfo = await GetJsonAsync(client, $"repos/{repo}", cancellationToken); + var defaultBranch = repoInfo.GetProperty("default_branch").GetString() + ?? throw new InvalidOperationException("default_branch missing"); + + var fileInfo = await GetJsonAsync( + client, + $"repos/{repo}/contents/{merchantsPath}?ref={Uri.EscapeDataString(defaultBranch)}", + cancellationToken); + var contentB64 = fileInfo.GetProperty("content").GetString() ?? string.Empty; + var fileSha = fileInfo.GetProperty("sha").GetString() ?? string.Empty; + var currentJson = Encoding.UTF8.GetString(Convert.FromBase64String(contentB64.Replace("\n", string.Empty))); + + var merchants = JsonSerializer.Deserialize>(currentJson) + ?? throw new InvalidOperationException("merchants.json must be a JSON array"); + + var normalizedUrl = NormalizeUrl(request.Url!); + foreach (var existing in merchants) + { + if (existing.TryGetProperty("url", out var existingUrl) && + existingUrl.ValueKind == JsonValueKind.String && + NormalizeUrl(existingUrl.GetString() ?? string.Empty) == normalizedUrl) + { + var existingName = existing.TryGetProperty("name", out var n) ? n.GetString() : "(unknown)"; + return new BtcMapsDirectoryResult { Skipped = $"duplicate-url:{existingName}" }; + } + } + + var marker = BuildUrlMarker(normalizedUrl); + var openPrSearch = await GetJsonAsync( + client, + $"search/issues?q={Uri.EscapeDataString($"repo:{repo} is:pr is:open in:body \"{marker}\"")}", + cancellationToken); + if (openPrSearch.TryGetProperty("total_count", out var totalCount) && totalCount.GetInt32() > 0) + { + var firstItem = openPrSearch.GetProperty("items")[0]; + return new BtcMapsDirectoryResult + { + Skipped = "duplicate-open-pr", + PrUrl = firstItem.TryGetProperty("html_url", out var h) ? h.GetString() : null, + PrNumber = firstItem.TryGetProperty("number", out var n) ? n.GetInt32() : null + }; + } + + var newEntry = BuildMerchantEntry(request); + var updated = merchants + .Select(e => (JsonElement?)e) + .Append(newEntry) + .OrderBy(e => e!.Value.TryGetProperty("name", out var n) ? n.GetString() : string.Empty, + StringComparer.OrdinalIgnoreCase) + .Select(e => e!.Value) + .ToList(); + + // Use UnsafeRelaxedJsonEscaping so non-ASCII codepoints and HTML-only "unsafe" + // chars (`&`, `'`, `<`, `>`) are written raw in the file, matching the upstream + // merchants.json convention. The default JavaScriptEncoder is HTML-safe and + // would re-encode every entry containing `'` or non-ASCII as `\uXXXX`, which + // shows up as a noisy full-file diff on every append. + var updatedJson = JsonSerializer.Serialize(updated, new JsonSerializerOptions + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }) + "\n"; + + var branchRef = await GetJsonAsync( + client, + $"repos/{repo}/git/ref/heads/{Uri.EscapeDataString(defaultBranch)}", + cancellationToken); + var baseSha = branchRef.GetProperty("object").GetProperty("sha").GetString() + ?? throw new InvalidOperationException("base sha missing"); + + var branchSuffix = Guid.NewGuid().ToString("N")[..8]; + var branchName = $"btcmaps/{Slugify(request.Name!)}-{branchSuffix}"; + await PostJsonAsync(client, $"repos/{repo}/git/refs", + new { @ref = $"refs/heads/{branchName}", sha = baseSha }, cancellationToken); + + await PutJsonAsync(client, $"repos/{repo}/contents/{merchantsPath}", + new + { + message = $"Add {request.Name}", + content = Convert.ToBase64String(Encoding.UTF8.GetBytes(updatedJson)), + sha = fileSha, + branch = branchName + }, cancellationToken); + + var prBody = BuildPrBody(request, marker); + var prResponse = await PostJsonAsync(client, $"repos/{repo}/pulls", + new + { + title = $"Add {request.Name}", + head = branchName, + @base = defaultBranch, + body = prBody + }, cancellationToken); + + return new BtcMapsDirectoryResult + { + PrUrl = prResponse.GetProperty("html_url").GetString(), + PrNumber = prResponse.GetProperty("number").GetInt32(), + Branch = branchName + }; + } + + private static JsonElement BuildMerchantEntry(BtcMapsSubmitRequest request) + { + using var ms = new MemoryStream(); + using (var w = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = false })) + { + w.WriteStartObject(); + w.WriteString("name", request.Name!.Trim()); + w.WriteString("url", request.Url!.Trim()); + w.WriteString("description", request.Description!.Trim()); + w.WriteString("type", request.Type!.Trim()); + if (!string.IsNullOrWhiteSpace(request.SubType)) + w.WriteString("subType", request.SubType.Trim()); + if (!string.IsNullOrWhiteSpace(request.Country)) + w.WriteString("country", request.Country.Trim()); + if (!string.IsNullOrWhiteSpace(request.Twitter)) + { + var t = request.Twitter.Trim(); + w.WriteString("twitter", t.StartsWith("@") ? t : "@" + t); + } + if (!string.IsNullOrWhiteSpace(request.Github)) + w.WriteString("github", request.Github.Trim()); + if (!string.IsNullOrWhiteSpace(request.OnionUrl)) + w.WriteString("onionUrl", request.OnionUrl.Trim()); + w.WriteEndObject(); + } + ms.Position = 0; + using var doc = JsonDocument.Parse(ms); + return doc.RootElement.Clone(); + } + + private static string BuildPrBody(BtcMapsSubmitRequest request, string urlMarker) + { + var sb = new StringBuilder(); + sb.AppendLine("Automated submission from the BTCPay Server plugin-builder `/apis/btcmaps/v1/submit` endpoint."); + sb.AppendLine(); + sb.AppendLine($"- **Name:** {request.Name}"); + sb.AppendLine($"- **URL:** {request.Url}"); + sb.AppendLine($"- **Type:** {request.Type}{(string.IsNullOrWhiteSpace(request.SubType) ? string.Empty : " / " + request.SubType)}"); + if (!string.IsNullOrWhiteSpace(request.Country)) sb.AppendLine($"- **Country:** {request.Country.Trim()}"); + if (!string.IsNullOrWhiteSpace(request.Twitter)) + { + // Render as an explicit https://x.com/ link so GitHub markdown does + // not auto-resolve a bare `@handle` to github.com/. + var raw = request.Twitter.Trim(); + var handle = raw.StartsWith("@") ? raw[1..] : raw; + sb.AppendLine($"- **Twitter:** [@{handle}](https://x.com/{handle})"); + } + if (!string.IsNullOrWhiteSpace(request.Github)) sb.AppendLine($"- **GitHub:** {request.Github}"); + sb.AppendLine(); + sb.AppendLine("**Description:**"); + sb.AppendLine(request.Description); + sb.AppendLine(); + sb.AppendLine("_Please review before merge - this PR was opened programmatically by a BTCMap-plugin merchant submission, not by a maintainer._"); + sb.AppendLine(); + sb.AppendLine($""); + return sb.ToString(); + } + + private static string BuildUrlMarker(string normalizedUrl) => + $"btcmaps-submit:url={normalizedUrl}"; + + public static string NormalizeUrl(string url) => + url.Trim().TrimEnd('/').ToLowerInvariant(); + + public static string Slugify(string input) + { + var chars = new StringBuilder(); + var lastWasDash = true; + foreach (var c in input.ToLowerInvariant()) + { + if (c is >= 'a' and <= 'z' or >= '0' and <= '9') + { + chars.Append(c); + lastWasDash = false; + } + else if (!lastWasDash) + { + chars.Append('-'); + lastWasDash = true; + } + } + var result = chars.ToString().Trim('-'); + if (result.Length > 40) result = result[..40].TrimEnd('-'); + return result.Length == 0 ? "merchant" : result; + } + + private static async Task GetJsonAsync(HttpClient client, string path, CancellationToken ct) + { + using var response = await client.GetAsync(path, ct); + await EnsureSuccess(response, path, ct); + var text = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(text); + return doc.RootElement.Clone(); + } + + private static async Task PostJsonAsync(HttpClient client, string path, object body, CancellationToken ct) + { + var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + using var response = await client.PostAsync(path, content, ct); + await EnsureSuccess(response, path, ct); + var text = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(text); + return doc.RootElement.Clone(); + } + + private static async Task PutJsonAsync(HttpClient client, string path, object body, CancellationToken ct) + { + var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json"); + using var response = await client.PutAsync(path, content, ct); + await EnsureSuccess(response, path, ct); + var text = await response.Content.ReadAsStringAsync(ct); + using var doc = JsonDocument.Parse(text); + return doc.RootElement.Clone(); + } + + private static async Task EnsureSuccess(HttpResponseMessage response, string path, CancellationToken ct) + { + if (response.IsSuccessStatusCode) return; + var body = await response.Content.ReadAsStringAsync(ct); + throw new HttpRequestException($"GitHub {(int)response.StatusCode} {path}: {body}"); + } +}