Compare commits

..

13 Commits

16 changed files with 868 additions and 616 deletions

View File

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

View File

@ -1,4 +1,5 @@
using System.Text.RegularExpressions;
using Dapper;
using Microsoft.Playwright;
using Microsoft.Playwright.Xunit;
using PluginBuilder.DataModels;
@ -14,6 +15,215 @@ public class PluginDetailsUITests(ITestOutputHelper output) : PageTest
{
private readonly XUnitLogger _log = new("PluginDetailsUITests", output);
[Fact]
public async Task PluginDetails_NonEmbed_KeepsReviewsStackedUnderDescription()
{
await using var tester = new PlaywrightTester(_log);
tester.Server.ReuseDatabase = false;
await tester.StartAsync();
var ownerId = await tester.Server.CreateFakeUserAsync("layout-owner@x.com", confirmEmail: true, githubVerified: true);
const string slug = "plugin-details-layout";
await tester.Server.CreateAndBuildPluginAsync(ownerId, slug);
await tester.Page!.SetViewportSizeAsync(1600, 1000);
await tester.GoToUrl($"/public/plugins/{slug}");
await tester.AssertNoError();
var descriptionCard = tester.Page.Locator(".card").Filter(new LocatorFilterOptions { HasText = "Description" });
var detailsCard = tester.Page.Locator("#download-btn").Locator("xpath=ancestor::*[contains(concat(' ', normalize-space(@class), ' '), ' card ')][1]");
var reviewsCard = tester.Page.Locator("#reviews");
await Expect(descriptionCard).ToHaveCountAsync(1);
await Expect(detailsCard).ToHaveCountAsync(1);
await Expect(reviewsCard).ToHaveCountAsync(1);
var descriptionBox = await descriptionCard.BoundingBoxAsync() ?? throw new InvalidOperationException("Description card was not visible.");
var detailsBox = await detailsCard.BoundingBoxAsync() ?? throw new InvalidOperationException("Details card was not visible.");
var reviewsBox = await reviewsCard.BoundingBoxAsync() ?? throw new InvalidOperationException("Reviews card was not visible.");
var reviewsGap = reviewsBox.Y - (descriptionBox.Y + descriptionBox.Height);
Assert.InRange(reviewsGap, 0, 80);
Assert.True(
reviewsBox.Y < detailsBox.Y + detailsBox.Height,
"Reviews should stack under the description column instead of waiting for the metadata card height.");
}
[Fact]
public async Task PluginDetails_EmbedNarrow_KeepsVersionBeforeReviews()
{
await using var tester = new PlaywrightTester(_log);
tester.Server.ReuseDatabase = false;
await tester.StartAsync();
var ownerId = await tester.Server.CreateFakeUserAsync("embed-layout-owner@x.com", confirmEmail: true, githubVerified: true);
const string slug = "plugin-details-embed-layout";
await tester.Server.CreateAndBuildPluginAsync(ownerId, slug);
await tester.Page!.SetViewportSizeAsync(700, 1000);
await tester.GoToUrl($"/public/plugins/{slug}?embed=1");
await tester.AssertNoError();
var descriptionCard = tester.Page.Locator(".plugin-details-description-card");
var detailsCard = tester.Page.Locator(".plugin-details-metadata-card");
var reviewsCard = tester.Page.Locator(".plugin-details-reviews-card");
await Expect(descriptionCard).ToHaveCountAsync(1);
await Expect(detailsCard).ToHaveCountAsync(1);
await Expect(reviewsCard).ToHaveCountAsync(1);
await Expect(tester.Page.Locator("#btcpay-install-plugin-btn")).ToHaveCountAsync(1);
var descriptionBox = await descriptionCard.BoundingBoxAsync() ?? throw new InvalidOperationException("Description card was not visible.");
var detailsBox = await detailsCard.BoundingBoxAsync() ?? throw new InvalidOperationException("Details card was not visible.");
var reviewsBox = await reviewsCard.BoundingBoxAsync() ?? throw new InvalidOperationException("Reviews card was not visible.");
Assert.True(descriptionBox.Y < detailsBox.Y, "Description should stay above metadata in embedded details.");
Assert.True(detailsBox.Y < reviewsBox.Y, "Metadata should stay above reviews in embedded details.");
}
[Fact]
public async Task PluginDetails_EmbedSelection_PreservesCompatibilityQuery()
{
await using var tester = new PlaywrightTester(_log);
tester.Server.ReuseDatabase = false;
await tester.StartAsync();
var ownerId = await tester.Server.CreateFakeUserAsync("embed-selection-owner@x.com", confirmEmail: true, githubVerified: true);
const string firstSlug = "embed-select-a";
const string secondSlug = "embed-select-b";
await tester.Server.CreateAndBuildPluginAsync(ownerId, firstSlug);
await tester.Server.CreateAndBuildPluginAsync(ownerId, secondSlug);
var embedOrigin = tester.ServerUri!.GetLeftPart(UriPartial.Authority);
await tester.GoToUrl("/");
var detailsUrl = new Uri(tester.ServerUri!, $"/public/plugins/{firstSlug}?embed=1&btcpayVersion=2.3.6&includePreRelease=true");
await tester.Page!.SetContentAsync($"""
<iframe id="details" src="{detailsUrl}"></iframe>
""");
await tester.Page.WaitForFunctionAsync("""
() => document.querySelector('#details')?.contentWindow?.document?.querySelector('[data-embed-page="details"]')
""");
await tester.Page.EvaluateAsync($$"""
() => document.querySelector('#details').contentWindow.postMessage({
type: 'btcpay:host-context',
selectedSlug: '{{secondSlug}}'
}, '{{embedOrigin}}')
""");
var finalUrlHandle = await tester.Page.WaitForFunctionAsync($$"""
() => {
const href = document.querySelector('#details')?.contentWindow?.location?.href || '';
return href.includes('/public/plugins/{{secondSlug}}') ? href : false;
}
""");
var finalUrl = await finalUrlHandle.JsonValueAsync<string>();
Assert.Contains($"/public/plugins/{secondSlug}", finalUrl);
Assert.Contains("embed=1", finalUrl);
Assert.Contains("btcpayVersion=2.3.6", finalUrl);
Assert.Contains("includePreRelease=true", finalUrl);
}
[Fact]
public async Task PluginDetails_PreReleaseInstall_ConfirmsBeforePostingEmbedInstallRequest()
{
await using var tester = new PlaywrightTester(_log);
tester.Server.ReuseDatabase = false;
await tester.StartAsync();
var ownerId = await tester.Server.CreateFakeUserAsync("pre-release-details-owner@x.com", confirmEmail: true, githubVerified: true);
const string slug = "plugin-details-pre-release";
var fullBuildId = await tester.Server.CreateAndBuildPluginAsync(ownerId, slug);
await using (var conn = await tester.Server.GetService<DBConnectionFactory>().Open())
{
var manifestInfo = await conn.QuerySingleAsync<string>(
"SELECT manifest_info FROM builds WHERE plugin_slug = @pluginSlug AND id = @buildId",
new { pluginSlug = slug, buildId = fullBuildId.BuildId });
var manifest = PluginManifest.Parse(manifestInfo);
await conn.SetVersionBuild(fullBuildId, manifest.Version, manifest.BTCPayMinVersion, manifest.BTCPayMaxVersion, false);
await conn.ExecuteAsync(
"""
INSERT INTO versions (plugin_slug, ver, build_id, btcpay_min_ver, btcpay_max_ver, pre_release)
VALUES (@pluginSlug, @version, @buildId, @btcpayMinVer, @btcpayMaxVer, TRUE)
""",
new
{
pluginSlug = slug,
version = PluginVersion.Parse("1.0.3.0").VersionParts,
buildId = fullBuildId.BuildId,
btcpayMinVer = manifest.BTCPayMinVersion?.VersionParts ?? PluginVersion.Zero.VersionParts,
btcpayMaxVer = manifest.BTCPayMaxVersion?.VersionParts
});
}
var embedOrigin = tester.ServerUri!.GetLeftPart(UriPartial.Authority);
var detailsUrl = new Uri(tester.ServerUri!, $"/public/plugins/{slug}?btcpayVersion=1.4.6.0&includePreRelease=true&embed=1&sort=helpful&count=10");
await tester.GoToUrl("/");
await tester.Page!.SetContentAsync($"""
<iframe id="details" src="{detailsUrl}"></iframe>
""");
await tester.Page.WaitForFunctionAsync("""
() => document.querySelector('#details')?.contentWindow?.document?.querySelector('[data-embed-page="details"]')
""");
await tester.Page.EvaluateAsync("""
() => {
window.__installMessages = [];
window.addEventListener('message', event => {
if (event.data?.type === 'pb:install-requested') {
window.__installMessages.push(event.data);
}
});
}
""");
await tester.Page.EvaluateAsync($$"""
() => document.querySelector('#details').contentWindow.postMessage({
type: 'btcpay:host-context',
colorMode: 'light'
}, '{{embedOrigin}}')
""");
var frame = tester.Page.FrameLocator("#details");
var versionButton = frame.Locator("#version-dropdown-btn");
await Expect(versionButton).ToContainTextAsync("1.0.3");
await Expect(frame.Locator("#btcpay-install-plugin-btn")).ToHaveTextAsync("Install in BTCPay Server");
await frame.Locator("#btcpay-install-plugin-btn").ClickAsync();
await Expect(frame.Locator("#pre-release-confirm-modal")).ToBeVisibleAsync();
Assert.Equal(0, await tester.Page.EvaluateAsync<int>("() => window.__installMessages.length"));
await frame.Locator("#pre-release-confirm-continue").ClickAsync();
await tester.Page.WaitForFunctionAsync("() => window.__installMessages.length === 1");
var installMessageJson = await tester.Page.EvaluateAsync<string>("() => JSON.stringify(window.__installMessages[0])");
Assert.Contains("\"version\":\"1.0.3.0\"", installMessageJson);
Assert.Contains("\"preRelease\":true", installMessageJson);
await versionButton.ClickAsync();
var versionMenu = frame.Locator("#version-dropdown-btn").Locator("xpath=following-sibling::ul[contains(@class, 'dropdown-menu')][1]");
var releaseItem = versionMenu.Locator("a.dropdown-item").Filter(new LocatorFilterOptions { HasText = "1.0.2" });
Assert.DoesNotContain("pre-release", await versionMenu.InnerTextAsync(), StringComparison.OrdinalIgnoreCase);
await releaseItem.ClickAsync();
var finalUrlHandle = await tester.Page.WaitForFunctionAsync("""
() => {
const href = document.querySelector('#details')?.contentWindow?.location?.href || '';
return href.includes('version=1.0.2.0') ? href : false;
}
""");
var finalUrl = await finalUrlHandle.JsonValueAsync<string>();
Assert.Contains("btcpayVersion=1.4.6.0", finalUrl);
Assert.Contains("includePreRelease=true", finalUrl);
Assert.Contains("embed=1", finalUrl);
Assert.Contains("sort=helpful", finalUrl);
Assert.Contains("count=10", finalUrl);
}
[Fact]
public async Task PluginDetails_Reviews_Flow_Works()
{

View File

@ -17,6 +17,45 @@ public class PublicDirectoryUITests(ITestOutputHelper output) : PageTest
{
private readonly XUnitLogger _log = new("PublicDirectoryUITests", output);
[Fact]
public async Task PublicDirectory_EmbedLinks_PreserveCompatibilityQuery()
{
await using var tester = new PlaywrightTester(_log);
tester.Server.ReuseDatabase = false;
await tester.StartAsync();
await using var conn = await tester.Server.GetService<DBConnectionFactory>().Open();
var ownerId = await tester.Server.CreateFakeUserAsync(confirmEmail: true, githubVerified: true);
const string pluginSlug = "public-directory-embed-query";
var fullBuildId = await tester.Server.CreateAndBuildPluginAsync(ownerId, pluginSlug);
var manifestInfoJson = await conn.QuerySingleAsync<string>(
"SELECT manifest_info FROM builds WHERE plugin_slug = @PluginSlug AND id = @BuildId",
new { PluginSlug = pluginSlug, fullBuildId.BuildId });
var manifest = PluginManifest.Parse(manifestInfoJson);
await conn.SetVersionBuild(fullBuildId, manifest.Version, manifest.BTCPayMinVersion, manifest.BTCPayMaxVersion, false);
await conn.SetPluginSettings(pluginSlug, null, PluginVisibilityEnum.Listed);
await tester.GoToUrl("/public/plugins?embed=1&btcpayVersion=2.3.6-rc2&includePreRelease=true&sort=rating");
await tester.AssertNoError();
await Expect(tester.Page!.Locator("form input[name='btcpayVersion']")).ToHaveValueAsync("2.3.6-rc2");
await Expect(tester.Page.Locator("form input[name='includePreRelease']")).ToHaveValueAsync("true");
var detailsHref = await tester.Page.Locator($"a[data-plugin-slug='{pluginSlug}']").GetAttributeAsync("href");
Assert.NotNull(detailsHref);
Assert.Contains($"/public/plugins/{pluginSlug}", detailsHref);
Assert.Contains("embed=1", detailsHref);
Assert.Contains("btcpayVersion=2.3.6-rc2", detailsHref);
Assert.Contains("includePreRelease=true", detailsHref);
var alphaSortHref = await tester.Page.Locator("a.dropdown-item[href*='sort=alpha']").GetAttributeAsync("href");
Assert.NotNull(alphaSortHref);
Assert.Contains("embed=1", alphaSortHref);
Assert.Contains("btcpayVersion=2.3.6-rc2", alphaSortHref);
Assert.Contains("includePreRelease=true", alphaSortHref);
}
[Fact]
public async Task PublicDirectory_RespectsPluginVisibility()
{

View File

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

View File

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

View File

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

View File

@ -210,8 +210,8 @@ public class HomeController(
[HttpGet("public/plugins")]
[EnableRateLimiting(Policies.PublicApiRateLimit)]
public async Task<IActionResult> AllPlugins(
[ModelBinder(typeof(PluginVersionModelBinder))]
PluginVersion? btcpayVersion = null, string? searchPluginName = null, string sort = "smart")
[ModelBinder(typeof(BtcPayHostVersionModelBinder))]
PluginVersion? btcpayVersion = null, bool includePreRelease = false, string? searchPluginName = null, string sort = "smart")
{
searchPluginName = searchPluginName.StripControlCharacters();
@ -285,7 +285,7 @@ public class HomeController(
new
{
btcpayVersion = btcpayVersion?.VersionParts,
includePreRelease = false,
includePreRelease,
searchPattern = $"%{searchPluginName}%",
hasSearchTerm = !string.IsNullOrWhiteSpace(searchPluginName)
});
@ -322,7 +322,12 @@ public class HomeController(
public async Task<IActionResult> GetPluginDetails(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
[FromQuery] PluginDetailsViewModel? model)
[ModelBinder(typeof(PluginVersionModelBinder))]
PluginVersion? version = null,
[ModelBinder(typeof(BtcPayHostVersionModelBinder))]
PluginVersion? btcpayVersion = null,
bool includePreRelease = false,
[FromQuery] PluginDetailsViewModel? model = null)
{
if (pluginSlug is null)
return NotFound();
@ -341,9 +346,39 @@ public class HomeController(
? " (hv.up_count - hv.down_count) DESC, r.created_at DESC "
: " r.created_at DESC ";
var versionFilter = version is null ? string.Empty : "AND v.ver = @version";
var versionSource = btcpayVersion is null
? "versions v"
: "get_all_versions(@btcpayVersion, @includePreRelease) gv JOIN versions v ON v.plugin_slug = gv.plugin_slug AND v.ver = gv.ver";
var versionsQuery = btcpayVersion is null
? """
SELECT array_agg(array_to_string(ver, '.') ORDER BY ver DESC)
FROM versions
WHERE plugin_slug = v.plugin_slug
"""
: """
SELECT array_agg(array_to_string(gv.ver, '.') ORDER BY gv.ver DESC)
FROM get_all_versions(@btcpayVersion, @includePreRelease) gv
WHERE gv.plugin_slug = v.plugin_slug
""";
var versionPreReleasesQuery = btcpayVersion is null
? """
SELECT array_agg(pre_release ORDER BY ver DESC)
FROM versions
WHERE plugin_slug = v.plugin_slug
"""
: """
SELECT array_agg(vv.pre_release ORDER BY gv.ver DESC)
FROM get_all_versions(@btcpayVersion, @includePreRelease) gv
JOIN versions vv ON vv.plugin_slug = gv.plugin_slug AND vv.ver = gv.ver
WHERE gv.plugin_slug = v.plugin_slug
""";
var prms = new
{
pluginSlug = pluginSlug.ToString(),
version = version?.VersionParts,
btcpayVersion = btcpayVersion?.VersionParts,
includePreRelease,
currentUserId = userId,
isAdmin,
skip = model.Skip,
@ -369,15 +404,13 @@ public class HomeController(
WHERE b2.plugin_slug = v.plugin_slug
ORDER BY b2.id ASC
LIMIT 1) AS created_at,
(
SELECT array_agg(array_to_string(ver, '.') ORDER BY ver DESC)
FROM versions
WHERE plugin_slug = v.plugin_slug
) AS versions
FROM versions v
(" + versionsQuery + @") AS versions,
(" + versionPreReleasesQuery + @") AS version_pre_releases
FROM " + versionSource + @"
JOIN builds b ON b.plugin_slug = v.plugin_slug AND b.id = v.build_id
JOIN plugins p ON b.plugin_slug = p.slug
WHERE v.plugin_slug = @pluginSlug
" + versionFilter + @"
AND b.manifest_info IS NOT NULL
AND b.build_info IS NOT NULL
AND (
@ -444,7 +477,15 @@ public class HomeController(
var pluginDetails = await multi.ReadFirstOrDefaultAsync<dynamic>();
if (pluginDetails is null)
return NotFound();
var versions = pluginDetails.versions as IEnumerable<string> ?? Enumerable.Empty<string>();
var versionLabels = (pluginDetails.versions as IEnumerable<string> ?? Enumerable.Empty<string>()).ToList();
var versionPreReleases = (pluginDetails.version_pre_releases as IEnumerable<bool> ?? Enumerable.Empty<bool>()).ToList();
var versions = versionLabels
.Select((v, i) => new PluginDetailsVersionViewModel
{
Version = v,
PreRelease = i < versionPreReleases.Count && versionPreReleases[i]
})
.ToList();
//second
var summary = await multi.ReadFirstOrDefaultAsync<PluginRatingSummary>() ?? new PluginRatingSummary();
@ -504,12 +545,13 @@ public class HomeController(
Reviews = items,
IsAdmin = isAdmin,
IsOwner = userId != null && userId == primaryOwnerId,
PluginVersions = versions.ToList(),
PluginVersions = versions,
ShowHiddenNotice = Enum.Parse<PluginVisibilityEnum>((string)pluginDetails.visibility, true) == PluginVisibilityEnum.Hidden,
Contributors = pluginContributors,
RatingFilter = model.RatingFilter,
OwnerGithubUrl = ownerGithubUrl,
OwnerNostrUrl = ownerNostrUrl
OwnerNostrUrl = ownerNostrUrl,
EmbedMode = string.Equals(Request.Query["embed"], "1", StringComparison.Ordinal)
};
return View(vm);
}
@ -558,7 +600,11 @@ public class HomeController(
await conn.UpsertPluginReview(reviewViewModel);
var sort = Request.Query["sort"].ToString();
var url = Url.Action(nameof(GetPluginDetails), "Home", new { pluginSlug = pluginSlug.ToString(), sort = string.IsNullOrEmpty(sort) ? null : sort });
var url = Url.Action(nameof(GetPluginDetails), "Home", new
{
pluginSlug = pluginSlug.ToString(),
sort = string.IsNullOrEmpty(sort) ? null : sort
});
return Redirect((url ?? "/") + "#reviews");
}
@ -635,7 +681,10 @@ public class HomeController(
if (!ok)
TempData[TempDataConstant.WarningMessage] = "Error while updating review helpful vote";
var url = Url.Action(nameof(GetPluginDetails), new { pluginSlug = pluginSlug.ToString() });
var url = Url.Action(nameof(GetPluginDetails), new
{
pluginSlug = pluginSlug.ToString()
});
return Redirect((url ?? "/") + "#reviews");
}
@ -659,7 +708,10 @@ public class HomeController(
if (!ok)
TempData[TempDataConstant.WarningMessage] = "Error while deleting review";
var url = Url.Action(nameof(GetPluginDetails), new { pluginSlug = pluginSlug.ToString() });
var url = Url.Action(nameof(GetPluginDetails), new
{
pluginSlug = pluginSlug.ToString()
});
return Redirect((url ?? "/") + "#reviews");
}

View File

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

View File

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

View File

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

View File

@ -17,13 +17,20 @@ public sealed class PluginDetailsViewModel : BasePagingViewModel
public bool IsAdmin { get; set; }
public bool? IsOwner { get; set; }
public List<string>? PluginVersions { get; set; }
public List<PluginDetailsVersionViewModel> PluginVersions { get; set; } = new();
public bool ShowHiddenNotice { get; set; }
public List<GitHubContributor> Contributors { get; init; } = new();
public int? RatingFilter { get; set; }
public string? OwnerGithubUrl { get; set; }
public string? OwnerNostrUrl { get; set; }
public bool EmbedMode { get; set; }
}
public sealed class PluginDetailsVersionViewModel
{
public string Version { get; init; } = "";
public bool PreRelease { get; init; }
}
public class Review

View File

@ -6,9 +6,15 @@
const string title = "BTCPay Server Plugin Directory";
const string desc = "Extend and customize your BTCPay Server with community and official plugins.";
ViewData["Title"] = title;
var embedMode = Context.Request.Query["embed"] == "1";
var embedRoute = embedMode ? "1" : null;
var currentSearch = Context.Request.Query["searchPluginName"].ToString();
var currentSort = (string?)Context.Request.Query["sort"] ?? "smart";
var currentBtcpayVersion = Context.Request.Query["btcpayVersion"].ToString();
var currentIncludePreRelease = Context.Request.Query["includePreRelease"].ToString();
var btcpayVersionRoute = string.IsNullOrEmpty(currentBtcpayVersion) ? null : currentBtcpayVersion;
var includePreReleaseRoute = string.IsNullOrEmpty(currentIncludePreRelease) ? null : currentIncludePreRelease;
if (string.IsNullOrEmpty(currentSort)) currentSort = "smart";
var sortLabel = currentSort switch
@ -35,17 +41,32 @@
<partial name="_PublicHeader" />
<div class="container">
<div class="row mb-4">
<div class="col-12 text-center">
<h3 class="display-5 fw-bold">BTCPay Server Plugin Directory</h3>
<p class="text-muted">Extend and customize your BTCPay Server with community and official plugins.</p>
<div class="@(embedMode ? "container-fluid px-0" : "container")" data-embed-page="list">
@if (!embedMode)
{
<div class="row mb-4">
<div class="col-12 text-center">
<h3 class="display-5 fw-bold">BTCPay Server Plugin Directory</h3>
<p class="text-muted">Extend and customize your BTCPay Server with community and official plugins.</p>
</div>
</div>
</div>
<div class="row mb-4">
<div>
}
<div class="@(embedMode ? "mb-4" : "row mb-4")">
<div class="@(embedMode ? "" : "col-12")">
<form asp-action="AllPlugins" method="get" class="d-flex flex-wrap flex-sm-nowrap align-items-center gap-3">
<input type="hidden" name="sort" value="@currentSort" />
@if (embedMode)
{
<input type="hidden" name="embed" value="1" />
}
@if (!string.IsNullOrEmpty(currentBtcpayVersion))
{
<input type="hidden" name="btcpayVersion" value="@currentBtcpayVersion" />
}
@if (!string.IsNullOrEmpty(currentIncludePreRelease))
{
<input type="hidden" name="includePreRelease" value="@currentIncludePreRelease" />
}
<input type="text"
name="searchPluginName"
@ -66,24 +87,36 @@
<a class="dropdown-item @(currentSort == "smart" ? "active" : "")"
asp-action="AllPlugins"
asp-route-searchPluginName="@currentSearch"
asp-route-btcpayVersion="@btcpayVersionRoute"
asp-route-includePreRelease="@includePreReleaseRoute"
asp-route-embed="@embedRoute"
asp-route-sort="smart">
Relevance
</a>
<a class="dropdown-item @(currentSort == "rating" ? "active" : "")"
asp-action="AllPlugins"
asp-route-searchPluginName="@currentSearch"
asp-route-btcpayVersion="@btcpayVersionRoute"
asp-route-includePreRelease="@includePreReleaseRoute"
asp-route-embed="@embedRoute"
asp-route-sort="rating">
Highest rated
</a>
<a class="dropdown-item @(currentSort == "recent" ? "active" : "")"
asp-action="AllPlugins"
asp-route-searchPluginName="@currentSearch"
asp-route-btcpayVersion="@btcpayVersionRoute"
asp-route-includePreRelease="@includePreReleaseRoute"
asp-route-embed="@embedRoute"
asp-route-sort="recent">
Most recent
</a>
<a class="dropdown-item @(currentSort == "alpha" ? "active" : "")"
asp-action="AllPlugins"
asp-route-searchPluginName="@currentSearch"
asp-route-btcpayVersion="@btcpayVersionRoute"
asp-route-includePreRelease="@includePreReleaseRoute"
asp-route-embed="@embedRoute"
asp-route-sort="alpha">
Alphabetical
</a>
@ -92,14 +125,16 @@
</form>
</div>
</div>
<div class="row">
<div class="@(embedMode ? "overflow-hidden" : "")">
<div class="@(embedMode ? "row row-cols-1 row-cols-md-2 g-4 mb-0" : "row")">
@if (Model != null && Model.Any())
{
@foreach (var plugin in Model)
{
var owner = plugin.GetOwnerName(GitHostingProviderFactory);
var ownerProfileUrl = plugin.GetOwnerProfileUrl(GitHostingProviderFactory);
<div class="col-md-6 mb-4">
var pluginIdentifier = plugin.ManifestInfo?["Identifier"]?.ToString();
<div class="@(embedMode ? "col" : "col-md-6 mb-4")" data-plugin-card data-plugin-identifier="@pluginIdentifier">
<div class="card h-100 plugin-card" data-type="community">
<div class="card-body">
@if (plugin.IsUnlisted)
@ -116,6 +151,11 @@
<div>
<h3 style="margin-top: 0; margin-bottom: 5px;">
<a asp-action="GetPluginDetails"
asp-route-embed="@embedRoute"
asp-route-btcpayVersion="@btcpayVersionRoute"
asp-route-includePreRelease="@includePreReleaseRoute"
data-plugin-slug="@plugin.ProjectSlug"
data-plugin-identifier="@plugin.ManifestInfo?["Identifier"]?.ToString()"
asp-route-pluginSlug="@plugin.ProjectSlug">@plugin.PluginTitle</a>
</h3>
@ -159,10 +199,21 @@
{
<div class="col-12 text-center py-5">
<h4>No plugins available yet</h4>
<p class="text-muted">Check back later, or
<a asp-action="Login" asp-controller="Home" class="text-success text-decoration-none">login to upload one</a>
</p>
@if (!embedMode)
{
<p class="text-muted">Check back later, or
<a asp-action="Login" asp-controller="Home" class="text-success text-decoration-none">login to upload one</a>
</p>
}
</div>
}
@if (embedMode && Model != null && Model.Any())
{
<div class="col-12 text-center py-5" data-plugin-directory-empty-state hidden>
<h4>All compatible plugins from the public directory are already installed or disabled on this server.</h4>
</div>
}
</div>
</div>
</div>

View File

@ -7,12 +7,24 @@
Layout = "_LayoutPublicModal";
var desc = string.IsNullOrWhiteSpace(Model.Plugin.Description) ? "Plugin for BTCPay Server" : Model.Plugin.Description;
ViewData["Title"] = Model.Plugin.PluginTitle + " - " + desc;
var embedRoute = Model.EmbedMode ? "1" : null;
var pluginIdentifier = Model.Plugin.ManifestInfo?["Identifier"]?.ToString();
var owner = Model.Plugin.GetGithubRepository()?.Owner;
var dependencies = Model.Plugin.ManifestInfo?["Dependencies"] as JArray;
var sourceUrl = Model.Plugin.GetGithubRepository()?.GetSourceUrl(Model.Plugin.BuildInfo?["gitCommit"]?.ToString(), Model.Plugin.BuildInfo?["pluginDir"]?.ToString());
var pluginUrl = Url.Action(nameof(HomeController.GetPluginDetails), "Home", new { pluginSlug = Model.Plugin.ProjectSlug }, Context.Request.Scheme, Context.Request.Host.ToString());
var writeReviewUrl = Model.EmbedMode ? $"{pluginUrl}#write-review" : "#write-review";
var writeReviewTarget = Model.EmbedMode ? "_blank" : null;
var writeReviewRel = Model.EmbedMode ? "noopener noreferrer" : null;
var currentBtcpayVersion = Context.Request.Query["btcpayVersion"].ToString();
var currentIncludePreRelease = Context.Request.Query["includePreRelease"].ToString();
var btcpayVersionRoute = string.IsNullOrEmpty(currentBtcpayVersion) ? null : currentBtcpayVersion;
var includePreReleaseRoute = string.IsNullOrEmpty(currentIncludePreRelease) ? null : currentIncludePreRelease;
var currentRating = Model.RatingFilter;
var containerClass = Model.EmbedMode ? "container-fluid px-0" : "container";
DateTimeOffset.TryParse(Model.Plugin.BuildInfo?["buildDate"]?.ToString(), out var buildDate);
var currentVersion = Model.PluginVersions.FirstOrDefault(v => v.Version == Model.Plugin.Version);
var currentShortVersion = string.Join(".", Model.Plugin.Version.Split('.').Take(3));
var videoEmbedUrl = Model.Plugin.VideoUrl.GetVideoEmbedUrl();
var videoThumbUrl = Model.Plugin.VideoUrl.GetVideoThumbnailUrl();
@ -52,7 +64,23 @@
</div>
}
<div class="container">
@if (Model.EmbedMode)
{
<style>
@@media (max-width: 991.98px) {
/* Let cards from both columns share the same Bootstrap order in narrow embeds. */
body.embed-mode .plugin-details-main > .plugin-details-column {
display: contents;
}
}
</style>
}
<div class="@containerClass"
data-embed-page="details"
data-plugin-slug="@Model.Plugin.ProjectSlug"
data-plugin-identifier="@pluginIdentifier"
data-plugin-pre-release="@((currentVersion?.PreRelease == true).ToString().ToLowerInvariant())">
<div class="row mb-4">
<div class="col-12">
<div class="rounded p-4" style="background: linear-gradient(135deg, var(--btcpay-body-bg) 0%, var(--btcpay-body-bg-medium) 100%);">
@ -89,9 +117,9 @@
</div>
</div>
<div class="row">
<div class="col-12 col-lg-8">
<div class="card">
<div class="row plugin-details-main @(Model.EmbedMode ? "gx-0 gx-lg-4" : null)">
<div class="col-12 col-lg-8 plugin-details-column">
<div class="card plugin-details-description-card @(Model.EmbedMode ? "col-12 order-1" : null)">
<div class="card-body">
<h4>Description</h4>
<p>@Model.Plugin.Description</p>
@ -100,7 +128,7 @@
@if (mediaItems.Any())
{
<div class="card mt-4">
<div class="card mt-4 @(Model.EmbedMode ? "col-12 order-3" : null)">
<div class="card-body">
<h4 class="mb-3">Media</h4>
<div id="plugin-media-carousel" class="plugin-media-carousel" tabindex="0" aria-label="Plugin media carousel">
@ -164,11 +192,11 @@
</div>
}
<div class="card mt-4" id="reviews">
<div class="card mt-4 plugin-details-reviews-card @(Model.EmbedMode ? "col-12 order-4" : null)" id="reviews">
<div class="card-body">
<div class="d-flex align-items-center mb-3">
<h4 class="mb-0">Reviews</h4>
<a class="btn btn-primary btn-sm ms-auto" href="#write-review">Write a review</a>
<a class="btn btn-primary btn-sm ms-auto" href="@writeReviewUrl" target="@writeReviewTarget" rel="@writeReviewRel">Write a review</a>
</div>
<div class="mb-3">
@ -180,6 +208,10 @@
<a class="text-decoration-none text-reset d-block"
asp-action="GetPluginDetails" asp-controller="Home"
asp-route-pluginSlug="@Model.Plugin.ProjectSlug"
asp-route-version="@Model.Plugin.Version"
asp-route-btcpayVersion="@btcpayVersionRoute"
asp-route-includePreRelease="@includePreReleaseRoute"
asp-route-embed="@embedRoute"
asp-route-sort="@Model.Sort" asp-route-skip="0" asp-route-count="@Model.Count"
asp-route-RatingFilter="@(isActive ? null : star)" asp-fragment="reviews">
<div class="d-flex align-items-center gap-2 mb-1 py-1 hover-bg @(isActive ? "bg-light border rounded px-2" : "")">
@ -209,11 +241,19 @@
<a class="dropdown-item @(Model.Sort == "newest" ? "active" : null)"
asp-action="GetPluginDetails" asp-controller="Home"
asp-route-pluginSlug="@Model.Plugin.ProjectSlug" asp-route-sort="newest"
asp-route-version="@Model.Plugin.Version"
asp-route-btcpayVersion="@btcpayVersionRoute"
asp-route-includePreRelease="@includePreReleaseRoute"
asp-route-embed="@embedRoute"
asp-route-skip="0" asp-route-count="@Model.Count"
asp-route-RatingFilter="@Model.RatingFilter" asp-fragment="reviews">Newest</a>
<a class="dropdown-item @(Model.Sort == "helpful" ? "active" : null)"
asp-action="GetPluginDetails" asp-controller="Home"
asp-route-pluginSlug="@Model.Plugin.ProjectSlug" asp-route-sort="helpful"
asp-route-version="@Model.Plugin.Version"
asp-route-btcpayVersion="@btcpayVersionRoute"
asp-route-includePreRelease="@includePreReleaseRoute"
asp-route-embed="@embedRoute"
asp-route-skip="0" asp-route-count="@Model.Count"
asp-route-RatingFilter="@Model.RatingFilter" asp-fragment="reviews">Most helpful</a>
</div>
@ -223,7 +263,7 @@
@if (!Model.Reviews.Any())
{
<div class="text-muted">
No reviews yet. <a class="text-success fw-semibold text-decoration-none hover-underline" href="#write-review">Be the first to write one</a>.
No reviews yet. <a class="text-success fw-semibold text-decoration-none hover-underline" href="@writeReviewUrl" target="@writeReviewTarget" rel="@writeReviewRel">Be the first to write one</a>.
</div>
}
else
@ -267,12 +307,12 @@
</div>
<div class="ms-auto d-inline-flex align-items-center gap-3">
@if (review.IsReviewOwner)
@if (Model.EmbedMode || review.IsReviewOwner)
{
<span class="test-upvote-disabled @voteBtn text-muted" aria-disabled="true" title="Authors can't vote their own review" style="cursor: default;">
<span class="test-upvote-disabled @voteBtn text-muted" aria-disabled="true" title="Open the plugin page to vote on reviews" style="cursor: default;">
<vc:icon symbol="thumb-up" /><span class="small ms-1">@review.UpCount</span>
</span>
<span class="test-downvote-disabled @voteBtn text-muted" aria-disabled="true" title="Authors can't vote their own review" style="cursor: default;">
<span class="test-downvote-disabled @voteBtn text-muted" aria-disabled="true" title="Open the plugin page to vote on reviews" style="cursor: default;">
<vc:icon symbol="thumb-down" /><span class="small ms-1">@review.DownCount</span>
</span>
}
@ -301,7 +341,7 @@
<partial name="_MarkdownRender" model="review.Body" />
}
</div>
@if (review.IsReviewOwner || Model.IsAdmin)
@if (!Model.EmbedMode && (review.IsReviewOwner || Model.IsAdmin))
{
<button type="button" class="btn btn-link btn-sm p-0 text-danger align-self-start ms-2"
title="Delete" aria-label="Delete review"
@ -322,7 +362,7 @@
<hr class="my-4" />
<a id="write-review"></a>
@if (User.Identity?.IsAuthenticated == true)
@if (!Model.EmbedMode && User.Identity?.IsAuthenticated == true)
{
@if (Model.IsOwner == true)
{
@ -349,16 +389,19 @@
}
else
{
<div class="alert bg-info text-white">
Please <a class="text-white text-decoration-underline fw-semibold" asp-action="Login" asp-controller="Home">sign in</a> to write a review.
</div>
@if (!Model.EmbedMode)
{
<div class="alert bg-info text-white">
Please <a class="text-white text-decoration-underline fw-semibold" asp-action="Login" asp-controller="Home">sign in</a> to write a review.
</div>
}
}
</div>
</div>
</div>
<div class="col-12 col-lg-4 mt-4 mt-lg-0">
<div class="card plugin-details-metadata">
<div class="col-12 col-lg-4 mt-4 mt-lg-0 plugin-details-column">
<div class="card plugin-details-metadata-card @(Model.EmbedMode ? "col-12 order-2 mt-4 mt-lg-0" : null)">
<div class="card-body">
<div class="row mb-3">
<div class="col-6 d-flex align-items-center"><strong>Version</strong></div>
@ -366,15 +409,25 @@
<div class="dropdown d-inline-block">
<button id="version-dropdown-btn" class="btn btn-secondary btn-sm dropdown-toggle" type="button"
data-bs-toggle="dropdown" aria-expanded="false">
@string.Join(".", Model.Plugin.Version.Split('.').Take(3))
@currentShortVersion
</button>
<ul class="dropdown-menu dropdown-menu-end" style="max-height: 140px; overflow-y: auto;">
@foreach (var v in Model.PluginVersions)
{
var shortV = string.Join(".", v.Split('.').Take(3));
var shortV = string.Join(".", v.Version.Split('.').Take(3));
<li>
<a class="dropdown-item @(v == Model.Plugin.Version ? "active" : "")"
href="#" data-version="@v" data-display="@shortV">@shortV</a>
<a class="dropdown-item @(v.Version == Model.Plugin.Version ? "active" : "")"
asp-action="GetPluginDetails"
asp-controller="Home"
asp-route-pluginSlug="@Model.Plugin.ProjectSlug"
asp-route-version="@v.Version"
asp-route-btcpayVersion="@btcpayVersionRoute"
asp-route-includePreRelease="@includePreReleaseRoute"
asp-route-embed="@embedRoute"
asp-route-sort="@Model.Sort"
asp-route-skip="0"
asp-route-count="@Model.Count"
asp-route-RatingFilter="@Model.RatingFilter">@shortV</a>
</li>
}
</ul>
@ -440,17 +493,30 @@
</span>
</div>
</div>
<a id="download-btn"
asp-action="Download" asp-controller="Api"
asp-route-pluginSlug="@Model.Plugin.ProjectSlug"
asp-route-version="@Model.Plugin.Version"
class="btn btn-primary w-100" target="_blank" rel="noopener noreferrer">Download</a>
@if (!Model.EmbedMode)
{
<a id="download-btn"
asp-action="Download" asp-controller="Api"
asp-route-pluginSlug="@Model.Plugin.ProjectSlug"
asp-route-version="@Model.Plugin.Version"
data-pre-release-confirm="download"
class="btn btn-primary w-100" target="_blank" rel="noopener noreferrer">Download</a>
}
else
{
<button type="button"
id="btcpay-install-plugin-btn"
class="btn btn-primary w-100"
data-plugin-install
data-pre-release-confirm="install"
disabled="@(string.IsNullOrWhiteSpace(pluginIdentifier) ? "disabled" : null)">Install in BTCPay Server</button>
}
</div>
</div>
@if (Model.Contributors?.Any() == true)
{
<div class="card mt-4">
<div class="card mt-4 @(Model.EmbedMode ? "col-12 order-5" : null)">
<div class="card-body">
<h4>Contributors</h4>
<div class="row">
@ -473,6 +539,29 @@
</div>
</div>
@if (currentVersion?.PreRelease == true)
{
<div class="modal fade" id="pre-release-confirm-modal" tabindex="-1" aria-labelledby="pre-release-confirm-title" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" id="pre-release-confirm-title">Pre-release version</h4>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close">
<vc:icon symbol="close" />
</button>
</div>
<div class="modal-body">
This version is marked as pre-release and may be intended for testing. Continue only if you trust this plugin version.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" id="pre-release-confirm-continue">Continue</button>
</div>
</div>
</div>
</div>
}
<partial name="_Confirm" model="@(new ConfirmModel("Delete review", "Are you sure?", "Delete"))" />
<script>
@ -536,67 +625,35 @@
})();
(() => {
const input = document.getElementById('version-selector');
const btn = document.getElementById('version-dropdown-btn');
if (!input || !btn) return;
const page = document.querySelector('[data-embed-page="details"]');
const modalEl = document.getElementById('pre-release-confirm-modal');
const continueButton = document.getElementById('pre-release-confirm-continue');
if (!page || page.dataset.pluginPreRelease !== 'true' || !modalEl || !continueButton) return;
const slug = '@Html.Raw(Model.Plugin.ProjectSlug?.Replace("'", "\\'"))';
const base = '/api/v1/plugins/' + encodeURIComponent(slug) + '/versions/';
const cache = new Map();
let pendingAction = null;
document.querySelectorAll('[data-version]').forEach(item => {
item.addEventListener('click', async function (e) {
e.preventDefault();
const version = this.dataset.version;
document.addEventListener('click', event => {
const action = event.target.closest('[data-pre-release-confirm]');
if (!action || action.dataset.preReleaseConfirmed === 'true') {
if (action) delete action.dataset.preReleaseConfirmed;
return;
}
btn.childNodes[0].textContent = this.dataset.display + ' ';
input.value = this.dataset.version;
event.preventDefault();
event.stopImmediatePropagation();
pendingAction = action;
window.bootstrap.Modal.getOrCreateInstance(modalEl).show();
}, true);
document.querySelectorAll('[data-version]').forEach(el =>
el.classList.toggle('active', el.dataset.version === version));
continueButton.addEventListener('click', () => {
if (!pendingAction) return;
const downloadBtn = document.getElementById('download-btn');
if (downloadBtn) downloadBtn.href = base + encodeURIComponent(version) + '/download';
let data = cache.get(version);
if (!data) {
try {
const res = await fetch(base + encodeURIComponent(version), { headers: { 'Accept': 'application/json' } });
if (!res.ok) return;
data = await res.json();
cache.set(version, data);
} catch { return; }
}
const set = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; };
const toggle = (id, show) => document.getElementById(id)?.classList.toggle('d-none', !show);
const dt = new Date(data?.buildInfo?.buildDate);
set('plugin-published-date', isNaN(dt) ? '' : dt.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' }));
const src = (() => {
const b = data?.buildInfo;
if (!b?.gitRepository || !b?.gitCommit) return null;
try {
const u = new URL(b.gitRepository);
if (u.hostname.toLowerCase() !== 'github.com') return null;
const [owner, repo] = u.pathname.split('/').filter(Boolean);
const dir = (b.pluginDir || '').split('/').filter(Boolean).map(encodeURIComponent).join('/');
return `https://github.com/${owner}/${repo.replace(/\.git$/i, '')}/tree/${encodeURIComponent(b.gitCommit)}${dir ? '/' + dir : ''}`;
} catch { return null; }
})();
const srcEl = document.getElementById('plugin-source-url');
if (srcEl) srcEl.href = src || '#';
toggle('plugin-repository-row', !!src);
const min = (data?.btcPayMinVersion || '').trim();
set('plugin-min-btcpay-version', min);
toggle('plugin-min-btcpay-row', !!min);
const max = (data?.btcPayMaxVersion || '').trim();
set('plugin-max-btcpay-version', max);
toggle('plugin-max-btcpay-row', !!max);
});
pendingAction.dataset.preReleaseConfirmed = 'true';
const action = pendingAction;
pendingAction = null;
window.bootstrap.Modal.getOrCreateInstance(modalEl).hide();
action.click();
});
})();
</script>

View File

@ -1,5 +1,6 @@
@{
Layout = null;
var embedMode = string.Equals(Context.Request.Query["embed"], "1", StringComparison.Ordinal);
}
<!DOCTYPE html>
<html lang="en">
@ -24,14 +25,23 @@
.account-form h4 {
margin-bottom: 1.5rem;
}
body.embed-mode {
background: transparent;
overflow-x: hidden;
}
</style>
</head>
<body class="d-flex flex-column min-vh-100">
<body class="d-flex flex-column min-vh-100 @(embedMode ? "embed-mode" : null)">
<section class="content-wrapper flex-grow-1">
<div class="navbar-brand d-none"></div>
<vc:status-message></vc:status-message>
@RenderBody()
</section>
<script src="~/vendor/bootstrap/bootstrap.bundle.min.js" asp-append-version="true"></script>
@if (embedMode)
{
<script src="~/js/public-embed.js" asp-append-version="true"></script>
}
</body>
</html>

View File

@ -1,39 +1,43 @@
@{
var isAuth = User.Identity?.IsAuthenticated == true;
var embedMode = string.Equals(Context.Request.Query["embed"], "1", StringComparison.Ordinal);
}
<div class="px-4">
<div class="d-flex align-items-center justify-content-between py-3">
<div class="d-flex align-items-center">
<a asp-controller="Home" asp-action="AllPlugins" class="text-decoration-none d-flex align-items-center me-4">
<img src="~/img/btcpay-logo.svg" alt="BTCPay Server" height="32" class="me-2">
<span class="fw-bold fs-5">BTCPay Plugin Builder</span>
</a>
</div>
<div class="d-flex align-items-center gap-3">
<div class="dropdown">
<button class="btn btn-link text-muted p-0 btcpay-theme-trigger" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Theme">
<vc:icon symbol="themes-system"/>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button type="button" class="btcpay-theme-switch dropdown-item" data-theme="light"><vc:icon symbol="themes-light"/> Light theme</button></li>
<li><button type="button" class="btcpay-theme-switch dropdown-item" data-theme="dark"><vc:icon symbol="themes-dark"/> Dark theme</button></li>
<li><button type="button" class="btcpay-theme-switch dropdown-item" data-theme="system"><vc:icon symbol="themes-system"/> Device default</button></li>
</ul>
</div>
@if (!isAuth)
{
<a asp-action="Login"
asp-controller="Home"
class="btn btn-success"
id="login-btn">
Login
@if (!embedMode)
{
<div class="px-4">
<div class="d-flex align-items-center justify-content-between py-3">
<div class="d-flex align-items-center">
<a asp-controller="Home" asp-action="AllPlugins" class="text-decoration-none d-flex align-items-center me-4">
<img src="~/img/btcpay-logo.svg" alt="BTCPay Server" height="32" class="me-2">
<span class="fw-bold fs-5">BTCPay Plugin Builder</span>
</a>
}
else
{
<a asp-action="Dashboard" asp-controller="Home" class="btn btn-success">Dashboard</a>
}
</div>
<div class="d-flex align-items-center gap-3">
<div class="dropdown">
<button class="btn btn-link text-muted p-0 btcpay-theme-trigger" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Theme">
<vc:icon symbol="themes-system"/>
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><button type="button" class="btcpay-theme-switch dropdown-item" data-theme="light"><vc:icon symbol="themes-light"/> Light theme</button></li>
<li><button type="button" class="btcpay-theme-switch dropdown-item" data-theme="dark"><vc:icon symbol="themes-dark"/> Dark theme</button></li>
<li><button type="button" class="btcpay-theme-switch dropdown-item" data-theme="system"><vc:icon symbol="themes-system"/> Device default</button></li>
</ul>
</div>
@if (!isAuth)
{
<a asp-action="Login"
asp-controller="Home"
class="btn btn-success"
id="login-btn">
Login
</a>
}
else
{
<a asp-action="Dashboard" asp-controller="Home" class="btn btn-success">Dashboard</a>
}
</div>
</div>
</div>
</div>
}

View File

@ -0,0 +1,219 @@
(function () {
const embedPage = document.querySelector("[data-embed-page]");
if (!embedPage || window.parent === window) {
return;
}
let lastHeight = 0;
let heightPostQueued = false;
let hiddenPluginIdentifiers = new Set();
let hostOrigin = null;
function applyHostColorMode(colorMode) {
if (colorMode !== "light" && colorMode !== "dark") {
return;
}
document.documentElement.setAttribute("data-btcpay-theme", colorMode);
const darkThemeLink = document.getElementById("DarkThemeLinkTag");
if (darkThemeLink) {
darkThemeLink.disabled = colorMode !== "dark";
}
scheduleHeightPost();
}
function postReady() {
window.parent.postMessage({ type: "pb:ready" }, "*");
}
function getContentHeight() {
const rectHeight = embedPage.getBoundingClientRect ? embedPage.getBoundingClientRect().height : 0;
return Math.max(
embedPage.scrollHeight || 0,
embedPage.offsetHeight || 0,
Math.ceil(rectHeight)
);
}
function postHeight() {
heightPostQueued = false;
const height = Math.ceil(getContentHeight()) + 4;
if (!height || Math.abs(height - lastHeight) < 2) {
return;
}
lastHeight = height;
window.parent.postMessage({
type: "pb:content-height",
height: height
}, "*");
}
function scheduleHeightPost() {
if (heightPostQueued) {
return;
}
heightPostQueued = true;
window.requestAnimationFrame(postHeight);
}
function normalizeIdentifier(identifier) {
return typeof identifier === "string" ? identifier.trim().toLowerCase() : "";
}
function normalizeHiddenPluginIdentifiers(value) {
const identifiers = new Set();
if (!Array.isArray(value)) {
return identifiers;
}
value.forEach(function (identifier) {
const normalizedIdentifier = normalizeIdentifier(identifier);
if (normalizedIdentifier) {
identifiers.add(normalizedIdentifier);
}
});
return identifiers;
}
function applyHiddenPluginFilter() {
if (embedPage.dataset.embedPage !== "list") {
return;
}
let visibleCount = 0;
document.querySelectorAll("[data-plugin-card]").forEach(function (card) {
const identifier = normalizeIdentifier(card.dataset.pluginIdentifier);
const shouldHide = identifier && hiddenPluginIdentifiers.has(identifier);
card.hidden = shouldHide;
if (!shouldHide) {
visibleCount += 1;
}
});
const emptyState = document.querySelector("[data-plugin-directory-empty-state]");
if (emptyState) {
emptyState.hidden = visibleCount !== 0;
}
scheduleHeightPost();
}
function postSelection(slug, identifier) {
if (!slug) {
return;
}
window.parent.postMessage({
type: "pb:plugin-selected",
slug: slug,
identifier: identifier || null
}, "*");
}
function postInstallRequest() {
const identifier = embedPage.dataset.pluginIdentifier || "";
const slug = embedPage.dataset.pluginSlug || "";
const versionInput = document.getElementById("version-selector");
const version = versionInput ? versionInput.value : "";
if (!hostOrigin || !identifier || !version) {
return;
}
window.parent.postMessage({
type: "pb:install-requested",
identifier: identifier,
slug: slug || null,
version: version,
preRelease: embedPage.dataset.pluginPreRelease === "true"
}, hostOrigin);
}
function buildDetailsUrl(slug) {
const url = new URL("/public/plugins/" + encodeURIComponent(slug), window.location.origin);
const currentUrl = new URL(window.location.href);
["btcpayVersion", "includePreRelease"].forEach(function (param) {
const value = currentUrl.searchParams.get(param);
if (value) {
url.searchParams.set(param, value);
}
});
url.searchParams.set("embed", "1");
return url.toString();
}
function handleHostContext(event) {
if (event.source !== window.parent) {
return;
}
const data = event.data;
if (!data || typeof data !== "object" || data.type !== "btcpay:host-context") {
return;
}
hostOrigin = event.origin;
hiddenPluginIdentifiers = normalizeHiddenPluginIdentifiers(data.hiddenPluginIdentifiers);
applyHostColorMode(data.colorMode);
applyHiddenPluginFilter();
const currentSlug = embedPage.dataset.pluginSlug || "";
const selectedSlug = typeof data.selectedSlug === "string" ? data.selectedSlug : "";
if (embedPage.dataset.embedPage === "list") {
return;
}
if (!selectedSlug || selectedSlug === currentSlug) {
return;
}
window.location.replace(buildDetailsUrl(selectedSlug));
}
postReady();
scheduleHeightPost();
if (embedPage.dataset.embedPage === "details") {
postSelection(embedPage.dataset.pluginSlug || "", embedPage.dataset.pluginIdentifier || "");
}
document.querySelectorAll("img").forEach(function (image) {
image.addEventListener("load", scheduleHeightPost);
image.addEventListener("error", scheduleHeightPost);
});
document.querySelectorAll("a[data-plugin-slug]").forEach(function (link) {
link.addEventListener("click", function (event) {
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey || event.button !== 0) {
return;
}
event.preventDefault();
postSelection(link.dataset.pluginSlug || "", link.dataset.pluginIdentifier || "");
});
});
document.querySelectorAll("[data-plugin-install]").forEach(function (button) {
button.addEventListener("click", function (event) {
event.preventDefault();
postInstallRequest();
});
});
window.addEventListener("message", handleHostContext);
window.addEventListener("resize", scheduleHeightPost);
if (window.ResizeObserver) {
const resizeObserver = new window.ResizeObserver(scheduleHeightPost);
resizeObserver.observe(embedPage);
}
if (document.fonts && document.fonts.ready) {
document.fonts.ready.then(scheduleHeightPost);
}
})();