Compare commits

..

No commits in common. "master" and "btcmaps-v1-directory-only" have entirely different histories.

24 changed files with 125 additions and 718 deletions

View File

@ -175,161 +175,6 @@ public class BtcMapsServiceTests
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.OnionUrl));
}
// BTC Map import-RPC fields are mandatory only when SubmitToBtcMap=true. The
// default-false path preserves the directory-only callers untouched.
private static BtcMapsSubmitRequest MakeValidBtcMap()
{
var req = MakeValid();
req.SubmitToBtcMap = true;
req.Lat = 51.5074;
req.Lon = -0.1278;
req.Category = "cafe";
req.ExternalId = "store.example.com:abc123";
return req;
}
[Fact]
public void Validate_AcceptsValidBtcMapSubmission()
{
Assert.Empty(MakeService().Validate(MakeValidBtcMap()));
}
[Fact]
public void Validate_DoesNotRequireBtcMapFieldsByDefault()
{
// Directory-only callers (the pre-existing shape) must not break: a
// request with SubmitToBtcMap unset (default false) and no Lat / Lon /
// Category / ExternalId is still valid.
Assert.Empty(MakeService().Validate(MakeValid()));
}
[Fact]
public void Validate_RejectsMissingLatWhenSubmitToBtcMap()
{
var req = MakeValidBtcMap();
req.Lat = null;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
}
[Fact]
public void Validate_RejectsOutOfRangeLat()
{
var req = MakeValidBtcMap();
req.Lat = 91.0;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
}
[Fact]
public void Validate_RejectsNaNLat()
{
var req = MakeValidBtcMap();
req.Lat = double.NaN;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
}
[Fact]
public void Validate_RejectsMissingLonWhenSubmitToBtcMap()
{
var req = MakeValidBtcMap();
req.Lon = null;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lon));
}
[Fact]
public void Validate_RejectsOutOfRangeLon()
{
var req = MakeValidBtcMap();
req.Lon = -180.5;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lon));
}
[Fact]
public void Validate_RejectsMissingCategoryWhenSubmitToBtcMap()
{
var req = MakeValidBtcMap();
req.Category = null;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
}
[Fact]
public void Validate_RejectsUppercaseCategoryWhenSubmitToBtcMap()
{
// BTC Map docs: "Use a short, single-word (if possible), lowercase identifier."
var req = MakeValidBtcMap();
req.Category = "Cafe";
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
}
[Fact]
public void Validate_RejectsCategoryWithInvalidCharacters()
{
var req = MakeValidBtcMap();
req.Category = "cafe!";
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
}
[Fact]
public void Validate_RejectsMissingExternalIdWhenSubmitToBtcMap()
{
var req = MakeValidBtcMap();
req.ExternalId = null;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.ExternalId));
}
[Fact]
public void Validate_RejectsOverlongExternalId()
{
var req = MakeValidBtcMap();
req.ExternalId = new string('x', 201);
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.ExternalId));
}
private static BtcMapsService MakeServiceWithConfig(IDictionary<string, string?> config) =>
new BtcMapsService(
configuration: new ConfigurationBuilder().AddInMemoryCollection(config).Build(),
httpClientFactory: new StubHttpClientFactory(),
logger: NullLogger<BtcMapsService>.Instance);
[Fact]
public async System.Threading.Tasks.Task SubmitToBtcMapAsync_RejectsHttpEndpoint()
{
// Bearer token must never cross the wire over plaintext http://. Misconfigured
// endpoint surfaces as InvalidOperationException before HttpClient.SendAsync,
// so the token is never built into a request header.
var service = MakeServiceWithConfig(new Dictionary<string, string?>
{
["BTCMAPS:BtcMapImportToken"] = "test-token",
["BTCMAPS:BtcMapImportEndpoint"] = "http://api.btcmap.org/rpc"
});
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => service.SubmitToBtcMapAsync(MakeValidBtcMap()));
Assert.Contains("https", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async System.Threading.Tasks.Task SubmitToBtcMapAsync_RejectsNonAbsoluteEndpoint()
{
var service = MakeServiceWithConfig(new Dictionary<string, string?>
{
["BTCMAPS:BtcMapImportToken"] = "test-token",
["BTCMAPS:BtcMapImportEndpoint"] = "/rpc"
});
await Assert.ThrowsAsync<InvalidOperationException>(
() => service.SubmitToBtcMapAsync(MakeValidBtcMap()));
}
[Fact]
public async System.Threading.Tasks.Task SubmitToBtcMapAsync_ThrowsTokenMissingWhenUnset()
{
// Token unset is the ops-deployment-pending shape; controller maps this to
// 503 btcmap-not-configured. Test the underlying exception type so the
// controller exception ladder stays wired.
var service = MakeServiceWithConfig(new Dictionary<string, string?>());
await Assert.ThrowsAsync<BtcMapsService.BtcMapTokenMissingException>(
() => service.SubmitToBtcMapAsync(MakeValidBtcMap()));
}
[Fact]
public void NormalizeUrl_LowercasesSchemeAndHostOnly()
{

View File

@ -59,7 +59,7 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
await t.Page.ClickAsync("button:text-is('Release')");
await t.Page!.ClickAsync("#StoreNav-Dashboard");
await t.Page.ClickAsync("#StoreNav-RequestListing");
await t.Page.ClickAsync("a.btn.btn-primary:text('Request Listing')");
await Expect(t.Page.Locator("#collapsePluginSettings")).ToBeVisibleAsync();
await Expect(t.Page.Locator("#pluginSettingsHeader")).ToContainTextAsync("Update Plugin Settings");
await Expect(t.Page.Locator("#collapseOwnerSettings")).Not.ToBeVisibleAsync();
@ -78,7 +78,7 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
await t.AssertNoError();
await t.Page!.ClickAsync("#StoreNav-Dashboard");
await t.Page.ClickAsync("#StoreNav-RequestListing");
await t.Page.ClickAsync("a.btn.btn-primary:text('Request Listing')");
await Expect(t.Page.Locator("#collapseRequestForm")).ToBeVisibleAsync();
await Expect(t.Page.Locator("#collapsePluginSettings")).Not.ToBeVisibleAsync();
await Expect(t.Page.Locator("#collapseOwnerSettings")).Not.ToBeVisibleAsync();
@ -87,7 +87,7 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
await t.Page.FillAsync("textarea[name='UserReviews']", "Great plugin, works as expected!");
await t.Page.ClickAsync("button[type='submit']:text('Submit')");
await t.AssertNoError();
await t.Page.ClickAsync("#StoreNav-RequestListing");
await t.Page.ClickAsync("a.btn.btn-primary:text('Request Listing')");
await Expect(t.Page.Locator("#collapsePluginSettings")).Not.ToBeVisibleAsync();
await Expect(t.Page.Locator("#collapseOwnerSettings")).Not.ToBeVisibleAsync();
@ -145,7 +145,7 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
await Expect(row.Locator(".badge.bg-warning:text('Pending')")).ToBeVisibleAsync();
// Click to view details - scoped to the specific row
await row.Locator($"a[href*='/admin/listing-requests/{requestId}']").ClickAsync();
await row.Locator("a.btn.btn-sm.btn-primary:text('View Details')").ClickAsync();
await Expect(t.Page).ToHaveURLAsync(new Regex($".*/admin/listing-requests/{requestId}", RegexOptions.IgnoreCase));
// Verify request details are displayed
@ -172,44 +172,6 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
Assert.Equal(PluginVisibilityEnum.Listed, plugin!.Visibility);
}
[Fact]
public async Task Admin_Can_Reject_Pending_ListingRequest()
{
await using var t = new PlaywrightTester(_log);
t.Server.ReuseDatabase = false;
await t.StartAsync();
await using var conn = await t.Server.GetService<DBConnectionFactory>().Open();
var pluginSlug = "test-plugin-" + PlaywrightTester.GetRandomUInt256()[..8];
var userId = await t.Server.CreateFakeUserAsync();
await t.Server.CreateAndBuildPluginAsync(userId, pluginSlug);
var requestId = await conn.CreateListingRequest(
pluginSlug,
"Test plugin release note",
"https://t.me/btcpayserver/12345",
"https://example.com/review",
null);
var adminEmail = await t.CreateServerAdminAsync();
await t.LogIn(adminEmail);
await t.GoToUrl($"/admin/listing-requests/{requestId}");
await t.Page.ClickAsync("button.btn.btn-danger:text('Reject')");
await t.Page.FillAsync("#rejectionReason", "Plugin does not meet quality standards");
await t.Page.ClickAsync("button[type='submit'].btn.btn-danger:text('Reject')");
await Expect(t.Page).ToHaveURLAsync(new Regex(".*/admin/listing-requests$", RegexOptions.IgnoreCase));
var rejected = await conn.GetListingRequest(requestId);
Assert.NotNull(rejected);
Assert.Equal(PluginListingRequestStatus.Rejected, rejected.Status);
Assert.Equal("Plugin does not meet quality standards", rejected.RejectionReason);
var plugin = await conn.GetPluginDetails(pluginSlug);
Assert.Equal(PluginVisibilityEnum.Unlisted, plugin!.Visibility);
}
[Fact]
public async Task Admin_Can_Reject_ListingRequest()
{

View File

@ -12,35 +12,4 @@ public sealed class BtcMapsSubmitRequest
public string? Twitter { get; set; }
public string? Github { get; set; }
public string? OnionUrl { get; set; }
public string? Phone { get; set; }
// BTC Map import RPC fields. Required iff SubmitToBtcMap=true.
// Plugin captures lat/lon and composes external_id as hostname:storeId
// so this endpoint just passes through to the btcmap submit_place RPC.
public double? Lat { get; set; }
public double? Lon { get; set; }
public string? Category { get; set; }
public string? ExternalId { get; set; }
// Address fields. Optional; forwarded to btcmap as osm:addr:* tags per the
// BTC Map import-RPC doc's osm:<tag_name> custom-field convention.
public string? HouseNumber { get; set; }
public string? Street { get; set; }
public string? City { get; set; }
public string? Postcode { get; set; }
// Email - first-class field on btcmap rest/v4/places.md. Plain key, no prefix.
public string? Email { get; set; }
// Payment-rail flags. Plugin sets these per the store's enabled rails;
// each true value emits the corresponding `payment:onchain=yes` /
// `payment:lightning=yes` marker in extra_fields. Null / false = omitted.
public bool? AcceptsOnchain { get; set; }
public bool? AcceptsLightning { get; set; }
// Routing flags. Default-true preserves the existing call-site semantics
// for SubmitToDirectory; SubmitToBtcMap defaults false so callers must
// opt in to the new path.
public bool SubmitToDirectory { get; set; } = true;
public bool SubmitToBtcMap { get; set; }
}

View File

@ -3,7 +3,6 @@ namespace PluginBuilder.APIModels;
public sealed class BtcMapsSubmitResponse
{
public BtcMapsDirectoryResult? Directory { get; set; }
public BtcMapsBtcMapResult? BtcMap { get; set; }
}
public sealed class BtcMapsDirectoryResult
@ -13,10 +12,3 @@ public sealed class BtcMapsDirectoryResult
public string? Branch { get; set; }
public string? Skipped { get; set; }
}
public sealed class BtcMapsBtcMapResult
{
public long? Id { get; set; }
public string? Origin { get; set; }
public string? ExternalId { get; set; }
}

View File

@ -19,16 +19,6 @@
<span>Builds</span>
</a>
</li>
@if (Model.RequestListing && Model.Versions.Any())
{
<li class="nav-item">
<a asp-area="" asp-controller="Plugin" asp-action="RequestListing" asp-route-pluginSlug="@Model.PluginSlug"
class="nav-link js-scroll-trigger @ViewData.IsActivePage(PluginNavPages.RequestListing)" id="StoreNav-RequestListing">
<vc:icon symbol="notification" />
<span>Request Listing</span>
</a>
</li>
}
<li class="nav-item">
<a asp-area="" asp-controller="Plugin" asp-action="Settings" asp-route-pluginSlug="@Model.PluginSlug"
class="nav-link js-scroll-trigger @ViewData.IsActivePage(PluginNavPages.Settings)" id="StoreNav-Settings">

View File

@ -2,7 +2,6 @@ using Dapper;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using PluginBuilder.Components.PluginVersion;
using PluginBuilder.DataModels;
using PluginBuilder.Services;
using PluginBuilder.Util;
using PluginBuilder.Util.Extensions;
@ -27,28 +26,22 @@ public class MainNav : ViewComponent
using var conn = await ConnectionFactory.Open();
if (pluginSlug is { } currentPluginSlug)
if (pluginSlug != null)
{
var slug = currentPluginSlug.ToString();
var rows = await conn.QueryAsync<(int[] ver, bool pre_release)>(
"SELECT ver, pre_release FROM users_plugins up " +
"JOIN versions v USING (plugin_slug) " +
"WHERE up.user_id=@userId AND up.plugin_slug=@pluginSlug " +
"ORDER BY v.ver DESC LIMIT 10", new { pluginSlug = slug, userId = UserManager.GetUserId(UserClaimsPrincipal) });
"ORDER BY v.ver DESC LIMIT 10", new { pluginSlug = pluginSlug.ToString(), userId = UserManager.GetUserId(UserClaimsPrincipal) });
foreach (var r in rows)
vm.Versions.Add(new PluginVersionViewModel
{
PluginSlug = slug,
PluginSlug = pluginSlug?.ToString(),
Version = new PluginBuilder.PluginVersion(r.ver).ToString(),
PreRelease = r.pre_release,
Published = true,
HidePublishBadge = true
});
var visibility = await conn.ExecuteScalarAsync<string?>(
"SELECT visibility FROM plugins WHERE slug=@pluginSlug",
new { pluginSlug = slug });
vm.RequestListing = string.Equals(visibility, nameof(PluginVisibilityEnum.Unlisted).ToLowerInvariant(), StringComparison.Ordinal);
}
// Only load pending count for admins to avoid burdening database

View File

@ -12,5 +12,4 @@ public class MainNavViewModel
public List<PluginVersionViewModel> Versions { get; set; } = new();
public int PendingListingRequestsCount { get; set; }
public bool RequestListing { get; set; }
}

View File

@ -4,6 +4,5 @@ public enum PluginNavPages
{
Dashboard,
Settings,
Owners,
RequestListing
Owners
}

View File

@ -1286,6 +1286,7 @@ public class AdminController(
TempData[TempDataConstant.WarningMessage] = "Failed to reject the listing request";
return RedirectToAction(nameof(ListingRequestDetail), new { requestId });
}
var existingSettings = await conn.GetSettings(pluginSlug);
var pluginOwners = await conn.GetPluginOwners(pluginSlug);
var primaryOwner = pluginOwners.FirstOrDefault(o => o.IsPrimary);
@ -1295,5 +1296,6 @@ public class AdminController(
TempData[TempDataConstant.SuccessMessage] = $"Plugin listing request for '{request.PluginSlug}' has been rejected";
return RedirectToAction(nameof(ListingRequests));
}
#endregion
}

View File

@ -30,80 +30,57 @@ public sealed class BtcMapsController(
if (errors.Count > 0)
return BadRequest(new { errors });
// At least one downstream lane must run; "submit nothing" is almost
// certainly a caller bug (forgot to set the flag) rather than a legit
// intent, and silently 200-ing an empty response would hide it.
if (!request.SubmitToDirectory && !request.SubmitToBtcMap)
{
return BadRequest(new { errors = new[] {
new ValidationError("body", "At least one of SubmitToDirectory or SubmitToBtcMap must be true.")
}});
}
var correlationId = Guid.NewGuid().ToString("N");
BtcMapsDirectoryResult? directory = null;
BtcMapsBtcMapResult? btcMap = null;
BtcMapsDirectoryResult directory;
if (request.SubmitToDirectory)
try
{
try
directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
}
catch (BtcMapsService.DirectoryTokenMissingException ex)
{
// Missing token is a server-side deployment / configuration outage,
// not a normal "skipped" outcome. Surface 503 so clients (and ops)
// can distinguish it from an accepted submission.
logger.LogError(ex, "BTCMaps directory submission rejected: token not configured (correlationId={CorrelationId})", correlationId);
return StatusCode(StatusCodes.Status503ServiceUnavailable, new
{
directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
}
catch (BtcMapsService.DirectoryTokenMissingException ex)
error = "directory-not-configured",
correlationId
});
}
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
{
// Caller cancelled (client disconnect, request abort). Rethrow so
// the pipeline drops the connection without producing a response
// body the client will never read.
logger.LogInformation(ex, "BTCMaps directory submission cancelled by caller (correlationId={CorrelationId})", correlationId);
throw;
}
catch (OperationCanceledException ex)
{
// OCE without caller cancellation = HttpClient.Timeout surfacing as
// TaskCanceledException. Treat as an upstream timeout, distinct
// from a generic 502 so ops + the plugin client can tell them apart.
logger.LogError(ex, "BTCMaps directory submission timed out upstream (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status504GatewayTimeout, new
{
logger.LogError(ex, "BTCMaps directory submission rejected: token not configured (correlationId={CorrelationId})", correlationId);
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "directory-not-configured", correlationId });
}
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
error = "directory-upstream-timeout",
correlationId
});
}
catch (Exception ex)
{
logger.LogError(ex, "BTCMaps directory submission failed (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status502BadGateway, new
{
logger.LogInformation(ex, "BTCMaps directory submission cancelled by caller (correlationId={CorrelationId})", correlationId);
throw;
}
catch (OperationCanceledException ex)
{
logger.LogError(ex, "BTCMaps directory submission timed out upstream (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status504GatewayTimeout, new { error = "directory-upstream-timeout", correlationId });
}
catch (Exception ex)
{
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 });
}
error = "directory-upstream-failed",
correlationId
});
}
if (request.SubmitToBtcMap)
{
try
{
btcMap = await btcMapsService.SubmitToBtcMapAsync(request, cancellationToken);
}
catch (BtcMapsService.BtcMapTokenMissingException ex)
{
logger.LogError(ex, "BTCMaps import submission rejected: token not configured (correlationId={CorrelationId})", correlationId);
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "btcmap-not-configured", correlationId });
}
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
{
logger.LogInformation(ex, "BTCMaps import submission cancelled by caller (correlationId={CorrelationId})", correlationId);
throw;
}
catch (OperationCanceledException ex)
{
logger.LogError(ex, "BTCMaps import submission timed out upstream (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status504GatewayTimeout, new { error = "btcmap-upstream-timeout", correlationId });
}
catch (Exception ex)
{
logger.LogError(ex, "BTCMaps import submission failed (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status502BadGateway, new { error = "btcmap-upstream-failed", correlationId });
}
}
return Ok(new BtcMapsSubmitResponse { Directory = directory, BtcMap = btcMap });
return Ok(new BtcMapsSubmitResponse { Directory = directory });
}
}

View File

@ -318,35 +318,6 @@ public class PluginController(
return RedirectToAction(nameof(Build), new { pluginSlug = pluginSlug.ToString(), buildId });
}
[HttpGet("listing-history")]
public async Task<IActionResult> ListingHistory(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug)
{
await using var conn = await connectionFactory.Open();
var plugin = await conn.GetPluginDetails(pluginSlug);
if (plugin is null)
return NotFound();
var pluginSettings = SafeJson.Deserialize<PluginSettings>(plugin.Settings);
var requests = await conn.GetAllListingRequestsForPlugin(pluginSlug);
var vm = new ListingHistoryViewModel
{
PluginSlug = pluginSlug.ToString(),
PluginTitle = pluginSettings?.PluginTitle,
Requests = requests.Select(r => new ListingHistoryItemViewModel
{
Id = r.Id,
Status = r.Status,
ReleaseNote = r.ReleaseNote,
SubmittedAt = r.SubmittedAt,
ReviewedAt = r.ReviewedAt,
RejectionReason = r.RejectionReason
}).ToList()
};
return View(vm);
}
[HttpGet("request-listing")]
public async Task<IActionResult> RequestListing(
[ModelBinder(typeof(PluginSlugModelBinder))]
@ -364,7 +335,6 @@ public class PluginController(
if (plugin.Visibility == PluginVisibilityEnum.Hidden)
return NotFound();
var allRequests = await conn.GetAllListingRequestsForPlugin(pluginSlug);
var pluginOwners = await conn.GetPluginOwners(pluginSlug);
var pluginSettings = SafeJson.Deserialize<PluginSettings>(plugin.Settings);
var pendingRequest = await conn.GetPendingListingRequestForPlugin(pluginSlug);
@ -372,7 +342,6 @@ public class PluginController(
model.ReleaseNote = pluginSettings?.Description;
model.HasPreviousRejection = rejectedRequest != null;
model.HasRequests = allRequests.Any();
if (pendingRequest != null)
{
@ -768,6 +737,7 @@ public class PluginController(
}
var pluginSettings = await conn.GetPluginDetails(pluginSlug);
vm.RequestListing = pluginSettings?.Visibility == PluginVisibilityEnum.Unlisted;
return View(vm);
}

View File

@ -5,5 +5,4 @@ public static class HttpClientNames
public const string GitHub = nameof(GitHub);
public const string GitLab = nameof(GitLab);
public const string BtcMapsDirectory = nameof(BtcMapsDirectory);
public const string BtcMap = nameof(BtcMap);
}

View File

@ -15,23 +15,6 @@ public class PluginListingRequest
public string? RejectionReason { get; set; }
}
public class ListingHistoryViewModel
{
public string PluginSlug { get; set; } = null!;
public string? PluginTitle { get; set; }
public List<ListingHistoryItemViewModel> Requests { get; set; } = new();
}
public class ListingHistoryItemViewModel
{
public int Id { get; set; }
public PluginListingRequestStatus Status { get; set; }
public string ReleaseNote { get; set; } = null!;
public DateTimeOffset SubmittedAt { get; set; }
public DateTimeOffset? ReviewedAt { get; set; }
public string? RejectionReason { get; set; }
}
public enum PluginListingRequestStatus
{
Pending,

View File

@ -223,16 +223,6 @@ public class Program
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
client.Timeout = TimeSpan.FromSeconds(15);
});
services.AddHttpClient(HttpClientNames.BtcMap, client =>
{
// BTC Map import RPC is a single JSON-RPC 2.0 dispatch endpoint.
// Per-call timeout caps a single round-trip at 15s, matching the
// BtcMapsDirectory budget so a hung remote can't pin the request
// longer than the per-IP rate-limit window.
client.DefaultRequestHeaders.Add("User-Agent", "PluginBuilder-BtcMap/1.0");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
client.Timeout = TimeSpan.FromSeconds(15);
});
services.AddHttpClient(HttpClientNames.GitLab, client =>
{
client.BaseAddress = new Uri("https://gitlab.com/api/v4/");
@ -291,16 +281,13 @@ public class Program
});
options.AddPolicy(Policies.BtcMapsSubmitRateLimit, httpContext =>
{
// Per-source-IP fixed window: 3 submissions per 24h. Caps automation
// 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. Tightened from 5/24h with the
// multi-vendor BTC Map import-RPC lane (PR #226) since that path
// forwards into a moderator review queue and rate-limit is the
// primary spam control on the public endpoint.
// submissions from a merchant.
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(clientIp, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 3,
PermitLimit = 5,
Window = TimeSpan.FromHours(24),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0

View File

@ -75,79 +75,48 @@ public sealed class BtcMapsService
public DirectoryTokenMissingException() : base("BTCMAPS:DirectoryGithubToken is not configured.") { }
}
public sealed class BtcMapTokenMissingException : Exception
{
public BtcMapTokenMissingException() : base("BTCMAPS:BtcMapImportToken is not configured.") { }
}
private const string DefaultBtcMapImportEndpoint = "https://api.btcmap.org/rpc";
private const string BtcMapImportOrigin = "btcpayserver";
private const string BtcMapImportMethod = "submit_place";
public IReadOnlyList<ValidationError> Validate(BtcMapsSubmitRequest request)
{
var errors = new List<ValidationError>();
// Name is the only field both lanes need - btcmap submit_place requires it
// as part of the params payload, and the directory merchants.json keys
// entries by name.
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."));
// Directory-lane required fields. Skip when the caller opted out of the
// directory submission so a btcmap-only call doesn't need to fill in
// unrelated directory metadata.
if (request.SubmitToDirectory)
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 url = (request.Url ?? string.Empty).Trim();
if (string.IsNullOrEmpty(url))
errors.Add(new ValidationError(nameof(request.Url), "Required when SubmitToDirectory=true."));
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 when SubmitToDirectory=true. 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 when SubmitToDirectory=true. 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))
{
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."));
}
errors.Add(new ValidationError(nameof(request.SubType),
"Invalid merchant subtype."));
}
}
// Country format check applies to either lane. GLOBAL is the directory's
// pseudonym for online-only / multi-region merchants but it is NOT a valid
// physical-place country code for the btcmap directory map (every place
// there is geocoded). Reject GLOBAL when the btcmap lane is on; allow it
// when only the directory lane is in play.
if (!string.IsNullOrWhiteSpace(request.Country))
{
var country = request.Country.Trim();
if (country == "GLOBAL")
{
if (request.SubmitToBtcMap)
{
errors.Add(new ValidationError(nameof(request.Country),
"Country=GLOBAL is incompatible with SubmitToBtcMap=true (btcmap places are physical locations). Use an ISO 3166-1 alpha-2 code or omit Country."));
}
}
else if (!Iso3166Alpha2.Contains(country))
{
// GLOBAL is the directory's pseudonym for online-only / multi-region merchants.
// Everything else must be an actual ISO 3166-1 alpha-2 code.
if (country != "GLOBAL" && !Iso3166Alpha2.Contains(country))
errors.Add(new ValidationError(nameof(request.Country),
"Must be ISO 3166-1 alpha-2 or GLOBAL."));
}
}
if (!string.IsNullOrWhiteSpace(request.OnionUrl))
@ -161,144 +130,9 @@ public sealed class BtcMapsService
}
}
// BTC Map import RPC fields become mandatory only when the caller
// opts into that lane via SubmitToBtcMap=true. Directory-only callers
// (the existing PR #224 shape) are unaffected.
if (request.SubmitToBtcMap)
{
if (!request.Lat.HasValue || request.Lat.Value is < -90 or > 90 || double.IsNaN(request.Lat.Value))
errors.Add(new ValidationError(nameof(request.Lat),
"Required when SubmitToBtcMap=true. Must be in [-90, 90]."));
if (!request.Lon.HasValue || request.Lon.Value is < -180 or > 180 || double.IsNaN(request.Lon.Value))
errors.Add(new ValidationError(nameof(request.Lon),
"Required when SubmitToBtcMap=true. Must be in [-180, 180]."));
var category = (request.Category ?? string.Empty).Trim();
if (string.IsNullOrEmpty(category) || category.Length > 50 ||
!category.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-' or '_'))
{
errors.Add(new ValidationError(nameof(request.Category),
"Required when SubmitToBtcMap=true. Short lowercase identifier (a-z, 0-9, -, _; max 50 chars)."));
}
var externalId = (request.ExternalId ?? string.Empty).Trim();
if (string.IsNullOrEmpty(externalId) || externalId.Length > 200)
errors.Add(new ValidationError(nameof(request.ExternalId),
"Required when SubmitToBtcMap=true. 1-200 characters."));
}
return errors;
}
public async Task<BtcMapsBtcMapResult> SubmitToBtcMapAsync(
BtcMapsSubmitRequest request,
CancellationToken cancellationToken = default)
{
var token = _configuration["BTCMAPS:BtcMapImportToken"];
if (string.IsNullOrWhiteSpace(token))
throw new BtcMapTokenMissingException();
// Bearer tokens MUST NOT cross the wire over http://; an operator-
// misconfigured endpoint would silently leak the scoped token to
// anyone on the network path. Parse the configured value into an
// absolute https URI before we even create the request, so a bad
// BTCMAPS:BtcMapImportEndpoint fails loudly with the offending
// value in the message instead of producing a quiet credential leak.
var endpoint = (_configuration["BTCMAPS:BtcMapImportEndpoint"] ?? DefaultBtcMapImportEndpoint).Trim();
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) ||
endpointUri.Scheme != Uri.UriSchemeHttps)
{
throw new InvalidOperationException(
$"BTCMAPS:BtcMapImportEndpoint must be an absolute https:// URL (got '{endpoint}').");
}
var client = _httpClientFactory.CreateClient(HttpClientNames.BtcMap);
// BTC Map import-RPC takes a JSON-RPC 2.0 envelope at /rpc with method=submit_place.
// Required params: origin, external_id, lat, lon, category, name. extra_fields uses
// the documented first-class keys (phone, website, description) and the osm:<tag_name>
// convention for granular OSM tags (osm:addr:*).
var extraFields = new Dictionary<string, object?>();
// First-class btcmap fields (plain keys per rest/v4/places.md).
if (!string.IsNullOrWhiteSpace(request.Url)) extraFields["website"] = request.Url.Trim();
if (!string.IsNullOrWhiteSpace(request.Description)) extraFields["description"] = request.Description.Trim();
if (!string.IsNullOrWhiteSpace(request.Phone)) extraFields["phone"] = request.Phone.Trim();
if (!string.IsNullOrWhiteSpace(request.Email)) extraFields["email"] = request.Email.Trim();
if (!string.IsNullOrWhiteSpace(request.Twitter))
{
// btcmap's `twitter` field is documented as a URL. Normalize the @handle
// shape the rest of the API accepts (with or without leading @) into
// the URL form the directory map expects.
var t = request.Twitter.Trim();
var handle = t.StartsWith("@") ? t[1..] : t;
extraFields["twitter"] = handle.StartsWith("http", StringComparison.OrdinalIgnoreCase)
? handle
: $"https://x.com/{handle}";
}
// OSM custom tags use osm:<tag_name> per the same doc.
if (!string.IsNullOrWhiteSpace(request.HouseNumber)) extraFields["osm:addr:housenumber"] = request.HouseNumber.Trim();
if (!string.IsNullOrWhiteSpace(request.Street)) extraFields["osm:addr:street"] = request.Street.Trim();
if (!string.IsNullOrWhiteSpace(request.City)) extraFields["osm:addr:city"] = request.City.Trim();
if (!string.IsNullOrWhiteSpace(request.Postcode)) extraFields["osm:addr:postcode"] = request.Postcode.Trim();
if (!string.IsNullOrWhiteSpace(request.Country)) extraFields["osm:addr:country"] = request.Country.Trim();
// Payment-rail flags. Plugin sets per the store's enabled rails - omit
// when null or false so a Lightning-only store doesn't claim on-chain
// support (or vice versa).
if (request.AcceptsOnchain == true) extraFields["osm:payment:onchain"] = "yes";
if (request.AcceptsLightning == true) extraFields["osm:payment:lightning"] = "yes";
var rpcParams = new Dictionary<string, object?>
{
["origin"] = BtcMapImportOrigin,
["external_id"] = request.ExternalId!.Trim(),
["lat"] = request.Lat!.Value,
["lon"] = request.Lon!.Value,
["category"] = request.Category!.Trim().ToLowerInvariant(),
["name"] = request.Name!.Trim(),
["extra_fields"] = extraFields
};
var envelope = new Dictionary<string, object?>
{
["jsonrpc"] = "2.0",
["method"] = BtcMapImportMethod,
["params"] = rpcParams,
["id"] = 1
};
using var req = new HttpRequestMessage(HttpMethod.Post, endpointUri);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Content = new StringContent(JsonSerializer.Serialize(envelope), Encoding.UTF8, "application/json");
using var response = await client.SendAsync(req, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"BtcMap RPC {(int)response.StatusCode} {endpointUri}: {body}");
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
// JSON-RPC 2.0 response shape: either {result: {...}} on success or {error: {...}}.
// 2xx status with an error body is a legal JSON-RPC outcome we must surface.
if (root.TryGetProperty("error", out var errorElement))
{
var errorJson = errorElement.GetRawText();
throw new HttpRequestException($"BtcMap RPC error response: {errorJson}");
}
if (!root.TryGetProperty("result", out var result))
throw new HttpRequestException($"BtcMap RPC missing result: {body}");
return new BtcMapsBtcMapResult
{
Id = result.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.Number ? id.GetInt64() : null,
Origin = result.TryGetProperty("origin", out var origin) ? origin.GetString() : null,
ExternalId = result.TryGetProperty("external_id", out var ext) ? ext.GetString() : null
};
}
public async Task<BtcMapsDirectoryResult> SubmitToDirectoryAsync(
BtcMapsSubmitRequest request,
CancellationToken cancellationToken = default)

View File

@ -42,6 +42,7 @@ Thank you,
BTCPay Server Plugin Builder Team
";
public Task<List<string>> SendEmail(string toCsvList, string subject, string messageText)
{
List<InternetAddress> toList = toCsvList.Split([","], StringSplitOptions.RemoveEmptyEntries)
@ -171,6 +172,7 @@ BTCPay Server Plugin Builder";
}
}
public async Task<EmailSettingsViewModel?> GetEmailSettingsFromDb()
{
await using var conn = await connectionFactory.Open();

View File

@ -860,29 +860,6 @@ public static class NpgsqlConnectionExtensions
return await connection.QueryFirstOrDefaultAsync<PluginListingRequest>(sql, new { pluginSlug = pluginSlug.ToString() });
}
public static async Task<List<PluginListingRequest>> GetAllListingRequestsForPlugin(this NpgsqlConnection connection, PluginSlug pluginSlug)
{
const string sql = """
SELECT id AS "Id",
plugin_slug AS "PluginSlug",
release_note AS "ReleaseNote",
telegram_verification_message AS "TelegramVerificationMessage",
user_reviews AS "UserReviews",
announcement_date AS "AnnouncementDate",
status AS "Status",
submitted_at AS "SubmittedAt",
reviewed_at AS "ReviewedAt",
reviewed_by AS "ReviewedBy",
rejection_reason AS "RejectionReason"
FROM plugin_listing_requests
WHERE plugin_slug = @pluginSlug
ORDER BY submitted_at DESC
""";
var results = await connection.QueryAsync<PluginListingRequest>(sql, new { pluginSlug = pluginSlug.ToString() });
return results.ToList();
}
public static async Task<int> GetPendingListingRequestsCount(this NpgsqlConnection connection)
{
const string sql = """

View File

@ -5,6 +5,7 @@ namespace PluginBuilder.ViewModels;
public class BuildListViewModel
{
public bool RequestListing { get; set; }
public List<BuildViewModel> Builds { get; set; } = [];
public class BuildViewModel

View File

@ -25,7 +25,7 @@ public class RequestListingViewModel
[Required]
[Display(Name = "User Reviews")]
public string UserReviews { get; set; } = string.Empty;
public bool HasRequests { get; set; }
public bool PendingListing { get; set; }
public bool HasPreviousRejection { get; set; }
public bool CanSendEmailReminder { get; set; }

View File

@ -2,7 +2,7 @@
@model ListingRequestDetailViewModel
@{
Layout = "_Layout";
ViewData.SetActivePage(PluginNavPages.RequestListing, "Request Listing");
ViewData.SetActivePage(AdminNavPages.ListingRequests);
ViewData["Title"] = $"Listing Request - {Model.PluginTitle ?? Model.PluginSlug}";
}
@ -15,6 +15,18 @@
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#rejectModal">Reject</button>
</div>
}
else
{
@switch (Model.Status)
{
case PluginListingRequestStatus.Approved:
<span class="badge bg-success fs-6 px-3 py-2">Approved</span>
break;
case PluginListingRequestStatus.Rejected:
<span class="badge bg-danger fs-6 px-3 py-2">Rejected</span>
break;
}
}
</div>
<div class="row mt-0">
@ -25,24 +37,13 @@
<img src="@Model.Logo" alt="Logo" style="width:64px;height:64px;object-fit:contain;border-radius:12px;flex-shrink:0;" />
}
<div>
<div class="d-flex align-items-center gap-2">
<h3 class="mb-0 fw-bold">@Model.PluginTitle</h3>
@switch (Model.Status)
{
case PluginListingRequestStatus.Approved:
<span class="badge bg-success">Approved</span>
break;
case PluginListingRequestStatus.Rejected:
<span class="badge bg-danger">Rejected</span>
break;
}
</div>
<h3 class="mb-0 fw-bold">@Model.PluginTitle</h3>
<div class="d-flex align-items-center gap-2 mt-1">
<a asp-controller="Home" asp-action="GetPluginDetails" asp-route-pluginSlug="@Model.PluginSlug" target="_blank" class="small">View Public Page</a>
</div>
@if (!string.IsNullOrEmpty(Model.PluginDescription))
{
<p class="mt-2 mb-0">@Model.PluginDescription</p>
<p class="mt-2 mb-0 text-muted">@Model.PluginDescription</p>
}
</div>
</div>
@ -93,7 +94,7 @@
</div>
<h5 class="fw-bold mb-4">Listing Request Details</h5>
@if (!string.IsNullOrEmpty(Model.RejectionReason))
@if (!string.IsNullOrEmpty(Model.RejectionReason))
{
<div class="mb-4">
<div class="text-muted fw-semibold small text-uppercase mb-2" style="letter-spacing:.05em;">Rejection Reason</div>
@ -173,7 +174,7 @@
@Html.AntiForgeryToken()
<div class="modal-body">
<p>Are you sure you want to approve this listing request?</p>
<p class="mb-0">The plugin <strong>@Model.PluginSlug</strong> will be <strong>Listed</strong> in the plugin directory.</p>
<p class="mb-0">The plugin <strong>@Model.PluginSlug</strong> will be set to <strong>Listed</strong> visibility.</p>
</div>
<div class="modal-footer border-0">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>

View File

@ -99,7 +99,7 @@ else
}
</td>
<td>
<a asp-action="ListingRequestDetail" asp-route-requestId="@request.Id">
<a asp-action="ListingRequestDetail" asp-route-requestId="@request.Id" class="btn btn-sm btn-primary">
View Details
</a>
</td>

View File

@ -44,6 +44,10 @@
Public Page
</a>
}
@if (Model.RequestListing && Model.Builds.Any())
{
<a asp-controller="Plugin" asp-action="RequestListing" asp-route-pluginSlug="@pluginSlug" class="btn btn-primary">Request Listing</a>
}
<a id="CreateNewBuild" asp-action="CreateBuild" asp-route-pluginSlug="@pluginSlug" class="btn btn-primary"><span class="fa fa-plus"></span> Create a
new build</a>
</div>

View File

@ -1,77 +0,0 @@
@using PluginBuilder.DataModels
@model ListingHistoryViewModel
@{
Layout = "_Layout";
ViewData.SetActivePage(PluginNavPages.RequestListing, "Listing History");
ViewData["Title"] = "Listing History";
}
<div class="d-flex align-items-center justify-content-between mb-4">
<h2 class="mb-0">@ViewData["Title"]</h2>
<a asp-action="RequestListing" asp-route-pluginSlug="@Model.PluginSlug" class="btn btn-outline-primary btn-sm">
Back to Listing Request
</a>
</div>
@if (!Model.Requests.Any())
{
<div class="alert alert-info">No listing requests have been submitted yet.</div>
}
else
{
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>Submitted</th>
<th>Release Note</th>
<th>Rejection Reason</th>
<th>Reviewed</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@foreach (var request in Model.Requests)
{
<tr>
<td class="text-nowrap small">@request.SubmittedAt.UtcDateTime.ToString("MMM dd, yyyy · HH:mm UTC")</td>
<td>@request.ReleaseNote</td>
<td>
@if (!string.IsNullOrEmpty(request.RejectionReason))
{
<span class="text-danger" style="white-space: pre-wrap;">@request.RejectionReason</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td class="text-nowrap small">
@if (request.ReviewedAt.HasValue)
{
@request.ReviewedAt.Value.UtcDateTime.ToString("MMM dd, yyyy · HH:mm UTC")
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@switch (request.Status)
{
case PluginListingRequestStatus.Pending:
<span class="badge bg-warning">Pending</span>
break;
case PluginListingRequestStatus.Approved:
<span class="badge bg-success">Approved</span>
break;
case PluginListingRequestStatus.Rejected:
<span class="badge bg-danger">Rejected</span>
break;
}
</td>
</tr>
}
</tbody>
</table>
</div>
}

View File

@ -1,7 +1,7 @@
@model RequestListingViewModel
@{
Layout = "_Layout";
ViewData.SetActivePage(PluginNavPages.RequestListing, "Plugin Listing Request");
ViewData.SetActivePage(PluginNavPages.Dashboard, "Plugin Listing Request");
var step3Completed = Model.PendingListing;
var step1Completed = Model.Step != RequestListingViewModel.State.UpdatePluginSettings;
var step2Completed = Model.Step != RequestListingViewModel.State.UpdateOwnerAccountSettings && Model.Step != RequestListingViewModel.State.UpdatePluginSettings;
@ -11,29 +11,27 @@
}
<div class="d-flex align-items-center justify-content-between mb-4">
<h2 class="mb-0">@ViewData["Title"]</h2>
<div class="d-flex align-items-center gap-3 mt-3 mt-sm-0">
@if (Model.HasRequests)
{
<a asp-action="ListingHistory" asp-route-pluginSlug="@Model.PluginSlug" class="btn btn-outline-secondary btn-sm">
View History
</a>
}
@if (!Model.PendingListing)
{
<h2 class="mb-0">
@ViewData["Title"]
</h2>
@if (!Model.PendingListing)
{
<div class="d-flex gap-3 mt-3 mt-sm-0">
<button type="submit" form="request-listing-form" class="btn btn-success" @(Model.Step == RequestListingViewModel.State.Done ? "" : "disabled")>
@(Model.HasPreviousRejection ? "Re-submit" : "Submit")
</button>
}
@if (Model.PendingListing && Model.CanSendEmailReminder)
{
<form asp-controller="Plugin" asp-action="SendReminder" asp-route-pluginSlug="@Model.PluginSlug" method="post" class="d-inline">
<button type="submit" class="btn btn-success">Send Reminder</button>
</form>
}
</div>
</div>
}
@if (Model.PendingListing && Model.CanSendEmailReminder)
{
<form asp-controller="Plugin" asp-action="SendReminder" asp-route-pluginSlug="@Model.PluginSlug" method="post" class="d-inline">
<button type="submit" class="btn btn-success">Send Reminder</button>
</form>
}
</div>
<div class="accordion" id="requestListingAccordion">
<div class="accordion-item">
<h3 class="accordion-header" id="pluginSettingsHeader">