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.
This commit is contained in:
parent
4480aa01d4
commit
33148aafcd
181
PluginBuilder.Tests/BtcMapsServiceTests.cs
Normal file
181
PluginBuilder.Tests/BtcMapsServiceTests.cs
Normal file
@ -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<BtcMapsService>.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);
|
||||
}
|
||||
}
|
||||
15
PluginBuilder/APIModels/BtcMapsSubmitRequest.cs
Normal file
15
PluginBuilder/APIModels/BtcMapsSubmitRequest.cs
Normal file
@ -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; }
|
||||
}
|
||||
14
PluginBuilder/APIModels/BtcMapsSubmitResponse.cs
Normal file
14
PluginBuilder/APIModels/BtcMapsSubmitResponse.cs
Normal file
@ -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; }
|
||||
}
|
||||
53
PluginBuilder/Controllers/BtcMapsController.cs
Normal file
53
PluginBuilder/Controllers/BtcMapsController.cs
Normal file
@ -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<BtcMapsController> logger)
|
||||
: ControllerBase
|
||||
{
|
||||
[HttpGet("ping")]
|
||||
public IActionResult Ping() => Ok(new { ok = true, service = "btcmaps", version = "v1" });
|
||||
|
||||
[HttpPost("submit")]
|
||||
[EnableRateLimiting(Policies.BtcMapsSubmitRateLimit)]
|
||||
public async Task<IActionResult> 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 });
|
||||
}
|
||||
}
|
||||
@ -4,4 +4,5 @@ public class Policies
|
||||
{
|
||||
public const string OwnPlugin = "OwnPlugin";
|
||||
public const string PublicApiRateLimit = "PublicApiRateLimit";
|
||||
public const string BtcMapsSubmitRateLimit = "BtcMapsSubmitRateLimit";
|
||||
}
|
||||
|
||||
@ -241,6 +241,7 @@ public class Program
|
||||
});
|
||||
services.AddScoped<PluginOwnershipService>();
|
||||
services.AddScoped<VersionLifecycleService>();
|
||||
services.AddSingleton<BtcMapsService>();
|
||||
|
||||
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 =>
|
||||
|
||||
338
PluginBuilder/Services/BtcMapsService.cs
Normal file
338
PluginBuilder/Services/BtcMapsService.cs
Normal file
@ -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<string> ValidTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"merchants", "apps", "hosted-btcpay", "non-profits"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> 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<BtcMapsService> _logger;
|
||||
|
||||
public BtcMapsService(
|
||||
IConfiguration configuration,
|
||||
ILogger<BtcMapsService> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public IReadOnlyList<ValidationError> Validate(BtcMapsSubmitRequest request)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
|
||||
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<BtcMapsDirectoryResult> 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<List<JsonElement>>(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/<handle> link so GitHub markdown does
|
||||
// not auto-resolve a bare `@handle` to github.com/<handle>.
|
||||
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($"<!-- {urlMarker} -->");
|
||||
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<JsonElement> 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<JsonElement> 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<JsonElement> 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}");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user