Compare commits

...

11 Commits

Author SHA1 Message Date
r1ckstardev
4fa9f947f1 btcmaps v1: country fallback + #btcmap changeset hashtag
Two follow-up items from @rollforsats's 2026-04-27 testing pass:

1. Country code missing from the directory PR diff.

   Plugins centralise the merchant's country in one form field, but
   the previous server contract had two separate sinks: top-level
   `country` for the directory entry, and `Address.Country` for OSM
   addr:country. If the plugin populated only one, the other path
   silently dropped the value.

   Fix: shared `ResolveDirectoryCountry` helper used by both
   `BuildMerchantEntry` (merchants.json entry) and `BuildPrBody` (PR
   description). Top-level `country` wins (the directory accepts the
   special "GLOBAL" pseudonym for online-only services, which has no
   OSM equivalent), with `Address.Country` as the fallback. OSM
   addr:country still reads `Address.Country` directly since the
   "GLOBAL" pseudonym is not valid for OSM addr:* and would fail
   validation.

2. #btcmap changeset hashtag.

   Per the BTC Map tagging spec
   (https://gitea.btcmap.org/teambtcmap/btcmap-general/wiki/Tagging-Merchants#changeset-comments)
   automated changesets should include the `#btcmap` hashtag in their
   comment so the BTC Map team can identify the activity. Appended to
   the create / update / unlist changeset comments in the existing
   format. `created_by` and `source` tags unchanged.

Test coverage:
- ResolveDirectoryCountry_PrefersTopLevelCountry: when both fields
  carry an ISO code, top-level wins.
- ResolveDirectoryCountry_FallsBackToAddressCountry: plugin sends only
  `address.country` -> directory entry still gets the country.
- ResolveDirectoryCountry_FallsBackThroughWhitespace: top-level
  whitespace-only is treated as empty for fallback purposes.
- ResolveDirectoryCountry_NullWhenNeitherProvided: neither sink
  populated -> null (existing conditional `WriteString` skips the key).

Local verification: dotnet build clean (0/0 errors), dotnet test
63/63 passing on .NET 10 RC.2.

Co-Authored-By: rollforsats <59777267+rollforsats@users.noreply.github.com>
2026-04-27 21:33:41 +00:00
r1ckstardev
dca09b0e04 btcmaps v1: BTC Map verification stamp + OSM addr:* tags
Follow-up to PR #211 fix-commit c43f64b, addressing the next round of
@rollforsats feedback on the directory-submission path:

1. check_date:currency:XBT auto-bump on every tagOnOsm operation.
   Per the BTC Map verification spec
   (https://gitea.btcmap.org/teambtcmap/btcmap-general/wiki/Verifying-Existing-Merchants)
   the verification tag is `check_date:currency:XBT=YYYY-MM-DD` (UTC,
   date-only). The act of submitting through the plugin is itself the
   verification, so the server stamps today's UTC date on both the
   create-new and existing-update paths. Re-verification (the plugin's
   11-month merchant button) is then a regular tagOnOsm call against
   the existing node.

2. Optional structured address block on the request body, written as
   OSM addr:* tags on the node.
   New `BtcMapsSubmitAddress` model holds HouseNumber / Street / City /
   Postcode / Country (all optional). Plugin gathers these as separate
   fields (housenumber split out from street at the form boundary, per
   @rollforsats); server writes only the keys the plugin populated, no
   inference. Maps 1:1 to addr:housenumber, addr:street, addr:city,
   addr:postcode, addr:country. Address.Country validated as ISO 3166-1
   alpha-2 (separate from the top-level directory-submission Country
   field, which still accepts GLOBAL).

Test coverage:
- Validate_AllowsRequestWithoutAddress: nullable Address, no errors.
- Validate_AcceptsFullAddressWithIsoCountry: all five components clean.
- Validate_AcceptsPartialAddress: city + country only, no errors on
  the unset fields.
- Validate_AddressCountry_MustBeIsoAlpha2: Theory of 6 inline cases
  covering uppercase ISO, lowercase, three-letter ISO 3166-1 alpha-3,
  one-letter, and the directory-only "GLOBAL" pseudonym (rejected for
  addr:country).

Local verification: dotnet build clean (0/0 errors), dotnet test
59/59 passing on .NET 10 RC.2 (`10.0.100-rc.2.25502.107`).

Co-Authored-By: rollforsats <59777267+rollforsats@users.noreply.github.com>
2026-04-27 19:27:22 +00:00
r1ckstardev
c43f64b960 btcmaps v1: address @rollforsats PR #211 testing review
Resolves the four findings from rollforsats's 2026-04-26 testing pass on
the directory-submission path (PR #211 discussion thread starting at
r3144167499). Three server-side fixes plus a clarification on #4.

1. Twitter link points to GitHub instead of X (BuildPrBody).
   - Bare `@handle` in markdown auto-resolves to `github.com/<handle>`
     when GitHub renders the directory PR.
   - Fix: render as an explicit `[@handle](https://x.com/<handle>)`
     link, normalising a leading `@` for the URL form.

2. merchants.json full-file diff on append (+142 / -131 in
   rollforsats/directory.btcpayserver.org#5).
   - Root cause: `JsonSerializer.Serialize` defaults to
     `JavaScriptEncoder.Default`, which is HTML-safe (escapes `<`, `>`,
     `&`, `'`, `"` plus all non-ASCII as `\uXXXX`). Every existing
     entry containing `'`, `&`, or non-ASCII (`'`, `u`, `n`, etc.) is
     rewritten in escape form even though no content changed.
   - Fix: pass `Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping`,
     the canonical option for writing JSON to a standalone file. Safe
     here because merchants.json is consumed by btcmaps tooling, not
     interpolated into HTML. After the change, an append diff is
     +N / -0 where N is the line count of the new entry.

3. Description required even when `submitToDirectory=false`.
   - Description is only consumed by BuildPrBody; tagOnOsm-only and
     unlistFromOsm-only requests have no consumer for it.
   - Fix: move the Description gate inside the existing
     `if (request.SubmitToDirectory)` branch, alongside Type / SubType
     / Country / OnionUrl which were already conditionally gated.

4. Directory PR fires even when `submitToDirectory=false` is reported
   by rollforsats. Server-side is already correctly gated at
   BtcMapsController.cs:39 (the directory branch only runs when the
   flag is true). Confirmed downstream of #3: as long as the server
   required Description unconditionally, the plugin had to send
   `submitToDirectory=true` to satisfy the gate. With #3 fixed the
   plugin can honour the merchant's actual selection. No server-side
   change.

Test coverage:
- New: Validate_RequiresDescription_OnDirectorySubmit covers the
  "Description required when SubmitToDirectory=true" path.
- New: Validate_AllowsMissingDescription_WhenTagOnOsmOnly covers the
  conditional-gate behaviour for tagOnOsm flows.
- New: Validate_AllowsMissingDescription_WhenUnlistOnly covers the
  unlistFromOsm flow.
- Updated: Validate_RejectsOverlongDescription renamed to
  Validate_RejectsOverlongDescription_OnDirectorySubmit and now sets
  SubmitToDirectory=true to match the new conditional shape.

Local verification: dotnet build clean (0/0 errors), dotnet test
50/50 passing on .NET 10 RC.2 (`10.0.100-rc.2.25502.107`).

Co-Authored-By: rollforsats <59777267+rollforsats@users.noreply.github.com>
2026-04-27 16:19:25 +00:00
r1ckstardev
9f4039f6c1 btcmaps v1: add UnlistFromOsm flag to submit endpoint
Follow-up on PR #211 per @rollforsats 2026-04-24 question 'how should
we handle un-listing a store from BTC Map?'. The old BTCPay plugin
removed bitcoin-related tags via changeset; this adds the symmetric
path to the v1 API.

Shape:
- Third flag on the existing request body, UnlistFromOsm=true,
  requires OsmNodeId + OsmNodeType. Mutually exclusive with
  TagOnOsm (opposite intent) and SubmitToDirectory (directory
  unlist is a separate merchant-row/PR/rebuild flow, out of v1
  scope per @rollforsats agreement).
- Tags removed: currency:XBT, payment:bitcoin (deprecated alias,
  kept for historical nodes), payment:lightning, payment:onchain.
  Non-bitcoin tags (name, amenity, website, address) stay intact -
  an OSM node can exist as a venue independent of bitcoin acceptance.
- Fetch-first: GET the element, check which target keys are present.
  If none are, skip changeset creation entirely and return
  'already-unlisted' via Skipped; controller surfaces as HTTP 409
  so the plugin can distinguish idempotent no-op from 'removed
  just now'.
- Otherwise: standard changeset-create + element-PUT (with target
  tags removed) + changeset-close flow, matching the TagOnOsmAsync
  pattern.

Response: BtcMapsOsmResult.RemovedTags carries the list of keys
the service actually removed (null on tag-on responses), so the
plugin can log what got touched.

Validation: 5 new xunit tests covering OsmNodeId+Type required,
both-paths-required combos rejected, positive-id required. All
pass alongside the 40 pre-existing BtcMaps tests (47 total).
Build clean (0 errors, pre-existing VideoUITests warnings
unchanged).

Endpoint contract summary (plugin consumption):
- Set UnlistFromOsm=true with the merchant's stored OsmNodeId +
  OsmNodeType. Omit TagOnOsm + SubmitToDirectory.
- 200: tags removed, response.Osm.RemovedTags lists which keys.
- 409 already-unlisted: no bitcoin tags present on the element,
  plugin should treat as idempotent success.
- 400: validation (missing node id, combination with other flags).
- 502 osm-upstream-failed: OSM API error, includes correlationId.

Co-Authored-By: r0ckstardev <5191402+rockstardev@users.noreply.github.com>
2026-04-24 19:08:31 +00:00
r1ckstardev
098eae8da4 Add OSM new-node create path (PR #211 r3131162977)
Per @rollforsats Telegram greenlight: implicit-create when TagOnOsm=true
and OsmNodeId is null. Plugin sends merchant lat + lon (+ optional
OSM amenity category) and the service POSTs /api/0.6/node to OSM,
returning the freshly-allocated node ID in the response.

Schema additions to BtcMapsSubmitRequest:
  Latitude, Longitude (double?, required when OsmNodeId is null,
                       validated to [-90, 90] / [-180, 180])
  OsmCategory (string?, defaults to amenity=shop when omitted;
               common values: cafe, restaurant, bar, pub, fast_food)

Response:
  BtcMapsOsmResult.Created (bool, true when service created the node;
                            plugin should persist NodeId back to the
                            merchant record so future submissions take
                            the existing-update path)

Validation:
  TagOnOsm=true + OsmNodeId=null  -> require valid Latitude + Longitude
  TagOnOsm=true + OsmNodeId set   -> require positive ID + known NodeType

Tests: 8 new theory rows for the create path lat/lon range checks plus
a fact for missing-coordinates. Existing-node test renamed for clarity.
2026-04-23 13:50:40 +00:00
r1ckstardev
a600d3883f Strip em-dash from PR body template (ASCII-only per CLAWBIBLE) 2026-04-23 13:39:09 +00:00
r1ckstardev
e6e00146f7 Address @rollforsats lightning-flag review (PR #211 r3131170121)
Lightning was being set unconditionally on the OSM node, assuming every
BTCPay merchant has a Lightning connection. Adds AcceptsLightning to
BtcMapsSubmitRequest (default true) and gates the payment:lightning=yes
tag on it. currency:XBT=yes stays unconditional since on-chain Bitcoin
is always supported by a BTCPay store.

Plugin contract: read store's Lightning configuration, pass true if a
Lightning node is connected, false otherwise.

Comment: https://github.com/btcpayserver/btcpayserver-plugin-builder/pull/211#discussion_r3131170121
2026-04-23 13:38:50 +00:00
r1ckstardev
70f267a3c9 Address @rollforsats review: replace deprecated payment:bitcoin tag with currency:XBT
Per OSM wiki, `payment:bitcoin=yes` is deprecated. The canonical
replacement is `currency:XBT=yes` (XBT is ISO 4217). Lightning tag
`payment:lightning=yes` stays unchanged — still current per OSM.

Comment thread: https://github.com/btcpayserver/btcpayserver-plugin-builder/pull/211#discussion_r3130675475
Refs:
  https://wiki.openstreetmap.org/wiki/Key:currency:XBT
  https://wiki.openstreetmap.org/wiki/Bitcoin
2026-04-23 12:20:57 +00:00
r1ckstardev
c7b9315160 Address CodeRabbit review: onion scheme + JsonDocument lifetimes
- Validate_ChecksOnionUrl: onion URL now restricted to http/https
  scheme (reject ftp://, gopher://, etc. even on .onion hosts)
- Wrap JsonDocument.Parse in using var doc so pooled buffers return
  to the pool after Clone — prevents slow leak under load in
  GetJsonAsync/PostJsonAsync/PutJsonAsync/BuildMerchantEntry
- Test: expand theory to cover http+.onion (now valid) and
  ftp+.onion (now invalid)
2026-04-22 18:48:20 +00:00
r1ckstardev
c1ce1a4e12 Address Hermes review: test ctor, abuse controls, race, info leak
Blocking fixes:
  * Test constructor: drop stale httpClientFactory arg so
    PluginBuilder.Tests builds (ctor is (IConfiguration, ILogger)).
  * Anonymous abuse controls: replace PublicApiRateLimit on /submit with
    a dedicated Policies.BtcMapsSubmitRateLimit policy (5 requests /
    24h per IP) so the shared GitHub PAT and OSM bearer can't be used
    as an open-write proxy at the default public-API rate.
  * GitHub race / idempotency: before opening a PR, search open PRs
    in the target repo for an embedded normalized-URL marker and skip
    with Skipped="duplicate-open-pr" when one is already in flight.
    Replace the per-second branch name with a Guid-derived suffix so
    two simultaneous submissions with the same slug can't collide on
    branch creation.
  * Info disclosure: upstream failures no longer echo ex.Message to
    callers. Response carries a correlationId; full detail stays in
    LogError.

Non-blocking follow-ups from the review:
  * Narrow both catch blocks with `when (ex is not OperationCanceledException)`
    so request disconnects bubble as cancellations instead of being
    misreported as 502 upstream failures.
  * Decouple the OSM changeset-close call from the request cancellation
    token: use an independent 10s timeout so client disconnects don't
    leave changesets open.
2026-04-22 18:38:55 +00:00
r1ckstardev
1c7e04a37f Add BTCMaps v1 submission API
Adds /apis/btcmaps/v1/{ping,submit} endpoint that the btcpayserver
BTCMap plugin can POST to when a merchant approves publishing.

submit accepts a single JSON body and runs two optional actions:

  - SubmitToDirectory=true opens a PR against the
    directory.btcpayserver.org merchants.json, deduping on normalized
    URL, using a fine-grained PAT in BTCMAPS:DirectoryGithubToken.
  - TagOnOsm=true writes payment:bitcoin=yes (+website,
    payment:lightning) to an existing OSM node/way/relation using the
    OSM OAuth2 bearer token in BTCMAPS:OsmAccessToken via a
    create-changeset -> GET -> merge tags -> PUT -> close-changeset
    flow.

Both actions skip-not-fail when their token is unset, so the endpoint
is safe to deploy before operators configure either integration.

Validation mirrors the directory UI's type/subType/country enums and
the OSM node-type set. Rate-limited via the existing
PublicApiRateLimit policy and marked [AllowAnonymous] so unauthenticated
plugin installs can call it.
2026-04-22 18:10:06 +00:00
8 changed files with 1483 additions and 0 deletions

View File

@ -0,0 +1,551 @@
using System.Linq;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using PluginBuilder.APIModels;
using PluginBuilder.Services;
using Xunit;
namespace PluginBuilder.Tests;
public class BtcMapsServiceTests
{
private static BtcMapsService MakeService() =>
new BtcMapsService(
configuration: new ConfigurationBuilder().Build(),
logger: NullLogger<BtcMapsService>.Instance);
[Fact]
public void Validate_RequiresAtLeastOneAction_NotEnforcedHere()
{
// The controller enforces (submitToDirectory || tagOnOsm). The service
// validator focuses on field-level validity, so an all-false request
// with only core fields should still pass Validate cleanly.
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Good Shop",
Url = "https://goodshop.example",
Description = "A very good shop."
};
Assert.Empty(svc.Validate(req));
}
[Fact]
public void Validate_RejectsMissingName()
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Url = "https://shop.example",
Description = "desc"
};
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Name));
}
[Fact]
public void Validate_RejectsNonHttpsUrl()
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "http://plain.example",
Description = "desc"
};
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Url));
}
[Fact]
public void Validate_RejectsOverlongDescription_OnDirectorySubmit()
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = new string('x', 1001),
Type = "merchants",
SubmitToDirectory = true
};
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
}
[Fact]
public void Validate_RequiresDescription_OnDirectorySubmit()
{
// Description is the directory PR body content; required only when actually
// submitting to the directory.
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Type = "merchants",
SubmitToDirectory = true
};
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
}
[Fact]
public void Validate_AllowsMissingDescription_WhenTagOnOsmOnly()
{
// tagOnOsm-only requests do not consume Description (the OSM tag set is
// name + amenity + currency:XBT + payment:lightning + website). Description
// is exclusively a directory-PR field.
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
OsmNodeId = 12345,
OsmNodeType = "node",
TagOnOsm = true
};
Assert.DoesNotContain(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
}
[Fact]
public void Validate_AllowsMissingDescription_WhenUnlistOnly()
{
// unlistFromOsm-only requests strip tags from an existing OSM element; no
// Description path on the wire.
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
UnlistFromOsm = true,
OsmNodeId = 12345,
OsmNodeType = "node"
};
Assert.Empty(svc.Validate(req));
}
[Theory]
[InlineData("merchants", "books", true)]
[InlineData("merchants", "not-a-subtype", false)]
[InlineData("apps", "not-a-subtype", true)]
public void Validate_ChecksMerchantSubType(string type, string subType, bool expectValid)
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = "desc",
Type = type,
SubType = subType,
SubmitToDirectory = true
};
var errors = svc.Validate(req);
if (expectValid)
Assert.DoesNotContain(errors, e => e.Path == nameof(BtcMapsSubmitRequest.SubType));
else
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.SubType));
}
[Fact]
public void Validate_RejectsUnknownType_OnDirectorySubmit()
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = "desc",
Type = "unicorns",
SubmitToDirectory = true
};
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Type));
}
[Fact]
public void Validate_SkipsDirectoryFieldsWhenNotSubmitting()
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = "desc",
Type = "unicorns",
SubmitToDirectory = false
};
Assert.Empty(svc.Validate(req));
}
[Theory]
[InlineData("GLOBAL", true)]
[InlineData("US", true)]
[InlineData("us", false)]
[InlineData("USA", false)]
public void Validate_ChecksCountryOnDirectorySubmit(string country, bool expectValid)
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = "desc",
Type = "merchants",
Country = country,
SubmitToDirectory = true
};
var errors = svc.Validate(req);
if (expectValid)
Assert.DoesNotContain(errors, e => e.Path == nameof(BtcMapsSubmitRequest.Country));
else
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.Country));
}
[Theory]
[InlineData("http://example.onion", true)]
[InlineData("ftp://abc.onion", false)]
[InlineData("https://abc.example", false)]
[InlineData("http://abc.onion", true)]
[InlineData("https://abc.onion", true)]
public void Validate_ChecksOnionUrl(string onion, bool expectValid)
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = "desc",
Type = "merchants",
OnionUrl = onion,
SubmitToDirectory = true
};
var errors = svc.Validate(req);
if (expectValid)
Assert.DoesNotContain(errors, e => e.Path == nameof(BtcMapsSubmitRequest.OnionUrl));
else
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.OnionUrl));
}
[Theory]
[InlineData(123L, "node", true)]
[InlineData(123L, "Node", true)]
[InlineData(123L, "relation", true)]
[InlineData(123L, "line", false)]
[InlineData(-1L, "node", false)]
public void Validate_ChecksExistingNodeFields(long? nodeId, string? nodeType, bool expectValid)
{
// Existing-update path: OsmNodeId is set, NodeType must be one of the
// known OSM types and the ID positive.
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = "desc",
OsmNodeId = nodeId,
OsmNodeType = nodeType,
TagOnOsm = true
};
var errors = svc.Validate(req)
.Where(e => e.Path is nameof(BtcMapsSubmitRequest.OsmNodeId) or nameof(BtcMapsSubmitRequest.OsmNodeType))
.ToList();
if (expectValid)
Assert.Empty(errors);
else
Assert.NotEmpty(errors);
}
[Theory]
[InlineData(40.7128, -74.0060, true)]
[InlineData(0.0, 0.0, true)]
[InlineData(-90.0, 180.0, true)]
[InlineData(90.0, -180.0, true)]
[InlineData(91.0, 0.0, false)]
[InlineData(-91.0, 0.0, false)]
[InlineData(0.0, 181.0, false)]
[InlineData(0.0, -181.0, false)]
public void Validate_CreatePath_RequiresValidLatLon(double lat, double lon, bool expectValid)
{
// Create-new path: OsmNodeId is null, lat + lon are required and must be
// in valid geographic ranges. NodeType is irrelevant on this path
// (server defaults the created element to a node).
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = "desc",
OsmNodeId = null,
Latitude = lat,
Longitude = lon,
TagOnOsm = true
};
var errors = svc.Validate(req)
.Where(e => e.Path is nameof(BtcMapsSubmitRequest.Latitude) or nameof(BtcMapsSubmitRequest.Longitude))
.ToList();
if (expectValid)
Assert.Empty(errors);
else
Assert.NotEmpty(errors);
}
[Fact]
public void Validate_CreatePath_RejectsMissingCoordinates()
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = "desc",
OsmNodeId = null,
TagOnOsm = true
};
var errors = svc.Validate(req).ToList();
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.Latitude));
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.Longitude));
}
[Theory]
[InlineData("https://shop.example/", "https://shop.example/", true)]
[InlineData("https://shop.example", "https://shop.example/", true)]
[InlineData("https://Shop.Example/", "https://shop.example", true)]
[InlineData("https://shop.example/a", "https://shop.example/b", false)]
public void NormalizeUrl_IgnoresTrailingSlashAndCase(string a, string b, bool equal)
{
Assert.Equal(equal, BtcMapsService.NormalizeUrl(a) == BtcMapsService.NormalizeUrl(b));
}
[Theory]
[InlineData("9 Bravos", "9-bravos")]
[InlineData("Altair Technology", "altair-technology")]
[InlineData("!!!", "merchant")]
[InlineData(" leading and trailing ", "leading-and-trailing")]
public void Slugify_ProducesUrlSafeSlug(string input, string expected)
{
Assert.Equal(expected, BtcMapsService.Slugify(input));
}
[Fact]
public void Validate_Unlist_RequiresOsmNodeIdAndType()
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = "desc",
UnlistFromOsm = true
};
var errors = svc.Validate(req).ToList();
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.OsmNodeId));
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.OsmNodeType));
}
[Fact]
public void Validate_Unlist_AcceptsNodeIdAndType()
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = "desc",
UnlistFromOsm = true,
OsmNodeId = 1234,
OsmNodeType = "node"
};
Assert.Empty(svc.Validate(req));
}
[Fact]
public void Validate_Unlist_RejectsCombinationWithTagOnOsm()
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = "desc",
UnlistFromOsm = true,
TagOnOsm = true,
OsmNodeId = 1234,
OsmNodeType = "node"
};
Assert.Contains(svc.Validate(req),
e => e.Path == nameof(BtcMapsSubmitRequest.UnlistFromOsm));
}
[Fact]
public void Validate_Unlist_RejectsCombinationWithSubmitToDirectory()
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = "desc",
UnlistFromOsm = true,
SubmitToDirectory = true,
Type = "merchants",
OsmNodeId = 1234,
OsmNodeType = "node"
};
Assert.Contains(svc.Validate(req),
e => e.Path == nameof(BtcMapsSubmitRequest.UnlistFromOsm));
}
[Fact]
public void ResolveDirectoryCountry_PrefersTopLevelCountry()
{
var req = new BtcMapsSubmitRequest
{
Country = "DE",
Address = new BtcMapsSubmitAddress { Country = "FR" }
};
Assert.Equal("DE", BtcMapsService.ResolveDirectoryCountry(req));
}
[Fact]
public void ResolveDirectoryCountry_FallsBackToAddressCountry()
{
// Plugin centralises country in the address block only; the directory
// entry should still carry the country code.
var req = new BtcMapsSubmitRequest
{
Address = new BtcMapsSubmitAddress { Country = "FR" }
};
Assert.Equal("FR", BtcMapsService.ResolveDirectoryCountry(req));
}
[Fact]
public void ResolveDirectoryCountry_FallsBackThroughWhitespace()
{
var req = new BtcMapsSubmitRequest
{
Country = " ",
Address = new BtcMapsSubmitAddress { Country = " IT " }
};
Assert.Equal("IT", BtcMapsService.ResolveDirectoryCountry(req));
}
[Fact]
public void ResolveDirectoryCountry_NullWhenNeitherProvided()
{
var req = new BtcMapsSubmitRequest
{
Address = new BtcMapsSubmitAddress { City = "Munich" }
};
Assert.Null(BtcMapsService.ResolveDirectoryCountry(req));
}
[Fact]
public void Validate_AllowsRequestWithoutAddress()
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
OsmNodeId = 12345,
OsmNodeType = "node",
TagOnOsm = true
};
Assert.Empty(svc.Validate(req));
}
[Fact]
public void Validate_AcceptsFullAddressWithIsoCountry()
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
OsmNodeId = 12345,
OsmNodeType = "node",
TagOnOsm = true,
Address = new BtcMapsSubmitAddress
{
HouseNumber = "12",
Street = "Main St",
City = "Munich",
Postcode = "80331",
Country = "DE"
}
};
Assert.Empty(svc.Validate(req));
}
[Fact]
public void Validate_AcceptsPartialAddress()
{
// Only some addr:* keys provided. Server writes whichever the plugin
// populated; nothing inferred. Empty / missing fields are not errors.
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
OsmNodeId = 12345,
OsmNodeType = "node",
TagOnOsm = true,
Address = new BtcMapsSubmitAddress
{
City = "Munich",
Country = "DE"
}
};
Assert.Empty(svc.Validate(req));
}
[Theory]
[InlineData("DE", true)]
[InlineData("US", true)]
[InlineData("de", false)]
[InlineData("DEU", false)]
[InlineData("D", false)]
[InlineData("GLOBAL", false)] // GLOBAL is valid for the directory's top-level Country, NOT for OSM addr:country.
public void Validate_AddressCountry_MustBeIsoAlpha2(string country, bool expectValid)
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
OsmNodeId = 12345,
OsmNodeType = "node",
TagOnOsm = true,
Address = new BtcMapsSubmitAddress { Country = country }
};
var errors = svc.Validate(req)
.Where(e => e.Path.EndsWith(nameof(BtcMapsSubmitAddress.Country)))
.ToList();
if (expectValid)
Assert.Empty(errors);
else
Assert.NotEmpty(errors);
}
[Theory]
[InlineData(0, false)]
[InlineData(-1, false)]
[InlineData(1, true)]
public void Validate_Unlist_RequiresPositiveNodeId(long id, bool expectValid)
{
var svc = MakeService();
var req = new BtcMapsSubmitRequest
{
Name = "Shop",
Url = "https://shop.example",
Description = "desc",
UnlistFromOsm = true,
OsmNodeId = id,
OsmNodeType = "node"
};
var errors = svc.Validate(req)
.Where(e => e.Path == nameof(BtcMapsSubmitRequest.OsmNodeId))
.ToList();
if (expectValid)
Assert.Empty(errors);
else
Assert.NotEmpty(errors);
}
}

View File

@ -0,0 +1,21 @@
namespace PluginBuilder.APIModels;
// Optional structured address block on BtcMapsSubmitRequest. Populated by the
// plugin when the merchant provides postal-address fields; consumed by
// BtcMapsService to write OSM `addr:*` tags. Each field is optional - the
// service only writes the OSM tags whose corresponding value is populated.
//
// Field ordering follows the OSM `addr:*` convention. HouseNumber + Street are
// kept separate (per OSM) and the plugin is responsible for splitting the raw
// merchant-entered street string into the two components before sending.
public sealed class BtcMapsSubmitAddress
{
public string? HouseNumber { get; set; }
public string? Street { get; set; }
public string? City { get; set; }
public string? Postcode { get; set; }
// ISO 3166-1 alpha-2. Validated alongside the top-level Country (which is
// the directory-submission field) when present; the two are independent.
public string? Country { get; set; }
}

View File

@ -0,0 +1,50 @@
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 long? OsmNodeId { get; set; }
public string? OsmNodeType { get; set; }
// Required when TagOnOsm=true and OsmNodeId is null (create-new path).
// Plugin should pass the merchant's coordinates from the BTCPay store
// address or merchant-supplied input.
public double? Latitude { get; set; }
public double? Longitude { get; set; }
// Optional. Maps to the OSM amenity= tag. Common values: shop, cafe,
// restaurant, bar, pub, fast_food. Defaults to "shop" when omitted.
public string? OsmCategory { get; set; }
public bool SubmitToDirectory { get; set; }
public bool TagOnOsm { get; set; }
// Defaults to true: a BTCPay store accepts on-chain Bitcoin by definition,
// so currency:XBT=yes is always set. Lightning is per-store configuration,
// so the plugin must pass the actual store state.
public bool AcceptsLightning { get; set; } = true;
// Opt-in un-listing: remove the bitcoin-related tags from an existing OSM
// element. Requires OsmNodeId + OsmNodeType. Mutually exclusive with TagOnOsm
// and SubmitToDirectory (v1 scope is OSM-only; directory unlist involves a
// separate merchant-row/PR/rebuild flow and is out of scope for this endpoint).
// If the target element no longer carries any of the bitcoin-related tags the
// service removes, the endpoint returns 409 Conflict.
public bool UnlistFromOsm { get; set; }
// Optional structured address. Consumed by the OSM tag writer (addr:*).
// Each field nullable; only populated keys are written to the node. Plugin
// is responsible for splitting raw street strings into HouseNumber + Street
// at the merchant-form boundary.
public BtcMapsSubmitAddress? Address { get; set; }
}

View File

@ -0,0 +1,34 @@
namespace PluginBuilder.APIModels;
public sealed class BtcMapsSubmitResponse
{
public BtcMapsDirectoryResult? Directory { get; set; }
public BtcMapsOsmResult? Osm { 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 BtcMapsOsmResult
{
public long? ChangesetId { get; set; }
public long? NodeId { get; set; }
public string? NodeType { get; set; }
public int? NewVersion { get; set; }
public string? Skipped { get; set; }
// True when the node was created on this request (OsmNodeId was null in
// the request and the service POSTed /api/0.6/node). Plugin should
// persist NodeId back to the merchant record so future submissions take
// the existing-update path.
public bool Created { get; set; }
// Populated on an un-list request (UnlistFromOsm=true) with the keys the
// service actually removed from the element. Null on a tag-on request.
public string[]? RemovedTags { get; set; }
}

View File

@ -0,0 +1,110 @@
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.") } });
if (!request.SubmitToDirectory && !request.TagOnOsm && !request.UnlistFromOsm)
return BadRequest(new { errors = new[] { new ValidationError("action", "Set submitToDirectory, tagOnOsm, and/or unlistFromOsm to true.") } });
var errors = btcMapsService.Validate(request);
if (errors.Count > 0)
return BadRequest(new { errors });
var correlationId = Guid.NewGuid().ToString("N");
var response = new BtcMapsSubmitResponse();
if (request.SubmitToDirectory)
{
try
{
response.Directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "BTCMaps directory submission failed (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status502BadGateway, new
{
error = "directory-upstream-failed",
correlationId
});
}
}
if (request.UnlistFromOsm)
{
try
{
response.Osm = await btcMapsService.UnlistFromOsmAsync(request, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "BTCMaps OSM un-list failed (correlationId={CorrelationId}) for {Name} node {NodeType}/{NodeId}",
correlationId, request.Name, request.OsmNodeType, request.OsmNodeId);
return StatusCode(StatusCodes.Status502BadGateway, new
{
error = "osm-upstream-failed",
correlationId,
partial = response
});
}
// Conflict surface: when the element already carries none of the
// bitcoin-related tags the service removes, the service reports
// "already-unlisted" via Skipped. Return 409 so the plugin can
// distinguish idempotent no-op from "actually removed just now".
if (response.Osm?.Skipped == "already-unlisted")
{
return Conflict(new
{
error = "already-unlisted",
correlationId,
partial = response
});
}
}
else if (request.TagOnOsm)
{
try
{
response.Osm = await btcMapsService.TagOnOsmAsync(request, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "BTCMaps OSM tagging failed (correlationId={CorrelationId}) for {Name} node {NodeType}/{NodeId}",
correlationId, request.Name, request.OsmNodeType, request.OsmNodeId);
return StatusCode(StatusCodes.Status502BadGateway, new
{
error = "osm-upstream-failed",
correlationId,
partial = response
});
}
}
return Ok(response);
}
}

View File

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

View File

@ -223,6 +223,7 @@ public class Program
services.AddSingleton<EmailService>();
services.AddSingleton<FirstBuildEvent>();
services.AddSingleton<NostrService>();
services.AddSingleton<BtcMapsService>();
// shared controller logic
services.AddSingleton<AdminSettingsCache>();
@ -262,6 +263,17 @@ public class Program
QueueLimit = 0
});
});
options.AddPolicy(Policies.BtcMapsSubmitRateLimit, httpContext =>
{
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(clientIp, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromHours(24),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});
});
services.AddOutputCache(options =>

View File

@ -0,0 +1,704 @@
using System.Globalization;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Xml.Linq;
using Microsoft.Extensions.Configuration;
using PluginBuilder.APIModels;
namespace PluginBuilder.Services;
public sealed class BtcMapsService
{
private const string DefaultOsmApiBase = "https://api.openstreetmap.org/api/0.6/";
private const string DefaultDirectoryRepo = "btcpayserver/directory.btcpayserver.org";
private const string DefaultDirectoryMerchantsPath = "src/data/merchants.json";
private const string UserAgent = "PluginBuilder-BtcMaps/1.0";
private static readonly HashSet<string> ValidTypes = new(StringComparer.OrdinalIgnoreCase)
{
"merchants", "apps", "hosted-btcpay", "non-profits"
};
private static readonly HashSet<string> ValidMerchantSubTypes = new(StringComparer.OrdinalIgnoreCase)
{
"3d-printing", "adult", "appliances-furniture", "art", "books",
"cryptocurrency-paraphernalia", "domains-hosting-vpns", "education",
"electronics", "fashion", "food", "gambling", "gift-cards",
"health-household", "holiday-travel", "jewelry", "payment-services",
"pets", "services", "software-video-games", "sports", "tools"
};
private static readonly HashSet<string> ValidOsmNodeTypes = new(StringComparer.OrdinalIgnoreCase)
{
"node", "way", "relation"
};
private readonly IConfiguration _configuration;
private readonly ILogger<BtcMapsService> _logger;
public BtcMapsService(
IConfiguration configuration,
ILogger<BtcMapsService> logger)
{
_configuration = configuration;
_logger = logger;
}
public IReadOnlyList<ValidationError> Validate(BtcMapsSubmitRequest request)
{
var errors = new List<ValidationError>();
var name = (request.Name ?? string.Empty).Trim();
if (string.IsNullOrEmpty(name) || name.Length > 200)
errors.Add(new ValidationError(nameof(request.Name), "Required, 1-200 characters."));
var url = (request.Url ?? string.Empty).Trim();
if (string.IsNullOrEmpty(url))
errors.Add(new ValidationError(nameof(request.Url), "Required."));
else if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed) || parsed.Scheme != Uri.UriSchemeHttps)
errors.Add(new ValidationError(nameof(request.Url), "Must be a valid https:// URL."));
if (request.SubmitToDirectory)
{
// Description is only consumed by the directory PR body; not required for
// tagOnOsm-only or unlistFromOsm-only requests.
var description = (request.Description ?? string.Empty).Trim();
if (string.IsNullOrEmpty(description) || description.Length > 1000)
errors.Add(new ValidationError(nameof(request.Description), "Required, 1-1000 characters."));
var type = (request.Type ?? string.Empty).Trim();
if (string.IsNullOrEmpty(type) || !ValidTypes.Contains(type))
errors.Add(new ValidationError(nameof(request.Type),
$"Required for directory submission. One of: {string.Join(", ", ValidTypes)}."));
if (!string.IsNullOrWhiteSpace(request.SubType))
{
var subType = request.SubType.Trim();
if (string.Equals(type, "merchants", StringComparison.OrdinalIgnoreCase) &&
!ValidMerchantSubTypes.Contains(subType))
{
errors.Add(new ValidationError(nameof(request.SubType),
"Invalid merchant subtype."));
}
}
if (!string.IsNullOrWhiteSpace(request.Country))
{
var country = request.Country.Trim();
if (!(country == "GLOBAL" || (country.Length == 2 && country.All(char.IsUpper))))
errors.Add(new ValidationError(nameof(request.Country),
"Must be ISO 3166-1 alpha-2 or GLOBAL."));
}
if (!string.IsNullOrWhiteSpace(request.OnionUrl))
{
if (!Uri.TryCreate(request.OnionUrl.Trim(), UriKind.Absolute, out var onionUri) ||
(onionUri.Scheme != Uri.UriSchemeHttp && onionUri.Scheme != Uri.UriSchemeHttps) ||
!onionUri.Host.EndsWith(".onion", StringComparison.OrdinalIgnoreCase))
{
errors.Add(new ValidationError(nameof(request.OnionUrl),
"Must be an http(s) .onion URL."));
}
}
}
if (request.TagOnOsm)
{
if (request.OsmNodeId is null)
{
// Create-new path: lat + lon required; OsmNodeType defaults to "node"
// when the service creates the OSM element.
if (request.Latitude is null || request.Latitude < -90.0 || request.Latitude > 90.0)
errors.Add(new ValidationError(nameof(request.Latitude),
"Required when OsmNodeId is null. Must be in range [-90, 90]."));
if (request.Longitude is null || request.Longitude < -180.0 || request.Longitude > 180.0)
errors.Add(new ValidationError(nameof(request.Longitude),
"Required when OsmNodeId is null. Must be in range [-180, 180]."));
}
else
{
if (request.OsmNodeId <= 0)
errors.Add(new ValidationError(nameof(request.OsmNodeId),
"Must be positive."));
var nodeType = (request.OsmNodeType ?? string.Empty).Trim();
if (string.IsNullOrEmpty(nodeType) || !ValidOsmNodeTypes.Contains(nodeType))
errors.Add(new ValidationError(nameof(request.OsmNodeType),
$"Required when OsmNodeId is set. One of: {string.Join(", ", ValidOsmNodeTypes)}."));
}
}
if (request.Address is not null && !string.IsNullOrWhiteSpace(request.Address.Country))
{
var addrCountry = request.Address.Country.Trim();
if (!(addrCountry.Length == 2 && addrCountry.All(char.IsUpper)))
errors.Add(new ValidationError($"{nameof(request.Address)}.{nameof(request.Address.Country)}",
"Must be ISO 3166-1 alpha-2."));
}
if (request.UnlistFromOsm)
{
// Un-listing always targets an existing element - there is no "remove-from-new-node"
// path. Mutually exclusive with TagOnOsm (opposite intent) and with SubmitToDirectory
// (v1 scope is OSM-only; directory unlist is a separate flow).
if (request.TagOnOsm)
errors.Add(new ValidationError(nameof(request.UnlistFromOsm),
"Cannot be combined with tagOnOsm (opposite intent)."));
if (request.SubmitToDirectory)
errors.Add(new ValidationError(nameof(request.UnlistFromOsm),
"Cannot be combined with submitToDirectory (directory unlist is out of v1 scope)."));
if (request.OsmNodeId is null || request.OsmNodeId <= 0)
errors.Add(new ValidationError(nameof(request.OsmNodeId),
"Required when unlistFromOsm is true. Must be positive."));
var nodeType = (request.OsmNodeType ?? string.Empty).Trim();
if (string.IsNullOrEmpty(nodeType) || !ValidOsmNodeTypes.Contains(nodeType))
errors.Add(new ValidationError(nameof(request.OsmNodeType),
$"Required when unlistFromOsm is true. One of: {string.Join(", ", ValidOsmNodeTypes)}."));
}
return errors;
}
public async Task<BtcMapsDirectoryResult> SubmitToDirectoryAsync(
BtcMapsSubmitRequest request,
CancellationToken cancellationToken = default)
{
var token = _configuration["BTCMAPS:DirectoryGithubToken"];
if (string.IsNullOrWhiteSpace(token))
return new BtcMapsDirectoryResult { Skipped = "directory-github-token-not-configured" };
var repo = _configuration["BTCMAPS:DirectoryRepo"] ?? DefaultDirectoryRepo;
var merchantsPath = _configuration["BTCMAPS:DirectoryMerchantsPath"] ?? DefaultDirectoryMerchantsPath;
using var client = new HttpClient();
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("User-Agent", UserAgent);
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var repoInfo = await GetJsonAsync(client, $"repos/{repo}", cancellationToken);
var defaultBranch = repoInfo.GetProperty("default_branch").GetString()
?? throw new InvalidOperationException("default_branch missing");
var fileInfo = await GetJsonAsync(
client,
$"repos/{repo}/contents/{merchantsPath}?ref={Uri.EscapeDataString(defaultBranch)}",
cancellationToken);
var contentB64 = fileInfo.GetProperty("content").GetString() ?? string.Empty;
var fileSha = fileInfo.GetProperty("sha").GetString() ?? string.Empty;
var currentJson = Encoding.UTF8.GetString(Convert.FromBase64String(contentB64.Replace("\n", string.Empty)));
var merchants = JsonSerializer.Deserialize<List<JsonElement>>(currentJson)
?? throw new InvalidOperationException("merchants.json must be a JSON array");
var normalizedUrl = NormalizeUrl(request.Url!);
foreach (var existing in merchants)
{
if (existing.TryGetProperty("url", out var existingUrl) &&
existingUrl.ValueKind == JsonValueKind.String &&
NormalizeUrl(existingUrl.GetString() ?? string.Empty) == normalizedUrl)
{
var existingName = existing.TryGetProperty("name", out var n) ? n.GetString() : "(unknown)";
return new BtcMapsDirectoryResult { Skipped = $"duplicate-url:{existingName}" };
}
}
var marker = BuildUrlMarker(normalizedUrl);
var openPrSearch = await GetJsonAsync(
client,
$"search/issues?q={Uri.EscapeDataString($"repo:{repo} is:pr is:open in:body \"{marker}\"")}",
cancellationToken);
if (openPrSearch.TryGetProperty("total_count", out var totalCount) && totalCount.GetInt32() > 0)
{
var firstItem = openPrSearch.GetProperty("items")[0];
return new BtcMapsDirectoryResult
{
Skipped = "duplicate-open-pr",
PrUrl = firstItem.TryGetProperty("html_url", out var h) ? h.GetString() : null,
PrNumber = firstItem.TryGetProperty("number", out var n) ? n.GetInt32() : null
};
}
var newEntry = BuildMerchantEntry(request);
var updated = merchants
.Select(e => (JsonElement?)e)
.Append(newEntry)
.OrderBy(e => e!.Value.TryGetProperty("name", out var n) ? n.GetString() : string.Empty,
StringComparer.OrdinalIgnoreCase)
.Select(e => e!.Value)
.ToList();
// Use UnsafeRelaxedJsonEscaping so non-ASCII codepoints and HTML-only "unsafe"
// chars (`&`, `'`, `<`, `>`) are written raw in the file, matching the upstream
// merchants.json convention. The default JavaScriptEncoder is HTML-safe and
// would re-encode every entry containing `'` or non-ASCII as `\uXXXX`, which
// shows up as a noisy full-file diff on every append.
var updatedJson = JsonSerializer.Serialize(updated, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
}) + "\n";
var branchRef = await GetJsonAsync(
client,
$"repos/{repo}/git/ref/heads/{Uri.EscapeDataString(defaultBranch)}",
cancellationToken);
var baseSha = branchRef.GetProperty("object").GetProperty("sha").GetString()
?? throw new InvalidOperationException("base sha missing");
var branchSuffix = Guid.NewGuid().ToString("N")[..8];
var branchName = $"btcmaps/{Slugify(request.Name!)}-{branchSuffix}";
await PostJsonAsync(client, $"repos/{repo}/git/refs",
new { @ref = $"refs/heads/{branchName}", sha = baseSha }, cancellationToken);
await PutJsonAsync(client, $"repos/{repo}/contents/{merchantsPath}",
new
{
message = $"Add {request.Name}",
content = Convert.ToBase64String(Encoding.UTF8.GetBytes(updatedJson)),
sha = fileSha,
branch = branchName
}, cancellationToken);
var prBody = BuildPrBody(request, marker);
var prResponse = await PostJsonAsync(client, $"repos/{repo}/pulls",
new
{
title = $"Add {request.Name}",
head = branchName,
@base = defaultBranch,
body = prBody
}, cancellationToken);
return new BtcMapsDirectoryResult
{
PrUrl = prResponse.GetProperty("html_url").GetString(),
PrNumber = prResponse.GetProperty("number").GetInt32(),
Branch = branchName
};
}
public async Task<BtcMapsOsmResult> TagOnOsmAsync(
BtcMapsSubmitRequest request,
CancellationToken cancellationToken = default)
{
var token = _configuration["BTCMAPS:OsmAccessToken"];
if (string.IsNullOrWhiteSpace(token))
return new BtcMapsOsmResult { Skipped = "osm-access-token-not-configured" };
var apiBase = _configuration["BTCMAPS:OsmApiBase"] ?? DefaultOsmApiBase;
var isCreate = request.OsmNodeId is null;
var nodeType = isCreate ? "node" : request.OsmNodeType!.ToLowerInvariant();
using var client = new HttpClient { BaseAddress = new Uri(apiBase) };
client.DefaultRequestHeaders.Add("User-Agent", UserAgent);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var changesetComment = isCreate
? $"Add {request.Name} as a bitcoin-accepting place via BTCPay Server #btcmap"
: $"Tag {request.Name} as accepting bitcoin via BTCPay Server #btcmap";
var changesetXml = new XDocument(
new XElement("osm",
new XElement("changeset",
new XElement("tag", new XAttribute("k", "created_by"), new XAttribute("v", UserAgent)),
new XElement("tag", new XAttribute("k", "comment"), new XAttribute("v", changesetComment)),
new XElement("tag", new XAttribute("k", "source"), new XAttribute("v", "BTCPay Server plugin-builder")))));
var csResponse = await client.PutAsync("changeset/create",
new StringContent(changesetXml.ToString(), Encoding.UTF8, "text/xml"), cancellationToken);
csResponse.EnsureSuccessStatusCode();
var changesetId = long.Parse(await csResponse.Content.ReadAsStringAsync(cancellationToken));
try
{
long nodeId;
int newVersion;
if (isCreate)
{
// Build a brand-new <node> with the merchant's tags. OSM accepts the
// POST /api/0.6/node body as <osm><node ...><tag .../></node></osm>
// and returns the freshly-allocated node ID as plain text.
var amenity = string.IsNullOrWhiteSpace(request.OsmCategory)
? "shop"
: request.OsmCategory.Trim();
var newNode = new XElement("node",
new XAttribute("changeset", changesetId),
new XAttribute("lat", request.Latitude!.Value.ToString("R", CultureInfo.InvariantCulture)),
new XAttribute("lon", request.Longitude!.Value.ToString("R", CultureInfo.InvariantCulture)));
newNode.Add(new XElement("tag", new XAttribute("k", "name"), new XAttribute("v", request.Name!.Trim())));
newNode.Add(new XElement("tag", new XAttribute("k", "amenity"), new XAttribute("v", amenity)));
newNode.Add(new XElement("tag", new XAttribute("k", "currency:XBT"), new XAttribute("v", "yes")));
// BTC Map verification stamp - bumped on every tag operation per
// https://gitea.btcmap.org/teambtcmap/btcmap-general/wiki/Verifying-Existing-Merchants
// Date-only UTC; the act of submitting through the plugin is itself the verification.
newNode.Add(new XElement("tag", new XAttribute("k", "check_date:currency:XBT"), new XAttribute("v", TodayUtcDate())));
if (!string.IsNullOrWhiteSpace(request.Url))
newNode.Add(new XElement("tag", new XAttribute("k", "website"), new XAttribute("v", request.Url.Trim())));
if (request.AcceptsLightning)
newNode.Add(new XElement("tag", new XAttribute("k", "payment:lightning"), new XAttribute("v", "yes")));
AddAddressTagsToNewNode(newNode, request.Address);
var createDoc = new XDocument(new XElement("osm", newNode));
var createResponse = await client.PutAsync("node/create",
new StringContent(createDoc.ToString(), Encoding.UTF8, "text/xml"), cancellationToken);
createResponse.EnsureSuccessStatusCode();
nodeId = long.Parse(await createResponse.Content.ReadAsStringAsync(cancellationToken));
newVersion = 1;
}
else
{
nodeId = request.OsmNodeId!.Value;
var elementPath = $"{nodeType}/{nodeId}";
var elementXmlText = await client.GetStringAsync(elementPath, cancellationToken);
var elementDoc = XDocument.Parse(elementXmlText);
var elementEl = elementDoc.Root?.Element(nodeType)
?? throw new InvalidOperationException($"OSM element <{nodeType}> not found in response");
elementEl.SetAttributeValue("changeset", changesetId);
// Bitcoin acceptance: per OSM, payment:bitcoin=yes is deprecated in favor
// of currency:XBT=yes (XBT is ISO 4217). Lightning is gated on the
// request's AcceptsLightning flag (per-store config).
SetOsmTag(elementEl, "currency:XBT", "yes");
// BTC Map verification stamp - same date-only UTC stamp as the create
// path, bumped here on re-verify or on any tag-update flow.
SetOsmTag(elementEl, "check_date:currency:XBT", TodayUtcDate());
if (!string.IsNullOrWhiteSpace(request.Url))
SetOsmTag(elementEl, "website", request.Url);
if (request.AcceptsLightning)
SetOsmTag(elementEl, "payment:lightning", "yes");
ApplyAddressTags(elementEl, request.Address);
var putResponse = await client.PutAsync(elementPath,
new StringContent(elementDoc.ToString(), Encoding.UTF8, "text/xml"), cancellationToken);
putResponse.EnsureSuccessStatusCode();
newVersion = int.Parse(await putResponse.Content.ReadAsStringAsync(cancellationToken));
}
return new BtcMapsOsmResult
{
ChangesetId = changesetId,
NodeId = nodeId,
NodeType = nodeType,
NewVersion = newVersion,
Created = isCreate
};
}
finally
{
using var closeCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
try
{
await client.PutAsync($"changeset/{changesetId}/close",
new StringContent(string.Empty), closeCts.Token);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to close OSM changeset {ChangesetId}", changesetId);
}
}
}
// Bitcoin-acceptance tags this service removes when un-listing. Keeps `website`,
// `name`, `amenity`, and address tags intact since those are not bitcoin-specific
// (a venue may remain on OSM after it stops accepting bitcoin). payment:bitcoin
// is included for historical nodes tagged before the deprecation-vs-currency:XBT
// switch.
private static readonly string[] BitcoinAcceptanceTagKeys =
{
"currency:XBT",
"payment:bitcoin",
"payment:lightning",
"payment:onchain"
};
public async Task<BtcMapsOsmResult> UnlistFromOsmAsync(
BtcMapsSubmitRequest request,
CancellationToken cancellationToken = default)
{
var token = _configuration["BTCMAPS:OsmAccessToken"];
if (string.IsNullOrWhiteSpace(token))
return new BtcMapsOsmResult { Skipped = "osm-access-token-not-configured" };
var apiBase = _configuration["BTCMAPS:OsmApiBase"] ?? DefaultOsmApiBase;
var nodeType = request.OsmNodeType!.ToLowerInvariant();
var nodeId = request.OsmNodeId!.Value;
var elementPath = $"{nodeType}/{nodeId}";
using var client = new HttpClient { BaseAddress = new Uri(apiBase) };
client.DefaultRequestHeaders.Add("User-Agent", UserAgent);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Fetch first so we can skip the changeset entirely when the element already
// has none of the bitcoin-related tags we remove. Idempotency + 409 surface.
var elementXmlText = await client.GetStringAsync(elementPath, cancellationToken);
var elementDoc = XDocument.Parse(elementXmlText);
var elementEl = elementDoc.Root?.Element(nodeType)
?? throw new InvalidOperationException($"OSM element <{nodeType}> not found in response");
var removableKeys = BitcoinAcceptanceTagKeys
.Where(k => elementEl.Elements("tag").Any(t => (string?)t.Attribute("k") == k))
.ToArray();
if (removableKeys.Length == 0)
{
// Nothing to remove - the element already carries no bitcoin-acceptance
// tags we own. Report it so the controller surfaces 409 to the plugin
// (distinguishes idempotent no-op from "removed just now").
return new BtcMapsOsmResult
{
NodeId = nodeId,
NodeType = nodeType,
Skipped = "already-unlisted"
};
}
var changesetXml = new XDocument(
new XElement("osm",
new XElement("changeset",
new XElement("tag", new XAttribute("k", "created_by"), new XAttribute("v", UserAgent)),
new XElement("tag", new XAttribute("k", "comment"), new XAttribute("v", $"Un-list {request.Name} from bitcoin-accepting places via BTCPay Server #btcmap")),
new XElement("tag", new XAttribute("k", "source"), new XAttribute("v", "BTCPay Server plugin-builder")))));
var csResponse = await client.PutAsync("changeset/create",
new StringContent(changesetXml.ToString(), Encoding.UTF8, "text/xml"), cancellationToken);
csResponse.EnsureSuccessStatusCode();
var changesetId = long.Parse(await csResponse.Content.ReadAsStringAsync(cancellationToken));
try
{
elementEl.SetAttributeValue("changeset", changesetId);
foreach (var key in removableKeys)
RemoveOsmTag(elementEl, key);
var putResponse = await client.PutAsync(elementPath,
new StringContent(elementDoc.ToString(), Encoding.UTF8, "text/xml"), cancellationToken);
putResponse.EnsureSuccessStatusCode();
var newVersion = int.Parse(await putResponse.Content.ReadAsStringAsync(cancellationToken));
return new BtcMapsOsmResult
{
ChangesetId = changesetId,
NodeId = nodeId,
NodeType = nodeType,
NewVersion = newVersion,
RemovedTags = removableKeys
};
}
finally
{
using var closeCts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
try
{
await client.PutAsync($"changeset/{changesetId}/close",
new StringContent(string.Empty), closeCts.Token);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to close OSM changeset {ChangesetId}", changesetId);
}
}
}
private static void RemoveOsmTag(XElement element, string key)
{
var existing = element.Elements("tag").FirstOrDefault(t => (string?)t.Attribute("k") == key);
existing?.Remove();
}
private static void SetOsmTag(XElement element, string key, string value)
{
var existing = element.Elements("tag").FirstOrDefault(t => (string?)t.Attribute("k") == key);
if (existing is not null)
existing.SetAttributeValue("v", value);
else
element.Add(new XElement("tag", new XAttribute("k", key), new XAttribute("v", value)));
}
private static string TodayUtcDate() =>
DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
// OSM addr:* writers. Plugin-side splits the raw merchant address into
// structured components; the server writes only the keys whose values are
// populated, never inferring or synthesising. This is the create-path
// helper (appends new <tag> children to a fresh <node>).
private static void AddAddressTagsToNewNode(XElement newNode, BtcMapsSubmitAddress? address)
{
if (address is null) return;
foreach (var (key, raw) in EnumerateAddressTags(address))
{
var value = raw.Trim();
if (value.Length == 0) continue;
newNode.Add(new XElement("tag", new XAttribute("k", key), new XAttribute("v", value)));
}
}
// Update-path helper: applies addr:* via SetOsmTag so existing values get
// overwritten in-place rather than producing duplicate <tag> children.
private static void ApplyAddressTags(XElement element, BtcMapsSubmitAddress? address)
{
if (address is null) return;
foreach (var (key, raw) in EnumerateAddressTags(address))
{
var value = raw.Trim();
if (value.Length == 0) continue;
SetOsmTag(element, key, value);
}
}
private static IEnumerable<(string Key, string Value)> EnumerateAddressTags(BtcMapsSubmitAddress address)
{
if (!string.IsNullOrWhiteSpace(address.HouseNumber)) yield return ("addr:housenumber", address.HouseNumber);
if (!string.IsNullOrWhiteSpace(address.Street)) yield return ("addr:street", address.Street);
if (!string.IsNullOrWhiteSpace(address.City)) yield return ("addr:city", address.City);
if (!string.IsNullOrWhiteSpace(address.Postcode)) yield return ("addr:postcode", address.Postcode);
if (!string.IsNullOrWhiteSpace(address.Country)) yield return ("addr:country", address.Country);
}
private static JsonElement BuildMerchantEntry(BtcMapsSubmitRequest request)
{
using var ms = new MemoryStream();
using (var w = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = false }))
{
w.WriteStartObject();
w.WriteString("name", request.Name!.Trim());
w.WriteString("url", request.Url!.Trim());
w.WriteString("description", request.Description!.Trim());
w.WriteString("type", request.Type!.Trim());
if (!string.IsNullOrWhiteSpace(request.SubType))
w.WriteString("subType", request.SubType.Trim());
var directoryCountry = ResolveDirectoryCountry(request);
if (!string.IsNullOrWhiteSpace(directoryCountry))
w.WriteString("country", directoryCountry);
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();
}
// Plugins centralise the merchant's country in one form field. The directory
// submission consumes top-level `country`; OSM addr:country reads
// `Address.Country`. If only one is sent, fall back to the other so the
// merchant.json entry carries the country regardless of which field the
// plugin populated. Top-level wins because it allows the directory-only
// GLOBAL pseudonym (e.g. online-only services) which has no OSM addr:*
// equivalent.
public static string? ResolveDirectoryCountry(BtcMapsSubmitRequest request)
{
if (!string.IsNullOrWhiteSpace(request.Country))
return request.Country.Trim();
if (!string.IsNullOrWhiteSpace(request.Address?.Country))
return request.Address!.Country!.Trim();
return null;
}
private static string BuildPrBody(BtcMapsSubmitRequest request, string urlMarker)
{
var sb = new StringBuilder();
sb.AppendLine("Automated submission from the BTCPay Server plugin-builder `/apis/btcmaps/v1/submit` endpoint.");
sb.AppendLine();
sb.AppendLine($"- **Name:** {request.Name}");
sb.AppendLine($"- **URL:** {request.Url}");
sb.AppendLine($"- **Type:** {request.Type}{(string.IsNullOrWhiteSpace(request.SubType) ? string.Empty : " / " + request.SubType)}");
var prBodyCountry = ResolveDirectoryCountry(request);
if (!string.IsNullOrWhiteSpace(prBodyCountry)) sb.AppendLine($"- **Country:** {prBodyCountry}");
if (!string.IsNullOrWhiteSpace(request.Twitter))
{
// Render as an explicit https://x.com/<handle> link so GitHub markdown does
// not auto-resolve a bare `@handle` to github.com/<handle>.
var raw = request.Twitter.Trim();
var handle = raw.StartsWith("@") ? raw[1..] : raw;
sb.AppendLine($"- **Twitter:** [@{handle}](https://x.com/{handle})");
}
if (!string.IsNullOrWhiteSpace(request.Github)) sb.AppendLine($"- **GitHub:** {request.Github}");
sb.AppendLine();
sb.AppendLine("**Description:**");
sb.AppendLine(request.Description);
sb.AppendLine();
sb.AppendLine("_Please review before merge - this PR was opened programmatically by a BTCMap-plugin merchant submission, not by a maintainer._");
sb.AppendLine();
sb.AppendLine($"<!-- {urlMarker} -->");
return sb.ToString();
}
private static string BuildUrlMarker(string normalizedUrl) =>
$"btcmaps-submit:url={normalizedUrl}";
public static string NormalizeUrl(string url) =>
url.Trim().TrimEnd('/').ToLowerInvariant();
public static string Slugify(string input)
{
var chars = new StringBuilder();
var lastWasDash = true;
foreach (var c in input.ToLowerInvariant())
{
if (c is >= 'a' and <= 'z' or >= '0' and <= '9')
{
chars.Append(c);
lastWasDash = false;
}
else if (!lastWasDash)
{
chars.Append('-');
lastWasDash = true;
}
}
var result = chars.ToString().Trim('-');
if (result.Length > 40) result = result[..40].TrimEnd('-');
return result.Length == 0 ? "merchant" : result;
}
private static async Task<JsonElement> GetJsonAsync(HttpClient client, string path, CancellationToken ct)
{
using var response = await client.GetAsync(path, ct);
await EnsureSuccess(response, path, ct);
var text = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
return doc.RootElement.Clone();
}
private static async Task<JsonElement> PostJsonAsync(HttpClient client, string path, object body, CancellationToken ct)
{
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
using var response = await client.PostAsync(path, content, ct);
await EnsureSuccess(response, path, ct);
var text = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
return doc.RootElement.Clone();
}
private static async Task<JsonElement> PutJsonAsync(HttpClient client, string path, object body, CancellationToken ct)
{
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
using var response = await client.PutAsync(path, content, ct);
await EnsureSuccess(response, path, ct);
var text = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
return doc.RootElement.Clone();
}
private static async Task EnsureSuccess(HttpResponseMessage response, string path, CancellationToken ct)
{
if (response.IsSuccessStatusCode) return;
var body = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"GitHub {(int)response.StatusCode} {path}: {body}");
}
}