Compare commits

...

37 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
makeentosch
ee45a5464c tests implementation 2026-05-03 21:20:07 +03:00
makeentosch
5ee5f70c6d api validation implemented 2026-04-28 20:06:28 +03:00
34 changed files with 1965 additions and 56 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("button:text-is('Release')");
await t.Page!.ClickAsync("#StoreNav-Dashboard"); 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("#collapsePluginSettings")).ToBeVisibleAsync();
await Expect(t.Page.Locator("#pluginSettingsHeader")).ToContainTextAsync("Update Plugin Settings"); await Expect(t.Page.Locator("#pluginSettingsHeader")).ToContainTextAsync("Update Plugin Settings");
await Expect(t.Page.Locator("#collapseOwnerSettings")).Not.ToBeVisibleAsync(); await Expect(t.Page.Locator("#collapseOwnerSettings")).Not.ToBeVisibleAsync();
@ -78,7 +78,7 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
await t.AssertNoError(); await t.AssertNoError();
await t.Page!.ClickAsync("#StoreNav-Dashboard"); 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("#collapseRequestForm")).ToBeVisibleAsync();
await Expect(t.Page.Locator("#collapsePluginSettings")).Not.ToBeVisibleAsync(); await Expect(t.Page.Locator("#collapsePluginSettings")).Not.ToBeVisibleAsync();
await Expect(t.Page.Locator("#collapseOwnerSettings")).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.FillAsync("textarea[name='UserReviews']", "Great plugin, works as expected!");
await t.Page.ClickAsync("button[type='submit']:text('Submit')"); await t.Page.ClickAsync("button[type='submit']:text('Submit')");
await t.AssertNoError(); 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("#collapsePluginSettings")).Not.ToBeVisibleAsync();
await Expect(t.Page.Locator("#collapseOwnerSettings")).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(); await Expect(row.Locator(".badge.bg-warning:text('Pending')")).ToBeVisibleAsync();
// Click to view details - scoped to the specific row // 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)); await Expect(t.Page).ToHaveURLAsync(new Regex($".*/admin/listing-requests/{requestId}", RegexOptions.IgnoreCase));
// Verify request details are displayed // Verify request details are displayed
@ -172,6 +172,44 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
Assert.Equal(PluginVisibilityEnum.Listed, plugin!.Visibility); 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] [Fact]
public async Task Admin_Can_Reject_ListingRequest() 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.Contains("404 - Page not found", body, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("399 -", 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(); get => BuildInfo?["pluginDir"]?.ToString();
} }
public bool IsUnlisted { get; set; }
public PluginRatingSummary RatingSummary { get; set; } = new(); public PluginRatingSummary RatingSummary { get; set; } = new();
public string GetSourceUrl(GitHostingProviderFactory providerFactory) public string GetSourceUrl(GitHostingProviderFactory providerFactory)

View File

@ -19,6 +19,16 @@
<span>Builds</span> <span>Builds</span>
</a> </a>
</li> </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"> <li class="nav-item">
<a asp-area="" asp-controller="Plugin" asp-action="Settings" asp-route-pluginSlug="@Model.PluginSlug" <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"> 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.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using PluginBuilder.Components.PluginVersion; using PluginBuilder.Components.PluginVersion;
using PluginBuilder.DataModels;
using PluginBuilder.Services; using PluginBuilder.Services;
using PluginBuilder.Util; using PluginBuilder.Util;
using PluginBuilder.Util.Extensions; using PluginBuilder.Util.Extensions;
@ -26,22 +27,28 @@ public class MainNav : ViewComponent
using var conn = await ConnectionFactory.Open(); 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)>( var rows = await conn.QueryAsync<(int[] ver, bool pre_release)>(
"SELECT ver, pre_release FROM users_plugins up " + "SELECT ver, pre_release FROM users_plugins up " +
"JOIN versions v USING (plugin_slug) " + "JOIN versions v USING (plugin_slug) " +
"WHERE up.user_id=@userId AND up.plugin_slug=@pluginSlug " + "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) foreach (var r in rows)
vm.Versions.Add(new PluginVersionViewModel vm.Versions.Add(new PluginVersionViewModel
{ {
PluginSlug = pluginSlug?.ToString(), PluginSlug = slug,
Version = new PluginBuilder.PluginVersion(r.ver).ToString(), Version = new PluginBuilder.PluginVersion(r.ver).ToString(),
PreRelease = r.pre_release, PreRelease = r.pre_release,
Published = true, Published = true,
HidePublishBadge = 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 // 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 List<PluginVersionViewModel> Versions { get; set; } = new();
public int PendingListingRequestsCount { get; set; } public int PendingListingRequestsCount { get; set; }
public bool RequestListing { get; set; }
} }

View File

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

View File

@ -164,6 +164,7 @@ public class AccountController(
} }
[HttpPost("nostr/verify-nip07")] [HttpPost("nostr/verify-nip07")]
[IgnoreAntiforgeryToken]
public async Task<IActionResult> NostrVerifyNip07([FromBody] VerifyNip07Request req) public async Task<IActionResult> NostrVerifyNip07([FromBody] VerifyNip07Request req)
{ {
var user = await userManager.GetUserAsync(User) ?? throw new Exception("User not found"); 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"; TempData[TempDataConstant.WarningMessage] = "Failed to reject the listing request";
return RedirectToAction(nameof(ListingRequestDetail), new { requestId }); return RedirectToAction(nameof(ListingRequestDetail), new { requestId });
} }
var existingSettings = await conn.GetSettings(pluginSlug); var existingSettings = await conn.GetSettings(pluginSlug);
var pluginOwners = await conn.GetPluginOwners(pluginSlug); var pluginOwners = await conn.GetPluginOwners(pluginSlug);
var primaryOwner = pluginOwners.FirstOrDefault(o => o.IsPrimary); 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"; TempData[TempDataConstant.SuccessMessage] = $"Plugin listing request for '{request.PluginSlug}' has been rejected";
return RedirectToAction(nameof(ListingRequests)); return RedirectToAction(nameof(ListingRequests));
} }
#endregion #endregion
} }

View File

@ -324,6 +324,26 @@ public class ApiController(
if (!ModelState.IsValid) if (!ModelState.IsValid)
return ValidationErrorResult(ModelState); 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 buildId = await conn.NewBuild(pluginSlug, model.ToBuildParameter());
var buildUrl = Url.ActionLink(nameof(PluginController.Build), "Plugin", var buildUrl = Url.ActionLink(nameof(PluginController.Build), "Plugin",
new { pluginSlug = pluginSlug.ToString(), buildId }); 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.plugin_slug,
lv.ver, lv.ver,
p.settings, p.settings,
p.visibility,
b.id, b.id,
b.manifest_info, b.manifest_info,
b.build_info, b.build_info,
@ -278,7 +279,7 @@ public class HomeController(
} }
var rows = await conn 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, query,
new new
@ -304,6 +305,7 @@ public class HomeController(
BuildInfo = JObject.Parse(r.build_info), BuildInfo = JObject.Parse(r.build_info),
ManifestInfo = manifestInfo, ManifestInfo = manifestInfo,
PluginLogo = settings?.Logo, PluginLogo = settings?.Logo,
IsUnlisted = r.visibility == PluginVisibilityEnum.Unlisted,
RatingSummary = new PluginRatingSummary RatingSummary = new PluginRatingSummary
{ {
Average = r.avg_rating, Average = r.avg_rating,

View File

@ -318,6 +318,35 @@ public class PluginController(
return RedirectToAction(nameof(Build), new { pluginSlug = pluginSlug.ToString(), buildId }); 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")] [HttpGet("request-listing")]
public async Task<IActionResult> RequestListing( public async Task<IActionResult> RequestListing(
[ModelBinder(typeof(PluginSlugModelBinder))] [ModelBinder(typeof(PluginSlugModelBinder))]
@ -335,6 +364,7 @@ public class PluginController(
if (plugin.Visibility == PluginVisibilityEnum.Hidden) if (plugin.Visibility == PluginVisibilityEnum.Hidden)
return NotFound(); return NotFound();
var allRequests = await conn.GetAllListingRequestsForPlugin(pluginSlug);
var pluginOwners = await conn.GetPluginOwners(pluginSlug); var pluginOwners = await conn.GetPluginOwners(pluginSlug);
var pluginSettings = SafeJson.Deserialize<PluginSettings>(plugin.Settings); var pluginSettings = SafeJson.Deserialize<PluginSettings>(plugin.Settings);
var pendingRequest = await conn.GetPendingListingRequestForPlugin(pluginSlug); var pendingRequest = await conn.GetPendingListingRequestForPlugin(pluginSlug);
@ -342,6 +372,7 @@ public class PluginController(
model.ReleaseNote = pluginSettings?.Description; model.ReleaseNote = pluginSettings?.Description;
model.HasPreviousRejection = rejectedRequest != null; model.HasPreviousRejection = rejectedRequest != null;
model.HasRequests = allRequests.Any();
if (pendingRequest != null) if (pendingRequest != null)
{ {
@ -737,7 +768,6 @@ public class PluginController(
} }
var pluginSettings = await conn.GetPluginDetails(pluginSlug); var pluginSettings = await conn.GetPluginDetails(pluginSlug);
vm.RequestListing = pluginSettings?.Visibility == PluginVisibilityEnum.Unlisted;
return View(vm); return View(vm);
} }

View File

@ -4,4 +4,6 @@ public static class HttpClientNames
{ {
public const string GitHub = nameof(GitHub); public const string GitHub = nameof(GitHub);
public const string GitLab = nameof(GitLab); 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 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 public enum PluginListingRequestStatus
{ {
Pending, 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 OwnPlugin = "OwnPlugin";
public const string PublicApiRateLimit = "PublicApiRateLimit"; public const string PublicApiRateLimit = "PublicApiRateLimit";
public const string BtcMapsSubmitRateLimit = "BtcMapsSubmitRateLimit";
} }

View File

@ -17,6 +17,7 @@ using PluginBuilder.Authentication;
using PluginBuilder.Configuration; using PluginBuilder.Configuration;
using PluginBuilder.Controllers.Logic; using PluginBuilder.Controllers.Logic;
using PluginBuilder.DataModels; using PluginBuilder.DataModels;
using PluginBuilder.Filters;
using PluginBuilder.HostedServices; using PluginBuilder.HostedServices;
using PluginBuilder.Hubs; using PluginBuilder.Hubs;
using PluginBuilder.Services; using PluginBuilder.Services;
@ -143,7 +144,10 @@ public class Program
public void AddServices(IConfiguration configuration, IServiceCollection services, IHostEnvironment env) public void AddServices(IConfiguration configuration, IServiceCollection services, IHostEnvironment env)
{ {
services.AddControllersWithViews() services.AddControllersWithViews(options =>
{
options.Filters.Add(new UIControllerAntiforgeryTokenAttribute());
})
.AddRazorRuntimeCompilation() .AddRazorRuntimeCompilation()
.AddRazorOptions(options => .AddRazorOptions(options =>
{ {
@ -207,6 +211,28 @@ public class Program
client.DefaultRequestHeaders.Authorization = client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token); 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 => services.AddHttpClient(HttpClientNames.GitLab, client =>
{ {
client.BaseAddress = new Uri("https://gitlab.com/api/v4/"); client.BaseAddress = new Uri("https://gitlab.com/api/v4/");
@ -237,6 +263,7 @@ public class Program
}); });
services.AddScoped<PluginOwnershipService>(); services.AddScoped<PluginOwnershipService>();
services.AddScoped<VersionLifecycleService>(); services.AddScoped<VersionLifecycleService>();
services.AddSingleton<BtcMapsService>();
services.AddRateLimiter(options => services.AddRateLimiter(options =>
{ {
@ -262,6 +289,23 @@ public class Program
QueueLimit = 0 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 => 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 BTCPay Server Plugin Builder Team
"; ";
public Task<List<string>> SendEmail(string toCsvList, string subject, string messageText) public Task<List<string>> SendEmail(string toCsvList, string subject, string messageText)
{ {
List<InternetAddress> toList = toCsvList.Split([","], StringSplitOptions.RemoveEmptyEntries) List<InternetAddress> toList = toCsvList.Split([","], StringSplitOptions.RemoveEmptyEntries)
@ -172,7 +171,6 @@ BTCPay Server Plugin Builder";
} }
} }
public async Task<EmailSettingsViewModel?> GetEmailSettingsFromDb() public async Task<EmailSettingsViewModel?> GetEmailSettingsFromDb()
{ {
await using var conn = await connectionFactory.Open(); 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() }); 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) public static async Task<int> GetPendingListingRequestsCount(this NpgsqlConnection connection)
{ {
const string sql = """ const string sql = """

View File

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

View File

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

View File

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

View File

@ -99,7 +99,7 @@ else
} }
</td> </td>
<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 View Details
</a> </a>
</td> </td>

View File

@ -102,6 +102,12 @@
<div class="col-md-6 mb-4"> <div class="col-md-6 mb-4">
<div class="card h-100 plugin-card" data-type="community"> <div class="card h-100 plugin-card" data-type="community">
<div class="card-body"> <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 class="row">
<div style="display: flex; align-items: flex-start; margin-bottom: 20px;"> <div style="display: flex; align-items: flex-start; margin-bottom: 20px;">
<div style="margin-right: 15px;"> <div style="margin-right: 15px;">

View File

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