Compare commits

...

46 Commits

Author SHA1 Message Date
thgO.O
ec4359ec1c
Merge pull request #226 from r1ckstardev/btcmap-import-rpc-v1
Some checks failed
PluginBuilder Tests / test (push) Has been cancelled
Publish Docker image / Push Docker image to Docker Hub (push) Has been cancelled
BTCMaps v2: add btcmap import-RPC submit path alongside directory submission
2026-06-03 21:39:05 -03:00
r1ckstardev
62d160a29b BTCMaps v2: drop hardcoded Status field on BtcMapResult
Per rollforsats feedback on PR #226 msg 82: the Status field on
BtcMapsBtcMapResult was a hardcoded 'submitted-for-review' constant
that didn't reflect anything the btcmap RPC actually returned.
submit_place's result envelope contains id / origin / external_id;
the queued-for-reviewer-workflow semantics are documented on the
BTC Map side, not signalled per-response. Carrying it on every
response was misleading.
2026-05-27 16:33:16 +00:00
r1ckstardev
2119281141 BTCMaps v2: tighten /apis/btcmaps/v1/submit rate limit to 3/24h per IP
Per CREATOR direction on PR #226. Tightening the existing endpoint-wide
fixed-window from 5/24h to 3/24h per source IP. The BTC Map import-RPC
lane forwards submissions into the upstream reviewer queue (not an
instant publish), and rate-limit is the primary spam control on the
public endpoint.
2026-05-25 17:30:22 +00:00
r1ckstardev
cac72c5682 BTCMaps v2: osm: prefix on payment-rail markers
Per rollforsats msg 76 + Hermes finding #4 on PR #226. payment:onchain
and payment:lightning are OSM-style custom tags, so they follow the
osm:<tag_name> convention btcmap documents for additional tags in
extra_fields. Emit as osm:payment:onchain / osm:payment:lightning.
2026-05-25 16:58:20 +00:00
r1ckstardev
034b272882 BTCMaps v2: status='submitted-for-review' on BtcMap result
Per Hermes finding #5 on PR #226. BTC Map's import RPC routes
submitted places into a reviewer queue rather than instant publish.
Successful submit_place means 'submission accepted for review', not
'place live on the directory map.'

Add a Status field on BtcMapsBtcMapResult defaulting to
'submitted-for-review' so clients reading the response shape get
the workflow semantics by default. Constant value clients can key
off if they want to surface the queued state in UX.
2026-05-25 16:41:46 +00:00
r1ckstardev
90bd954a47 BTCMaps v2: lane-conditional validation + reject GLOBAL country on btcmap lane
Per Hermes review findings #2 and #3 on PR #226.

#2 - Url / Description / Type were required unconditionally. BTC Map
submit_place needs none of those (its required-fields are origin +
external_id + lat + lon + category + name). Wrap the directory-lane
required-field checks in if(request.SubmitToDirectory) so a caller
with SubmitToBtcMap=true + SubmitToDirectory=false doesn't have to
populate unrelated directory metadata.

Name validation stays unconditional - both lanes need it.

#3 - Country=GLOBAL is the directory's pseudonym for online-only /
multi-region merchants; it has no meaning for the btcmap directory
map (every place is physically geocoded). Reject Country=GLOBAL when
SubmitToBtcMap=true so we don't forward osm:addr:country=GLOBAL to
btcmap. Directory-only callers keep the existing GLOBAL semantics.
2026-05-25 16:39:15 +00:00
r1ckstardev
373c1cbd6a BTCMaps v2: drop stale coordination-history comment
Per rollforsats msg 73 on PR #226. The comment narrated decision
history from an earlier review pass that subsequent iterations
partially reverted, so the running narrative is now misleading.
Keep the load-bearing JSON-RPC envelope reference, drop the rest.
2026-05-25 16:21:43 +00:00
r1ckstardev
edd7e8d2ed BTCMaps v2: add osm:addr:country + email + twitter back, gate payment rails behind caller flags
Per rollforsats msg 71 on PR #226. Iterating on extra_fields shape:

- Re-add osm:addr:country mapping (existing Country field, now using the
  granular osm:addr:* prefix in line with the other address fields).
- Re-add twitter mapping, but using the first-class `twitter` URL key
  per btcmap rest/v4/places.md (not contact:twitter). Normalize @handle
  vs URL input shapes.
- Add Email first-class field (`email` key in btcmap doc).
- Replace always-emit payment:onchain + payment:lightning with caller-
  controlled bool flags AcceptsOnchain + AcceptsLightning. Each emits
  the corresponding payment:<rail>=yes marker only when explicitly true.
  Lightning-only or on-chain-only stores can now signal accurately.
2026-05-25 16:07:00 +00:00
r1ckstardev
5faf9ce9c0 BTCMaps v2: extra_fields shape revisions per rollforsats review
Per rollforsats feedback on PR #226: trim the extra_fields surface to
what's directly useful for the btcmap directory map, and add granular
address tags + per-rail payment markers.

Drops:
- contact:twitter / contact:github / contact:onion - these channels
  aren't surfaced on the btcmap directory map; the rollforsats plugin
  side can iterate later if a use case surfaces.
- addr:country - replaced by the granular osm:addr:* fields below.
  Bare "addr:" prefix is not the btcmap-documented convention; their
  doc specifies osm:<tag_name> for OSM-style tags.
- payment:bitcoin=yes - replaced by the per-rail markers below; one
  yes/no is less informative than telling the directory which rails
  the merchant accepts.

Adds:
- HouseNumber, Street, City, Postcode fields on BtcMapsSubmitRequest;
  plugin captures these alongside lat/lon. Forwarded as
  osm:addr:housenumber / osm:addr:street / osm:addr:city /
  osm:addr:postcode per the btcmap rest/v4/places.md osm:<tag_name>
  convention (https://github.com/teambtcmap/btcmap-api/blob/master/docs/rest/v4/places.md).
- payment:onchain=yes + payment:lightning=yes always emitted. BTCPay
  merchants run both rails by default; granular per-rail flags can
  follow if a store-level disable becomes useful.

Keeps: website, description, phone.
2026-05-25 15:43:24 +00:00
r1ckstardev
717f55d2a6 BTCMaps v2: enforce https on BtcMap import endpoint before bearer auth
Per CodeRabbit review on PR #226. A misconfigured
BTCMAPS:BtcMapImportEndpoint over http:// would silently leak the
scoped token to anyone on the network path between plugin-builder
and btcmap. Parse the configured value as an absolute https URI
before building the request, throwing InvalidOperationException
with the offending value if the parse / scheme check fails. The
exception fires before SendAsync, so the token never reaches a
HttpRequestMessage header.

Adds 3 tests: http-rejected, non-absolute-rejected, token-missing
maps to BtcMapTokenMissingException (controller-ladder regression
guard). 40/40 BtcMapsServiceTests passing.
2026-05-25 15:06:27 +00:00
r1ckstardev
3836fe6b38 BTCMaps v2: add btcmap import-RPC submit path alongside directory submission
Adds a second downstream lane to /apis/btcmaps/v1/submit that forwards
the merchant payload to teambtcmap/btcmap-api's submit_place RPC
(merged 2026-05-24 in teambtcmap/btcmap-api#91).

Request schema:

- New fields Lat, Lon, Category, ExternalId on BtcMapsSubmitRequest,
  required iff SubmitToBtcMap=true. Validator enforces lat/lon
  ranges, lowercase-identifier category, and 1-200 char external_id.
  Plugin side (rollforsats/BTCPayServerPlugins) composes external_id
  as hostname:storeId so the namespace stays unique per BTCPay
  instance.
- New SubmitToDirectory + SubmitToBtcMap routing flags. The
  directory flag defaults true to preserve existing callers; btcmap
  defaults false so new callers must opt in.
- New Phone field forwarded as OSM Key:phone in extra_fields.

Service layer:

- BtcMapsService.SubmitToBtcMapAsync POSTs a JSON-RPC 2.0 envelope
  ({jsonrpc, method, params, id}) to BTCMAPS:BtcMapImportEndpoint
  (default https://api.btcmap.org/rpc) with method=submit_place,
  origin=btcpayserver, and the merchant payload mapped to the
  documented param shape. Bearer auth from BTCMAPS:BtcMapImportToken.
- Optional fields (website, description, twitter, github, onion,
  phone, country) ride along in extra_fields using OSM tag keys
  (contact:twitter, addr:country, etc.) plus the implicit
  payment:bitcoin=yes marker.
- New BtcMapTokenMissingException parallels the existing
  DirectoryTokenMissingException so the controller can return 503
  with a distinct error code when ops haven't provisioned the
  scoped token yet.

Controller:

- /apis/btcmaps/v1/submit branches on SubmitToDirectory +
  SubmitToBtcMap. At least one must be true (rejected 400 otherwise).
- Each lane has its own exception ladder symmetric to the existing
  directory path: token-missing 503 (directory-not-configured /
  btcmap-not-configured), caller-cancel rethrow, upstream-timeout
  504, generic-failure 502 - error codes namespaced by lane so ops
  can tell them apart.

HttpClient registration:

- New HttpClientNames.BtcMap named client registered with 15s
  per-call timeout and JSON Accept header, matching the
  BtcMapsDirectory budget for bounded worst-case behavior.

Tests:

- 12 new validation tests in BtcMapsServiceTests covering the
  SubmitToBtcMap=true required-field paths (Lat / Lon / Category /
  ExternalId; range checks; lowercase-identifier policy; overlong
  external_id) plus the default-false directory-only-still-works
  baseline. 37/37 BtcMapsServiceTests passing.
2026-05-25 14:45:46 +00:00
r1ckstardev
d34405970b Pre-stage: Phone field on submit request + BtcMap http client name
Invariant scaffolding for the btcmap import-RPC integration. Both
changes are additive (Phone is optional, BtcMap constant unreferenced
until impl). Build clean.

Pending coordination Qs from rollforsats (lat/lon source, category
mapping, external_id namespace) before service + controller wiring.
2026-05-24 16:59:59 +00:00
thgO.O
8ffea888af
Merge pull request #222 from btcpayserver/ft/request_listing_flow
Some checks failed
PluginBuilder Tests / test (push) Has been cancelled
Improve plugin listing request flow
2026-05-18 11:02:33 -03:00
thgO.O
19d6c58325
fix: mark request listing nav active 2026-05-18 00:31:18 -03:00
thgO.O
474264419a
chore: remove reviewer feedback leftovers 2026-05-18 00:30:59 -03:00
thgO.O
3d4068aa43
fix: keep listing rejection pending-only 2026-05-18 00:30:26 -03:00
thgO.O
ad5bb7bdc3
Merge pull request #224 from btcpayserver/btcmaps-v1-directory-only
Some checks failed
PluginBuilder Tests / test (push) Has been cancelled
Publish Docker image / Push Docker image to Docker Hub (push) Has been cancelled
2026-05-14 11:46:25 -03:00
Chukwuleta Tobechi
0dea08d032 remove reviewer_feedback 2026-05-14 11:59:58 +01:00
Chukwuleta Tobechi
61218fa8bb fix code rabbit suggestion 2026-05-14 11:42:35 +01:00
Chukwuleta Tobechi
21c2a93fdd revert changes 2026-05-14 11:21:20 +01:00
r1ckstardev
170d56d6d8 btcmaps v1: timeout vs caller-cancel split + canonical-case type/subType on write
Addresses thgO-O review id 4274558448 (PR #224) items #2 and #3.

#2 OperationCanceledException vs HttpClient.Timeout: the prior
catch-when filter excluded OCE wholesale to avoid swallowing client
disconnects, but HttpClient.Timeout surfaces as TaskCanceledException
(inherits from OCE) so an upstream GitHub timeout slipped through and
fell out as a 500 instead of the intended 502. Now split into three
arms: caller-cancel rethrows (info-log + drop the connection),
upstream-timeout returns 504 with discriminant directory-upstream-
timeout, anything else still 502 directory-upstream-failed.

#3 type/subType case normalization on write: ValidTypes +
ValidMerchantSubTypes match OrdinalIgnoreCase at validation, but the
write at BuildMerchantEntry preserved the user-submitted casing. A
submission of "Merchants" / "Books" would pass validation and land
non-canonical in merchants.json. Normalize to ToLowerInvariant on
write so the file stays in the lowercase convention. Country is
already validated against a case-sensitive Ordinal set so the
validator rejects non-uppercase before reaching the writer; no write-
path change needed there.
2026-05-13 12:42:50 +00:00
Chukwuleta Tobechi
5c29872ca4 resolve test failure 2026-05-11 21:33:38 +01:00
Chukwuleta Tobechi
66d9d5e172 Remove feedback and move request listing to side nav 2026-05-11 21:12:01 +01:00
r1ckstardev
b84538f2c2 btcmaps v1: IHttpClientFactory + markdown-safe PR body + idempotent branch + ISO whitelist
Addresses post-#224 review feedback from @rollforsats + CodeRabbit:

- IHttpClientFactory + named HttpClientNames.BtcMapsDirectory client
  replaces per-request `new HttpClient()`. 15s per-call timeout caps the
  ~5-7 GitHub round-trips at a bounded worst case instead of the default
  100s x N. Bearer token stays per-request (the BTCMAPS token is distinct
  from the global PluginBuilder GitHub token; must not leak into the
  singleton handler).
- Markdown injection guard on the PR body. User fields (Name, Type,
  SubType, Country, Twitter, GitHub) are wrapped in inline code spans
  with backtick-escape so a doctored merchant name can't render as a
  clickable link in the maintainer-facing PR description. Description
  goes inside a fenced code block. URL is rendered as <bare-url> autolink
  so the maintainer always sees the actual destination.
- Idempotent branch name: SHA-1-derived suffix from the normalized URL
  replaces the random GUID. Two concurrent same-URL submissions now
  collide on `git/refs` create instead of racing through preflight and
  opening duplicate PRs. The 422 "Reference already exists" surface is
  caught and mapped to the open-PR lookup or `branch-exists-no-open-pr`.
- NormalizeUrl lowercases scheme + host only and preserves path + query
  case verbatim. Lowercasing the whole URL falsely de-duplicates
  case-sensitive paths.
- Country code validation moves to an actual ISO 3166-1 alpha-2 set
  built from CultureInfo at startup. Replaces the
  `length==2 && IsUpper` shape that accepted reserved/unassigned codes
  like ZZ.
- Missing BTCMAPS:DirectoryGithubToken throws
  DirectoryTokenMissingException at the service layer; controller maps
  it to 503 with `directory-not-configured`. Previously surfaced as a
  200 OK with `Skipped` which a client could misread as "accepted".

5 new tests:
- Validate_RejectsNonAssignedTwoLetterCountry (ZZ)
- NormalizeUrl_PreservesPathCase
- NormalizeUrl_PreservesQueryCase
- BuildBranchName_DeterministicForSameUrl
- BuildBranchName_DiffersForDifferentUrls

25/25 BtcMapsServiceTests pass on Release build.
2026-05-11 16:32:04 +00:00
r1ckstardev
33148aafcd 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.
2026-05-10 18:52:16 +00:00
thgO.O
4480aa01d4
Merge pull request #223 from ak91456/master
Some checks failed
PluginBuilder Tests / test (push) Has been cancelled
add Unlisted badge to plugin cards in public directory search results
2026-05-09 01:07:30 -03:00
thgO.O
4616f040dd
refactor: use visibility enum for unlisted check 2026-05-09 00:47:56 -03:00
thgO.O
1901893d69
style: refine unlisted badge placement 2026-05-09 00:28:35 -03:00
Khushvendra Singh
e0c8972366
Align antiforgery error details flow with BTCPay UI handling (#196) (#197)
* Align antiforgery error details flow with BTCPay UI handling (#196)

- add UIControllerAntiforgeryTokenAttribute equivalent and register it globally in MVC\n- populate UIErrorController.ErrorDetailsKey on antiforgery validation failures\n- keep UI-only/non-safe-method antiforgery behavior aligned with BTCPay policy semantics\n- add focused unit tests for validation, existing failure enrichment, GET bypass, API bypass, and IgnoreAntiforgeryToken bypass\n- add integration-style error page test to verify generic UI error view renders CSRF details

* Align antiforgery policy precedence for UI error handling

* Fix antiforgery error details and exempt NIP-07 verify endpoint

* Preserve detailed antiforgery error messages

* Keep antiforgery PR focused

* fix: keep antiforgery details generic

* test: focus antiforgery coverage

---------

Co-authored-by: thgO.O <thgo.o.btc@proton.me>
2026-05-08 18:40:12 -03:00
Psycological
d3af9746b8 add Unlisted badge to plugin cards in public directory search results 2026-05-08 08:10:39 +05:30
Chukwuleta Tobechi
0750690a44 Include tests 2026-05-06 16:48:14 +01:00
Chukwuleta Tobechi
85a8581e06 Improve plugin listing request flow 2026-05-06 15:47:46 +01:00
thgO.O
cf99e25872
Merge pull request #214 from makeentosch/feature/api-validation
Some checks failed
PluginBuilder Tests / test (push) Has been cancelled
Api validation implemented
2026-05-04 18:01:08 -03:00
thgO.O
68058ed408
test: assert api build validation does not create build 2026-05-04 17:22:31 -03:00
makeentosch
53472297c1 fix: tests 2026-05-04 21:18:42 +03:00
Chukwuleta Tobechi
05011e0fc5
Merge pull request #219 from btcpayserver/fx/request_listing_revamp
Some checks are pending
PluginBuilder Tests / test (push) Waiting to run
request listing view update
2026-05-03 21:42:09 +01:00
Chukwuleta Tobechi
95f67b3fda resolve code rabbit suggeston 2026-05-03 21:27:47 +01:00
Chukwuleta Tobechi
4582d96b3d request listing view update 2026-05-03 21:09:03 +01:00
makeentosch
ee45a5464c tests implementation 2026-05-03 21:20:07 +03:00
Chukwuleta Tobechi
8c42cf7d70
Merge pull request #206 from SusanGithaigaN/versions
Some checks failed
PluginBuilder Tests / test (push) Has been cancelled
add select plugin version option
2026-05-01 18:46:48 +01:00
Chukwuleta Tobechi
7518da1cc6 retest 2026-05-01 18:31:16 +01:00
Chukwuleta Tobechi
fd49a37c63 resolve test failure 2026-05-01 18:12:36 +01:00
Chukwuleta Tobechi
ac1dc009cd asome minor fixes 2026-05-01 17:40:07 +01:00
Chukwuleta Tobechi
3752a94d6c
Merge pull request #212 from btcpayserver/fix/local-download-loopback
Some checks are pending
PluginBuilder Tests / test (push) Waiting to run
Handle local loopback plugin downloads
2026-04-29 20:15:46 +01:00
makeentosch
5ee5f70c6d api validation implemented 2026-04-28 20:06:28 +03:00
susan githaiga
650f74781d add select plugin version option 2026-04-22 18:11:02 +03:00
36 changed files with 2557 additions and 923 deletions

View File

@ -0,0 +1,132 @@
using System.Net;
using System.Text;
using Dapper;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
using PluginBuilder.APIModels;
using PluginBuilder.Services;
using PluginBuilder.Util.Extensions;
using Xunit;
using Xunit.Abstractions;
namespace PluginBuilder.Tests.ApiTests;
public class CreateBuildValidationApiTests(ITestOutputHelper logs) : UnitTestBase(logs)
{
private const string Password = "123456";
private const string MediaType = "application/json";
private static readonly JsonSerializerSettings SerializerSettings = new() { ContractResolver = new CamelCasePropertyNamesContractResolver() };
[Fact]
public async Task Should_Return422_If_InvalidPluginDirectory_WithoutCsproj()
{
// Arrange
await using var tester = Create();
tester.ReuseDatabase = false;
await tester.Start();
var email = $"test-{Guid.NewGuid():N}@example.com";
var ownerId = await tester.CreateFakeUserAsync(email, Password);
var pluginSlug = "test-no-csproj-" + Guid.NewGuid().ToString("N")[..8];
await using var conn = await tester.GetService<DBConnectionFactory>().Open();
await conn.NewPlugin(pluginSlug, ownerId);
var client = tester.CreateHttpClient().SetBasicAuth(email, Password);
var request = new CreateBuildRequest
{
GitRepository = ServerTester.RepoUrl,
GitRef = ServerTester.GitRef,
PluginDirectory = "Invalid/Path/Without/Csproj",
BuildConfig = "Release"
};
// Act
var content = new StringContent(
JsonConvert.SerializeObject(request, SerializerSettings),
Encoding.UTF8,
MediaType);
var response = await client.PostAsync(
$"/api/v1/plugins/{pluginSlug}/builds",
content);
// Assert
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var result = await response.Content.ReadAsStringAsync();
Assert.Contains("Manifest validation failed:", result);
Assert.False(await conn.ExecuteScalarAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM builds WHERE plugin_slug = @pluginSlug)",
new { pluginSlug }));
}
[Fact]
public async Task Should_Return422_If_SameRepo_UsedForAnotherPluginSlug()
{
// Arrange
await using var tester = Create();
tester.ReuseDatabase = false;
await tester.Start();
var email1 = $"owner1-{Guid.NewGuid():N}@example.com";
var email2 = $"owner2-{Guid.NewGuid():N}@example.com";
var ownerId1 = await tester.CreateFakeUserAsync(email1, Password);
var ownerId2 = await tester.CreateFakeUserAsync(email2, Password);
var pluginSlug1 = "first-plugin-" + Guid.NewGuid().ToString("N")[..8];
await using var conn = await tester.GetService<DBConnectionFactory>().Open();
await conn.NewPlugin(pluginSlug1, ownerId1);
var buildService = tester.GetService<BuildService>();
var actualIdentifier = await buildService.FetchIdentifierFromCsprojAsync(
ServerTester.RepoUrl,
ServerTester.GitRef,
ServerTester.PluginDir);
await conn.ExecuteAsync(
"UPDATE plugins SET identifier = @identifier WHERE slug = @slug",
new
{
identifier = actualIdentifier,
slug = pluginSlug1
});
var client = tester.CreateHttpClient().SetBasicAuth(email2, Password);
var pluginSlug2 = "second-plugin-" + Guid.NewGuid().ToString("N")[..8];
await conn.NewPlugin(pluginSlug2, ownerId2);
var request = new CreateBuildRequest
{
GitRepository = ServerTester.RepoUrl,
GitRef = ServerTester.GitRef,
PluginDirectory = ServerTester.PluginDir,
BuildConfig = "Release"
};
// Act
var content = new StringContent(
JsonConvert.SerializeObject(request, SerializerSettings),
Encoding.UTF8,
MediaType);
var response = await client.PostAsync(
$"/api/v1/plugins/{pluginSlug2}/builds",
content);
// Assert
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
var result = await response.Content.ReadAsStringAsync();
Assert.Contains("does not belong to plugin slug", result);
Assert.False(await conn.ExecuteScalarAsync<bool>(
"SELECT EXISTS(SELECT 1 FROM builds WHERE plugin_slug = @pluginSlug)",
new { pluginSlug = pluginSlug2 }));
}
}

View File

@ -0,0 +1,388 @@
using System.Net.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using PluginBuilder.APIModels;
using PluginBuilder.Services;
using Xunit;
namespace PluginBuilder.Tests;
public class BtcMapsServiceTests
{
private sealed class StubHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new HttpClient();
}
private static BtcMapsService MakeService() =>
new BtcMapsService(
configuration: new ConfigurationBuilder().Build(),
httpClientFactory: new StubHttpClientFactory(),
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_RejectsNonAssignedTwoLetterCountry()
{
// ZZ is reserved / not assigned in ISO 3166-1, so the validator must
// reject it even though it passes the length + casing check.
var req = MakeValid();
req.Country = "ZZ";
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));
}
// 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()
{
// Scheme + host are case-insensitive (DNS + RFC); path + query are not, so
// they must be preserved verbatim. Trailing slash is stripped only when the
// path is non-root.
Assert.Equal("https://example.com/", BtcMapsService.NormalizeUrl("HTTPS://Example.com/"));
Assert.Equal("https://example.com/", BtcMapsService.NormalizeUrl(" https://example.com "));
}
[Fact]
public void NormalizeUrl_PreservesPathCase()
{
Assert.Equal("https://example.com/Foo/Bar",
BtcMapsService.NormalizeUrl("HTTPS://Example.com/Foo/Bar/"));
}
[Fact]
public void NormalizeUrl_PreservesQueryCase()
{
Assert.Equal("https://example.com/path?ID=ABC",
BtcMapsService.NormalizeUrl("https://EXAMPLE.com/path?ID=ABC"));
}
[Fact]
public void BuildBranchName_DeterministicForSameUrl()
{
var a = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/foo");
var b = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/foo");
Assert.Equal(a, b);
Assert.StartsWith("btcmaps/good-shop-", a);
}
[Fact]
public void BuildBranchName_DiffersForDifferentUrls()
{
var a = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/foo");
var b = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/bar");
Assert.NotEqual(a, b);
}
[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);
}
}

View File

@ -0,0 +1,157 @@
using System.Reflection;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using PluginBuilder.Controllers;
using PluginBuilder.Filters;
using Xunit;
namespace PluginBuilder.Tests.FilterTests;
public class UIControllerAntiforgeryTokenAttributeTests
{
[Fact]
public async Task OnAuthorizationAsync_WithPostUiRequest_ValidationFailure_SetsResultAndErrorDetails()
{
var filter = new UIControllerAntiforgeryTokenAttribute();
var services = new ServiceCollection()
.AddSingleton<IAntiforgery, ThrowingAntiforgery>()
.BuildServiceProvider();
var context = CreateContext(filter, typeof(DummyUiController), HttpMethods.Post, services);
await filter.OnAuthorizationAsync(context);
Assert.IsType<AntiforgeryValidationFailedResult>(context.Result);
Assert.Equal("CSRF token validation failed.", context.HttpContext.Items[UIErrorController.ErrorDetailsKey]);
}
[Fact]
public async Task OnAuthorizationAsync_WithPostApiRequest_DoesNotValidate()
{
var filter = new UIControllerAntiforgeryTokenAttribute();
var services = new ServiceCollection()
.AddSingleton<IAntiforgery, ThrowingAntiforgery>()
.BuildServiceProvider();
var context = CreateContext(filter, typeof(DummyApiController), HttpMethods.Post, services);
await filter.OnAuthorizationAsync(context);
Assert.Null(context.Result);
Assert.False(context.HttpContext.Items.ContainsKey(UIErrorController.ErrorDetailsKey));
}
[Fact]
public async Task OnAuthorizationAsync_WithIgnoreAntiforgeryPolicy_DoesNotValidate()
{
var filter = new UIControllerAntiforgeryTokenAttribute();
var services = new ServiceCollection()
.AddSingleton<IAntiforgery, ThrowingAntiforgery>()
.BuildServiceProvider();
var context = CreateContext(
filter,
typeof(DummyUiController),
HttpMethods.Post,
services,
new IgnoreAntiforgeryTokenAttribute());
await filter.OnAuthorizationAsync(context);
Assert.Null(context.Result);
Assert.False(context.HttpContext.Items.ContainsKey(UIErrorController.ErrorDetailsKey));
}
[Fact]
public async Task OnResultExecutionAsync_WithAntiforgeryFailureResult_AddsErrorDetails()
{
var filter = new UIControllerAntiforgeryTokenAttribute();
var httpContext = new DefaultHttpContext();
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
List<IFilterMetadata> filters = new() { filter };
var result = new AntiforgeryValidationFailedResult();
var controller = new object();
var context = new ResultExecutingContext(actionContext, filters, result, controller);
await filter.OnResultExecutionAsync(context, () =>
{
var executedContext = new ResultExecutedContext(actionContext, filters, result, controller);
return Task.FromResult(executedContext);
});
Assert.Equal("CSRF token validation failed.", context.HttpContext.Items[UIErrorController.ErrorDetailsKey]);
}
[Fact]
public void NostrVerifyNip07_HasIgnoreAntiforgeryTokenAttribute()
{
var method = typeof(AccountController).GetMethod(nameof(AccountController.NostrVerifyNip07));
Assert.NotNull(method);
Assert.NotNull(method!.GetCustomAttribute<IgnoreAntiforgeryTokenAttribute>());
}
private static AuthorizationFilterContext CreateContext(
UIControllerAntiforgeryTokenAttribute filter,
Type controllerType,
string method,
IServiceProvider services,
params IFilterMetadata[] extraFilters)
{
var httpContext = new DefaultHttpContext
{
RequestServices = services
};
httpContext.Request.Method = method;
var descriptor = new ControllerActionDescriptor
{
ActionName = "Action",
ControllerName = controllerType.Name.Replace("Controller", string.Empty, StringComparison.Ordinal),
ControllerTypeInfo = controllerType.GetTypeInfo()
};
var actionContext = new ActionContext(httpContext, new RouteData(), descriptor);
List<IFilterMetadata> filters = new();
filters.Add(filter);
filters.AddRange(extraFilters);
// Match MVC's ordered execution so IAntiforgeryPolicy precedence is realistic in tests.
var orderedFilters = filters
.Select((metadata, index) => new
{
Metadata = metadata,
Order = (metadata as IOrderedFilter)?.Order ?? 0,
Index = index
})
.OrderBy(x => x.Order)
.ThenBy(x => x.Index)
.Select(x => x.Metadata)
.ToList();
return new AuthorizationFilterContext(actionContext, orderedFilters);
}
private sealed class DummyUiController : Controller;
private sealed class DummyApiController : ControllerBase;
private sealed class ThrowingAntiforgery : IAntiforgery
{
public AntiforgeryTokenSet GetAndStoreTokens(HttpContext httpContext) => throw new NotSupportedException();
public AntiforgeryTokenSet GetTokens(HttpContext httpContext) => throw new NotSupportedException();
public Task<bool> IsRequestValidAsync(HttpContext httpContext) => throw new NotSupportedException();
public void SetCookieTokenAndHeader(HttpContext httpContext) => throw new NotSupportedException();
public Task ValidateRequestAsync(HttpContext httpContext) => throw new AntiforgeryValidationException("Invalid CSRF token.");
}
}

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("a.btn.btn-primary:text('Request Listing')");
await t.Page.ClickAsync("#StoreNav-RequestListing");
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("a.btn.btn-primary:text('Request Listing')");
await t.Page.ClickAsync("#StoreNav-RequestListing");
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("a.btn.btn-primary:text('Request Listing')");
await t.Page.ClickAsync("#StoreNav-RequestListing");
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.btn.btn-sm.btn-primary:text('View Details')").ClickAsync();
await row.Locator($"a[href*='/admin/listing-requests/{requestId}']").ClickAsync();
await Expect(t.Page).ToHaveURLAsync(new Regex($".*/admin/listing-requests/{requestId}", RegexOptions.IgnoreCase));
// Verify request details are displayed
@ -172,6 +172,44 @@ 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

@ -172,4 +172,23 @@ public class ErrorPageTests(ITestOutputHelper logs) : UnitTestBase(logs)
Assert.Contains("404 - Page not found", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("399 -", body, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ForgotPassword_WithoutAntiforgeryToken_ShowsCsrfDetails()
{
await using var tester = await Start();
var client = tester.CreateHttpClient();
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html"));
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["Email"] = "test@example.com"
});
var response = await client.PostAsync("/forgotpassword", content);
var body = await response.Content.ReadAsStringAsync();
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
Assert.Contains("400 - Bad Request", body, StringComparison.OrdinalIgnoreCase);
Assert.Contains("CSRF token validation failed.", body, StringComparison.OrdinalIgnoreCase);
}
}

View File

@ -0,0 +1,46 @@
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; }
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

@ -0,0 +1,22 @@
namespace PluginBuilder.APIModels;
public sealed class BtcMapsSubmitResponse
{
public BtcMapsDirectoryResult? Directory { get; set; }
public BtcMapsBtcMapResult? BtcMap { 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; }
}
public sealed class BtcMapsBtcMapResult
{
public long? Id { get; set; }
public string? Origin { get; set; }
public string? ExternalId { get; set; }
}

View File

@ -39,6 +39,7 @@ public class PublishedPlugin : PublishedVersion
get => BuildInfo?["pluginDir"]?.ToString();
}
public bool IsUnlisted { get; set; }
public PluginRatingSummary RatingSummary { get; set; } = new();
public string GetSourceUrl(GitHostingProviderFactory providerFactory)

View File

@ -19,6 +19,16 @@
<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,6 +2,7 @@ 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;
@ -26,22 +27,28 @@ public class MainNav : ViewComponent
using var conn = await ConnectionFactory.Open();
if (pluginSlug != null)
if (pluginSlug is { } currentPluginSlug)
{
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 = pluginSlug.ToString(), userId = UserManager.GetUserId(UserClaimsPrincipal) });
"ORDER BY v.ver DESC LIMIT 10", new { pluginSlug = slug, userId = UserManager.GetUserId(UserClaimsPrincipal) });
foreach (var r in rows)
vm.Versions.Add(new PluginVersionViewModel
{
PluginSlug = pluginSlug?.ToString(),
PluginSlug = slug,
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,4 +12,5 @@ public class MainNavViewModel
public List<PluginVersionViewModel> Versions { get; set; } = new();
public int PendingListingRequestsCount { get; set; }
public bool RequestListing { get; set; }
}

View File

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

View File

@ -164,6 +164,7 @@ public class AccountController(
}
[HttpPost("nostr/verify-nip07")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> NostrVerifyNip07([FromBody] VerifyNip07Request req)
{
var user = await userManager.GetUserAsync(User) ?? throw new Exception("User not found");

View File

@ -1286,7 +1286,6 @@ 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);
@ -1296,6 +1295,5 @@ public class AdminController(
TempData[TempDataConstant.SuccessMessage] = $"Plugin listing request for '{request.PluginSlug}' has been rejected";
return RedirectToAction(nameof(ListingRequests));
}
#endregion
}

View File

@ -324,6 +324,26 @@ public class ApiController(
if (!ModelState.IsValid)
return ValidationErrorResult(ModelState);
try
{
var identifier = await buildService.FetchIdentifierFromCsprojAsync(
model.GitRepository,
model.GitRef,
model.PluginDirectory);
var owns = await conn.EnsureIdentifierOwnership(pluginSlug, identifier);
if (!owns)
{
ModelState.AddModelError(string.Empty, $"The plugin identifier {identifier} does not belong to plugin slug {pluginSlug}.");
return ValidationErrorResult(ModelState);
}
}
catch (BuildServiceException ex)
{
ModelState.AddModelError(string.Empty, $"Manifest validation failed: {ex.Message}");
return ValidationErrorResult(ModelState);
}
var buildId = await conn.NewBuild(pluginSlug, model.ToBuildParameter());
var buildUrl = Url.ActionLink(nameof(PluginController.Build), "Plugin",
new { pluginSlug = pluginSlug.ToString(), buildId });

View File

@ -0,0 +1,109 @@
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 });
// 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;
if (request.SubmitToDirectory)
{
try
{
directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
}
catch (BtcMapsService.DirectoryTokenMissingException ex)
{
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)
{
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 });
}
}
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 });
}
}

View File

@ -230,6 +230,7 @@ public class HomeController(
lv.plugin_slug,
lv.ver,
p.settings,
p.visibility,
b.id,
b.manifest_info,
b.build_info,
@ -278,7 +279,7 @@ public class HomeController(
}
var rows = await conn
.QueryAsync<(string plugin_slug, int[] ver, string settings, long id, string manifest_info, string build_info, decimal avg_rating, int total_reviews
.QueryAsync<(string plugin_slug, int[] ver, string settings, PluginVisibilityEnum visibility, long id, string manifest_info, string build_info, decimal avg_rating, int total_reviews
)>(
query,
new
@ -304,6 +305,7 @@ public class HomeController(
BuildInfo = JObject.Parse(r.build_info),
ManifestInfo = manifestInfo,
PluginLogo = settings?.Logo,
IsUnlisted = r.visibility == PluginVisibilityEnum.Unlisted,
RatingSummary = new PluginRatingSummary
{
Average = r.avg_rating,

View File

@ -318,6 +318,35 @@ 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))]
@ -335,6 +364,7 @@ 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);
@ -342,6 +372,7 @@ public class PluginController(
model.ReleaseNote = pluginSettings?.Description;
model.HasPreviousRejection = rejectedRequest != null;
model.HasRequests = allRequests.Any();
if (pendingRequest != null)
{
@ -737,7 +768,6 @@ public class PluginController(
}
var pluginSettings = await conn.GetPluginDetails(pluginSlug);
vm.RequestListing = pluginSettings?.Visibility == PluginVisibilityEnum.Unlisted;
return View(vm);
}

View File

@ -4,4 +4,6 @@ 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,6 +15,23 @@ 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

@ -0,0 +1,95 @@
#nullable enable
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using PluginBuilder.Controllers;
namespace PluginBuilder.Filters;
public class UIControllerAntiforgeryTokenAttribute :
Attribute,
IFilterMetadata,
IAntiforgeryPolicy,
IAsyncAuthorizationFilter,
IAsyncAlwaysRunResultFilter,
IOrderedFilter
{
public int Order => 1000;
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
{
if (context.Result is AntiforgeryValidationFailedResult)
{
AddErrorDetails(context.HttpContext);
return;
}
var antiforgery = context.HttpContext.RequestServices.GetService<IAntiforgery>();
if (
antiforgery is not null &&
context.IsEffectivePolicy<IAntiforgeryPolicy>(this) &&
ShouldValidate(context))
{
try
{
await antiforgery.ValidateRequestAsync(context.HttpContext);
}
catch (AntiforgeryValidationException)
{
context.Result = new AntiforgeryValidationFailedResult();
AddErrorDetails(context.HttpContext);
}
}
}
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
{
if (context.Result is AntiforgeryValidationFailedResult)
AddErrorDetails(context.HttpContext);
await next();
}
private static void AddErrorDetails(HttpContext context, string? message = null)
{
if (!string.IsNullOrWhiteSpace(message))
{
context.Items[UIErrorController.ErrorDetailsKey] = message;
return;
}
if (context.Items.TryGetValue(UIErrorController.ErrorDetailsKey, out var existing) &&
existing is string existingMessage &&
!string.IsNullOrWhiteSpace(existingMessage))
return;
context.Items[UIErrorController.ErrorDetailsKey] = "CSRF token validation failed.";
}
private static bool ShouldValidate(AuthorizationFilterContext context)
{
var isUi = IsUi(context);
if (isUi is not true)
return false;
var method = context.HttpContext.Request.Method;
return !HttpMethods.IsGet(method) && !HttpMethods.IsHead(method) && !HttpMethods.IsTrace(method) && !HttpMethods.IsOptions(method);
}
private static bool? IsUi(AuthorizationFilterContext context)
{
if (context.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor)
return null;
if (controllerActionDescriptor.ControllerName.StartsWith("UI", StringComparison.OrdinalIgnoreCase))
return true;
if (controllerActionDescriptor.ControllerName.StartsWith("Greenfield", StringComparison.OrdinalIgnoreCase))
return false;
return typeof(Controller).IsAssignableFrom(controllerActionDescriptor.ControllerTypeInfo);
}
}

View File

@ -4,4 +4,5 @@ public class Policies
{
public const string OwnPlugin = "OwnPlugin";
public const string PublicApiRateLimit = "PublicApiRateLimit";
public const string BtcMapsSubmitRateLimit = "BtcMapsSubmitRateLimit";
}

View File

@ -17,6 +17,7 @@ using PluginBuilder.Authentication;
using PluginBuilder.Configuration;
using PluginBuilder.Controllers.Logic;
using PluginBuilder.DataModels;
using PluginBuilder.Filters;
using PluginBuilder.HostedServices;
using PluginBuilder.Hubs;
using PluginBuilder.Services;
@ -143,7 +144,10 @@ public class Program
public void AddServices(IConfiguration configuration, IServiceCollection services, IHostEnvironment env)
{
services.AddControllersWithViews()
services.AddControllersWithViews(options =>
{
options.Filters.Add(new UIControllerAntiforgeryTokenAttribute());
})
.AddRazorRuntimeCompilation()
.AddRazorOptions(options =>
{
@ -207,6 +211,28 @@ public class Program
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
});
services.AddHttpClient(HttpClientNames.BtcMapsDirectory, client =>
{
// Per-call timeout caps a single GitHub round-trip at 15s. The directory
// submission makes ~5-7 GitHub calls sequentially; with the default 100s
// timeout a hung remote could pin the request for ~10min and tie up a
// rate-limit slot. 15s per call keeps the worst case bounded.
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("User-Agent", "PluginBuilder-BtcMaps/1.0");
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
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/");
@ -237,6 +263,7 @@ public class Program
});
services.AddScoped<PluginOwnershipService>();
services.AddScoped<VersionLifecycleService>();
services.AddSingleton<BtcMapsService>();
services.AddRateLimiter(options =>
{
@ -262,6 +289,23 @@ public class Program
QueueLimit = 0
});
});
options.AddPolicy(Policies.BtcMapsSubmitRateLimit, httpContext =>
{
// Per-source-IP fixed window: 3 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.
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(clientIp, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 3,
Window = TimeSpan.FromHours(24),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});
});
services.AddOutputCache(options =>

View File

@ -0,0 +1,668 @@
using System.Globalization;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using PluginBuilder.APIModels;
using PluginBuilder.DataModels;
namespace PluginBuilder.Services;
public sealed class BtcMapsService
{
private const string DefaultDirectoryRepo = "btcpayserver/directory.btcpayserver.org";
private const string DefaultDirectoryMerchantsPath = "src/data/merchants.json";
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"
};
// ISO 3166-1 alpha-2 codes derived from CultureInfo at startup. Cached because
// CultureInfo.GetCultures + RegionInfo enumeration is non-trivial and the set
// is stable for the process lifetime.
private static readonly HashSet<string> Iso3166Alpha2 = BuildIsoAlpha2Set();
private static HashSet<string> BuildIsoAlpha2Set()
{
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var culture in CultureInfo.GetCultures(CultureTypes.SpecificCultures))
{
try
{
var region = new RegionInfo(culture.Name);
if (region.TwoLetterISORegionName.Length == 2 &&
region.TwoLetterISORegionName.All(c => c is >= 'A' and <= 'Z'))
{
set.Add(region.TwoLetterISORegionName);
}
}
catch (ArgumentException)
{
// Some neutral cultures throw; skip.
}
}
return set;
}
private readonly IConfiguration _configuration;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<BtcMapsService> _logger;
public BtcMapsService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
ILogger<BtcMapsService> logger)
{
_configuration = configuration;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public sealed class DirectoryTokenMissingException : Exception
{
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 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))
{
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))
{
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."));
}
}
// 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)
{
var token = _configuration["BTCMAPS:DirectoryGithubToken"];
if (string.IsNullOrWhiteSpace(token))
throw new DirectoryTokenMissingException();
var repo = _configuration["BTCMAPS:DirectoryRepo"] ?? DefaultDirectoryRepo;
var merchantsPath = _configuration["BTCMAPS:DirectoryMerchantsPath"] ?? DefaultDirectoryMerchantsPath;
var client = _httpClientFactory.CreateClient(HttpClientNames.BtcMapsDirectory);
// Auth is per-call: the named-client registration sets BaseAddress + User-Agent
// + Accept + Timeout, but the BTCMAPS token is distinct from the global
// PluginBuilder GitHub token and must not be baked into the singleton handler.
using var authClient = new HttpRequestAuth(client, token);
var repoInfo = await GetJsonAsync(authClient, $"repos/{repo}", cancellationToken);
var defaultBranch = repoInfo.GetProperty("default_branch").GetString()
?? throw new InvalidOperationException("default_branch missing");
var fileInfo = await GetJsonAsync(
authClient,
$"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}" };
}
}
// Deterministic branch name derived from the normalized URL. Two concurrent
// submissions of the same URL collide on the git/refs create instead of
// racing through preflight and opening duplicate PRs.
var branchName = BuildBranchName(request.Name!, normalizedUrl);
var marker = BuildUrlMarker(normalizedUrl);
var branchRef = await GetJsonAsync(
authClient,
$"repos/{repo}/git/ref/heads/{Uri.EscapeDataString(defaultBranch)}",
cancellationToken);
var baseSha = branchRef.GetProperty("object").GetProperty("sha").GetString()
?? throw new InvalidOperationException("base sha missing");
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 refCreateResponse = await PostJsonAllowConflictAsync(
authClient,
$"repos/{repo}/git/refs",
new { @ref = $"refs/heads/{branchName}", sha = baseSha },
cancellationToken);
if (refCreateResponse.IsConflict)
{
// Branch already exists. Look up the open PR keyed by the URL marker;
// if one is open, return its details; otherwise this is a stuck-branch
// from a prior failed run and we cannot safely reuse it.
var openPrSearch = await GetJsonAsync(
authClient,
$"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
};
}
return new BtcMapsDirectoryResult { Skipped = "branch-exists-no-open-pr", Branch = branchName };
}
await PutJsonAsync(authClient, $"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(authClient, $"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());
// type + subType are validated case-insensitively above
// (ValidTypes / ValidMerchantSubTypes use OrdinalIgnoreCase) but
// the upstream merchants.json convention is lowercase. Normalize
// on write so a submission of "Merchants" / "Books" lands as
// "merchants" / "books" in the file. Country uses a case-sensitive
// Ordinal set so the validator already rejects non-uppercase.
w.WriteString("type", request.Type!.Trim().ToLowerInvariant());
if (!string.IsNullOrWhiteSpace(request.SubType))
w.WriteString("subType", request.SubType.Trim().ToLowerInvariant());
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)
{
// User-supplied fields are wrapped in inline code spans so a doctored merchant
// name (e.g. `[click here](https://attacker.example)`) can't render as a clickable
// link in the maintainer-facing PR description. The URL is its own line and is
// displayed via a plain markdown link with a sanitized label so the maintainer
// sees the bare URL, not a renamed target.
var sb = new StringBuilder();
sb.AppendLine("Automated submission from the BTCPay Server plugin-builder `/apis/btcmaps/v1/submit` endpoint.");
sb.AppendLine();
sb.AppendLine($"- **Name:** {EscapeInlineCode(request.Name)}");
sb.AppendLine($"- **URL:** <{request.Url}>");
sb.AppendLine($"- **Type:** {EscapeInlineCode(request.Type)}{(string.IsNullOrWhiteSpace(request.SubType) ? string.Empty : " / " + EscapeInlineCode(request.SubType))}");
if (!string.IsNullOrWhiteSpace(request.Country)) sb.AppendLine($"- **Country:** {EscapeInlineCode(request.Country.Trim())}");
if (!string.IsNullOrWhiteSpace(request.Twitter))
{
// The Twitter handle is rendered inside an inline code span so a hostile
// value like `]( <evil-url> )` cannot escape into an active link. The
// maintainer can copy the handle and visit manually.
var raw = request.Twitter.Trim();
var handle = raw.StartsWith("@") ? raw[1..] : raw;
sb.AppendLine($"- **Twitter:** {EscapeInlineCode("@" + handle)}");
}
if (!string.IsNullOrWhiteSpace(request.Github)) sb.AppendLine($"- **GitHub:** {EscapeInlineCode(request.Github)}");
sb.AppendLine();
sb.AppendLine("**Description:**");
sb.AppendLine();
sb.AppendLine("```");
sb.AppendLine(request.Description?.Replace("```", "```") ?? string.Empty);
sb.AppendLine("```");
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();
}
// Wrap user input as an inline code span. Use enough backticks to escape any
// backticks the input contains (CommonMark inline-code-fence rule).
private static string EscapeInlineCode(string? value)
{
var s = value ?? string.Empty;
if (s.Length == 0) return "``";
var longestRun = 0;
var current = 0;
foreach (var c in s)
{
if (c == '`') { current++; if (current > longestRun) longestRun = current; }
else current = 0;
}
var fence = new string('`', longestRun + 1);
// Pad with spaces if the value starts or ends with a backtick (CommonMark rule).
var needsPad = s.StartsWith("`") || s.EndsWith("`");
return needsPad ? $"{fence} {s} {fence}" : $"{fence}{s}{fence}";
}
private static string BuildUrlMarker(string normalizedUrl) =>
$"btcmaps-submit:url={normalizedUrl}";
public static string NormalizeUrl(string url)
{
// Normalize for duplicate detection without lying about case-sensitive parts.
// Scheme + host get lower-cased (DNS is case-insensitive, scheme is too); path
// and query are preserved as-is. Trailing slash is trimmed only when the path
// is the bare root, since /foo/ and /foo are sometimes distinct on real servers.
var trimmed = (url ?? string.Empty).Trim();
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var parsed))
return trimmed.TrimEnd('/');
var sb = new StringBuilder();
sb.Append(parsed.Scheme.ToLowerInvariant());
sb.Append("://");
sb.Append(parsed.Host.ToLowerInvariant());
if (!parsed.IsDefaultPort)
{
sb.Append(':');
sb.Append(parsed.Port);
}
var path = parsed.AbsolutePath;
if (path == "/")
sb.Append(path);
else
sb.Append(path.TrimEnd('/'));
if (!string.IsNullOrEmpty(parsed.Query))
sb.Append(parsed.Query);
return sb.ToString();
}
// Deterministic branch name from the normalized URL. Same URL always produces
// the same branch; second concurrent submission collides on the git/refs create
// and the controller surfaces the duplicate-open-PR shape.
public static string BuildBranchName(string name, string normalizedUrl)
{
var slug = Slugify(name);
var hash = SHA1.HashData(Encoding.UTF8.GetBytes(normalizedUrl));
var suffix = Convert.ToHexString(hash)[..8].ToLowerInvariant();
return $"btcmaps/{slug}-{suffix}";
}
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;
}
// Lightweight wrapper that re-attaches the per-request Authorization header on
// each call. The named-client handler is reused (socket pool, factory rotation),
// but auth stays out of the singleton handler.
private sealed class HttpRequestAuth : IDisposable
{
public HttpClient Client { get; }
public string Token { get; }
public HttpRequestAuth(HttpClient client, string token) { Client = client; Token = token; }
public void Dispose() { /* HttpClient is owned by IHttpClientFactory; do not dispose */ }
}
private static HttpRequestMessage NewRequest(HttpMethod method, string path, string token)
{
var req = new HttpRequestMessage(method, path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return req;
}
private static async Task<JsonElement> GetJsonAsync(HttpRequestAuth auth, string path, CancellationToken ct)
{
using var req = NewRequest(HttpMethod.Get, path, auth.Token);
using var response = await auth.Client.SendAsync(req, 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(HttpRequestAuth auth, string path, object body, CancellationToken ct)
{
using var req = NewRequest(HttpMethod.Post, path, auth.Token);
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
using var response = await auth.Client.SendAsync(req, ct);
await EnsureSuccess(response, path, ct);
var text = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
return doc.RootElement.Clone();
}
private readonly record struct ConflictAware(JsonElement? Body, bool IsConflict);
private static async Task<ConflictAware> PostJsonAllowConflictAsync(HttpRequestAuth auth, string path, object body, CancellationToken ct)
{
using var req = NewRequest(HttpMethod.Post, path, auth.Token);
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
using var response = await auth.Client.SendAsync(req, ct);
if (response.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity)
{
// GitHub returns 422 "Reference already exists" when the branch ref is
// pre-claimed by a concurrent or earlier submission. That's the idempotency
// signal we want; surface it.
var conflictText = await response.Content.ReadAsStringAsync(ct);
if (conflictText.Contains("Reference already exists", StringComparison.OrdinalIgnoreCase))
return new ConflictAware(null, true);
throw new HttpRequestException($"GitHub {(int)response.StatusCode} {path}: {conflictText}");
}
await EnsureSuccess(response, path, ct);
var text = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
return new ConflictAware(doc.RootElement.Clone(), false);
}
private static async Task<JsonElement> PutJsonAsync(HttpRequestAuth auth, string path, object body, CancellationToken ct)
{
using var req = NewRequest(HttpMethod.Put, path, auth.Token);
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
using var response = await auth.Client.SendAsync(req, 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}");
}
}

View File

@ -42,7 +42,6 @@ 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)
@ -172,7 +171,6 @@ BTCPay Server Plugin Builder";
}
}
public async Task<EmailSettingsViewModel?> GetEmailSettingsFromDb()
{
await using var conn = await connectionFactory.Open();

View File

@ -860,6 +860,29 @@ 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,7 +5,6 @@ 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,232 +2,163 @@
@model ListingRequestDetailViewModel
@{
Layout = "_Layout";
ViewData.SetActivePage(AdminNavPages.ListingRequests);
ViewData.SetActivePage(PluginNavPages.RequestListing, "Request Listing");
ViewData["Title"] = $"Listing Request - {Model.PluginTitle ?? Model.PluginSlug}";
}
<div class="d-flex align-items-center justify-content-between mb-4">
<h2 class="mb-0">@ViewData["Title"]</h2>
<div class="d-flex gap-2">
<a asp-action="ListingRequests" class="btn btn-secondary">Back to List</a>
@if (Model.Status == PluginListingRequestStatus.Pending)
{
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#approveModal">
Approve
</button>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#rejectModal">
Reject
</button>
}
</div>
<div class="sticky-header">
<h2 class="my-1">@ViewData["Title"]</h2>
@if (Model.Status == PluginListingRequestStatus.Pending)
{
<div class="d-flex align-items-center gap-2">
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#approveModal">Approve</button>
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#rejectModal">Reject</button>
</div>
}
</div>
<div class="row">
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Request Status</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-sm-4 fw-semibold">Status:</div>
<div class="col-sm-8">
@switch (Model.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;
}
</div>
<div class="row mt-0">
<div class="col-lg-6">
<div class="d-flex align-items-center gap-3 mb-5">
@if (!string.IsNullOrEmpty(Model.Logo))
{
<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>
<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>
<div class="row mb-3">
<div class="col-sm-4 fw-semibold">Submitted:</div>
<div class="col-sm-8">@Model.SubmittedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss UTC")</div>
</div>
@if (Model.ReviewedAt.HasValue)
@if (!string.IsNullOrEmpty(Model.PluginDescription))
{
<div class="row mb-3">
<div class="col-sm-4 fw-semibold">Reviewed:</div>
<div class="col-sm-8">@Model.ReviewedAt.Value.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss UTC")</div>
</div>
<div class="row mb-3">
<div class="col-sm-4 fw-semibold">Reviewed By:</div>
<div class="col-sm-8">@Model.ReviewedByEmail</div>
</div>
}
@if (!string.IsNullOrEmpty(Model.RejectionReason))
{
<div class="row mb-3">
<div class="col-sm-4 fw-semibold">Rejection Reason:</div>
<div class="col-sm-8">
<div class="alert alert-danger mb-0">@Model.RejectionReason</div>
</div>
</div>
}
@if (Model.AnnouncementDate.HasValue)
{
<div class="row">
<div class="col-sm-4 fw-semibold">Announcement Date:</div>
<div class="col-sm-8">@Model.AnnouncementDate.Value.UtcDateTime.ToString("yyyy-MM-dd HH:mm UTC")</div>
</div>
<p class="mt-2 mb-0">@Model.PluginDescription</p>
}
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Plugin Information</h5>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-sm-4 fw-semibold">Plugin Slug:</div>
<div class="col-sm-8">
<code>@Model.PluginSlug</code>
<a asp-controller="Home" asp-action="GetPluginDetails" asp-route-pluginSlug="@Model.PluginSlug"
target="_blank" class="ms-2">View Public Page</a>
</div>
</div>
@if (!string.IsNullOrEmpty(Model.PluginTitle))
{
<div class="row mb-3">
<div class="col-sm-4 fw-semibold">Title:</div>
<div class="col-sm-8">@Model.PluginTitle</div>
</div>
}
@if (!string.IsNullOrEmpty(Model.PluginDescription))
{
<div class="row mb-3">
<div class="col-sm-4 fw-semibold">Description:</div>
<div class="col-sm-8">@Model.PluginDescription</div>
</div>
}
@if (!string.IsNullOrEmpty(Model.Logo))
{
<div class="row mb-3">
<div class="col-sm-4 fw-semibold">Logo:</div>
<div class="col-sm-8">
<img src="@Model.Logo" alt="Plugin Logo" style="max-width: 200px; max-height: 100px;" />
</div>
</div>
}
@if (!string.IsNullOrEmpty(Model.GitRepository) || !string.IsNullOrEmpty(Model.Documentation))
{
<div class="mb-5">
@if (!string.IsNullOrEmpty(Model.GitRepository))
{
<div class="row mb-3">
<div class="col-sm-4 fw-semibold">Repository:</div>
<div class="col-sm-8">
<a href="@Model.GitRepository" target="_blank" rel="noopener">@Model.GitRepository</a>
</div>
<div class="mb-3">
<div class="text-muted fw-semibold small text-uppercase mb-1" style="letter-spacing:.05em;">Repository</div>
<a href="@Model.GitRepository" target="_blank" rel="noopener" class="small">@Model.GitRepository</a>
</div>
}
@if (!string.IsNullOrEmpty(Model.Documentation))
{
<div class="row">
<div class="col-sm-4 fw-semibold">Documentation:</div>
<div class="col-sm-8">
<a href="@Model.Documentation" target="_blank" rel="noopener">@Model.Documentation</a>
</div>
<div>
<div class="text-muted fw-semibold small text-uppercase mb-1" style="letter-spacing:.05em;">Documentation</div>
<a href="@Model.Documentation" target="_blank" rel="noopener" class="small">@Model.Documentation</a>
</div>
}
</div>
}
<div class="mb-5">
<div class="mb-3">
<div class="text-muted fw-semibold small text-uppercase mb-1" style="letter-spacing:.05em;">Submitted</div>
<span>@Model.SubmittedAt.UtcDateTime.ToString("MMM dd, yyyy · HH:mm UTC")</span>
</div>
<div class="mb-3">
<div class="text-muted fw-semibold small text-uppercase mb-1" style="letter-spacing:.05em;">Status</div>
<p class="mb-0">@Model.Status</p>
</div>
@if (Model.ReviewedAt.HasValue)
{
<div class="mb-3">
<div class="text-muted fw-semibold small text-uppercase mb-1" style="letter-spacing:.05em;">Reviewed By</div>
<span>@Model.ReviewedByEmail · @Model.ReviewedAt.Value.UtcDateTime.ToString("MMM dd, yyyy")</span>
</div>
}
@if (Model.AnnouncementDate.HasValue)
{
<div>
<div class="text-muted fw-semibold small text-uppercase mb-1" style="letter-spacing:.05em;">Announcement</div>
<span>@Model.AnnouncementDate.Value.UtcDateTime.ToString("MMM dd, yyyy · HH:mm UTC")</span>
</div>
}
</div>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">Listing Request Details</h5>
</div>
<div class="card-body">
<div class="mb-3">
<label class="fw-semibold">Release Note:</label>
<p class="mt-2">@Model.ReleaseNote</p>
</div>
<div class="mb-3">
<label class="fw-semibold">Telegram Verification:</label>
<p class="mt-2">
<a href="@Model.TelegramVerificationMessage" target="_blank" rel="noopener">
@Model.TelegramVerificationMessage
</a>
</p>
</div>
<div>
<label class="fw-semibold">User Reviews:</label>
<p class="mt-2">@Model.UserReviews</p>
</div>
<h5 class="fw-bold mb-4">Listing Request Details</h5>
@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>
<p class="mb-0 text-danger" style="white-space: pre-wrap;">@Model.RejectionReason</p>
</div>
}
<div class="mb-4">
<div class="text-muted fw-semibold small text-uppercase mb-2" style="letter-spacing:.05em;">Release Note</div>
<p class="mb-0">@Model.ReleaseNote</p>
</div>
<div class="mb-4">
<div class="text-muted fw-semibold small text-uppercase mb-2" style="letter-spacing:.05em;">Telegram Verification</div>
<a href="@Model.TelegramVerificationMessage" target="_blank" rel="noopener" class="text-break">
@Model.TelegramVerificationMessage
</a>
</div>
<div>
<div class="text-muted fw-semibold small text-uppercase mb-2" style="letter-spacing:.05em;">User Reviews</div>
<p class="mb-0">@Model.UserReviews</p>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">Plugin Owners</h5>
<div class="col-lg-3 offset-lg-1">
<h5 class="fw-bold mb-4">Plugin Owners</h5>
@foreach (var owner in Model.Owners)
{
<div class="mb-4">
<div class="d-flex align-items-center gap-2 mb-2">
<span class="fw-semibold">@owner.Email</span>
@if (owner.IsPrimary)
{
<span class="badge bg-primary">Primary</span>
}
</div>
<div class="d-flex flex-column gap-1 small">
<span class="@(owner.EmailVerified ? "text-success" : "text-muted")">
<vc:icon symbol="@(owner.EmailVerified ? "checkmark" : "cross")" />
Email @(owner.EmailVerified ? "Verified" : "Not Verified")
</span>
<span class="@(!string.IsNullOrWhiteSpace(owner.GithubProfile) ? "text-success" : "text-muted")">
<vc:icon symbol="@(!string.IsNullOrWhiteSpace(owner.GithubProfile) ? "checkmark" : "cross")" />
@if (!string.IsNullOrWhiteSpace(owner.GithubProfile))
{
<a href="@owner.GithubProfile" target="_blank" rel="noopener" class="text-success text-decoration-none">GitHub Verified</a>
}
else
{
<span>GitHub Not Verified</span>
}
</span>
<span class="@(!string.IsNullOrWhiteSpace(owner.NostrProfile) ? "text-success" : "text-muted")">
<vc:icon symbol="@(!string.IsNullOrWhiteSpace(owner.NostrProfile) ? "checkmark" : "cross")" />
@if (!string.IsNullOrWhiteSpace(owner.NostrProfile))
{
<a href="@owner.NostrProfile" target="_blank" rel="noopener" class="text-success text-decoration-none">Nostr Verified</a>
}
else
{
<span>Nostr Not Verified</span>
}
</span>
</div>
</div>
<div class="card-body">
@foreach (var owner in Model.Owners)
{
<div class="mb-3 pb-3 @(owner != Model.Owners.Last() ? "border-bottom" : "")">
<div class="d-flex align-items-center mb-2">
<strong>@owner.Email</strong>
@if (owner.IsPrimary)
{
<span class="badge bg-primary ms-2">Primary</span>
}
</div>
<div class="small">
<div class="d-flex align-items-center mb-1">
@if (owner.EmailVerified)
{
<vc:icon symbol="checkmark" css-class="text-success me-1" />
<a href="mailto:@owner.Email" class="text-decoration-none">
Email Verified
</a>
}
else
{
<vc:icon symbol="cross" css-class="text-danger me-1" />
<span class="text-muted">Email Not Verified</span>
}
</div>
<div class="d-flex align-items-center mb-1">
@if (owner.GithubProfile != null)
{
<vc:icon symbol="checkmark" css-class="text-success me-1" />
<a href="@owner.GithubProfile" target="_blank" rel="noopener" class="text-decoration-none">
GitHub Verified
</a>
}
else
{
<vc:icon symbol="cross" css-class="text-danger me-1" />
<span class="text-muted">GitHub Not Verified</span>
}
</div>
<div class="d-flex align-items-center">
@if (owner.NostrProfile != null)
{
<vc:icon symbol="checkmark" css-class="text-success me-1" />
<a href="@owner.NostrProfile" target="_blank" rel="noopener" class="text-decoration-none">
Nostr Verified
</a>
}
else
{
<vc:icon symbol="cross" css-class="text-danger me-1" />
<span class="text-muted">Nostr Not Verified</span>
}
</div>
</div>
</div>
}
</div>
</div>
}
</div>
</div>
@ -242,7 +173,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 set to <strong>Listed</strong> visibility.</p>
<p class="mb-0">The plugin <strong>@Model.PluginSlug</strong> will be <strong>Listed</strong> in the plugin directory.</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" class="btn btn-sm btn-primary">
<a asp-action="ListingRequestDetail" asp-route-requestId="@request.Id">
View Details
</a>
</td>

View File

@ -102,6 +102,12 @@
<div class="col-md-6 mb-4">
<div class="card h-100 plugin-card" data-type="community">
<div class="card-body">
@if (plugin.IsUnlisted)
{
<div class="position-absolute top-0 end-0 mt-3 me-3 d-flex flex-wrap justify-content-end gap-2">
<span class="badge fw-normal text-muted bg-medium rounded-pill px-2 py-1" title="This plugin is only shown in search results">Unlisted</span>
</div>
}
<div class="row">
<div style="display: flex; align-items: flex-start; margin-bottom: 20px;">
<div style="margin-right: 15px;">

File diff suppressed because it is too large Load Diff

View File

@ -44,10 +44,6 @@
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

@ -0,0 +1,77 @@
@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.Dashboard, "Plugin Listing Request");
ViewData.SetActivePage(PluginNavPages.RequestListing, "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,27 +11,29 @@
}
<div class="d-flex align-items-center justify-content-between mb-4">
<h2 class="mb-0">
@ViewData["Title"]
</h2>
@if (!Model.PendingListing)
{
<div class="d-flex gap-3 mt-3 mt-sm-0">
<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)
{
<button type="submit" form="request-listing-form" class="btn btn-success" @(Model.Step == RequestListingViewModel.State.Done ? "" : "disabled")>
@(Model.HasPreviousRejection ? "Re-submit" : "Submit")
</button>
</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>
}
}
@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>
<div class="accordion" id="requestListingAccordion">
<div class="accordion-item">
<h3 class="accordion-header" id="pluginSettingsHeader">

View File

@ -52,6 +52,9 @@
<symbol id="caret-right" viewBox="0 0 24 24">
<path d="M9.5 17L14.5 12L9.5 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</symbol>
<symbol id="caret-left" viewBox="0 0 24 24">
<path d="M7.92303 12.0039C7.92303 12.3233 8.03989 12.5882 8.28919 12.8375L14.2881 18.704C14.4906 18.9065 14.7322 19 15.0204 19C15.6047 19 16.08 18.5403 16.08 17.956C16.08 17.6678 15.9553 17.4029 15.745 17.1925L10.4161 12.0117L15.745 6.81525C15.9553 6.6049 16.08 6.3478 16.08 6.05175C16.08 5.46745 15.6047 5 15.0204 5C14.7322 5 14.4906 5.10128 14.2881 5.30384L8.28919 11.1703C8.0321 11.4196 7.92303 11.6845 7.92303 12.0039Z" fill="currentColor"/>
</symbol>
<symbol id="caret-down" viewBox="0 0 24 24">
<path d="M7 9.5L12 14.5L17 9.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</symbol>

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB