Compare commits

..

No commits in common. "master" and "ft/request_listing_flow" have entirely different histories.

25 changed files with 76 additions and 1577 deletions

View File

@ -1,388 +0,0 @@
using System.Net.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using PluginBuilder.APIModels;
using PluginBuilder.Services;
using Xunit;
namespace PluginBuilder.Tests;
public class BtcMapsServiceTests
{
private sealed class StubHttpClientFactory : IHttpClientFactory
{
public HttpClient CreateClient(string name) => new HttpClient();
}
private static BtcMapsService MakeService() =>
new BtcMapsService(
configuration: new ConfigurationBuilder().Build(),
httpClientFactory: new StubHttpClientFactory(),
logger: NullLogger<BtcMapsService>.Instance);
private static BtcMapsSubmitRequest MakeValid() => new()
{
Name = "Good Shop",
Url = "https://goodshop.example",
Description = "A very good shop.",
Type = "merchants"
};
[Fact]
public void Validate_AcceptsMinimalDirectoryRequest()
{
Assert.Empty(MakeService().Validate(MakeValid()));
}
[Fact]
public void Validate_RejectsMissingName()
{
var req = MakeValid();
req.Name = null;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Name));
}
[Fact]
public void Validate_RejectsOverlongName()
{
var req = MakeValid();
req.Name = new string('x', 201);
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Name));
}
[Fact]
public void Validate_RejectsNonHttpsUrl()
{
var req = MakeValid();
req.Url = "http://plain.example";
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Url));
}
[Fact]
public void Validate_RejectsMissingDescription()
{
var req = MakeValid();
req.Description = null;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
}
[Fact]
public void Validate_RejectsOverlongDescription()
{
var req = MakeValid();
req.Description = new string('x', 1001);
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
}
[Fact]
public void Validate_RejectsMissingType()
{
var req = MakeValid();
req.Type = null;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Type));
}
[Fact]
public void Validate_RejectsInvalidType()
{
var req = MakeValid();
req.Type = "shops";
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Type));
}
[Fact]
public void Validate_RejectsInvalidMerchantSubType()
{
var req = MakeValid();
req.SubType = "not-a-real-subtype";
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.SubType));
}
[Fact]
public void Validate_AcceptsValidMerchantSubType()
{
var req = MakeValid();
req.SubType = "books";
Assert.Empty(MakeService().Validate(req));
}
[Fact]
public void Validate_AcceptsIsoAlpha2Country()
{
var req = MakeValid();
req.Country = "DE";
Assert.Empty(MakeService().Validate(req));
}
[Fact]
public void Validate_AcceptsGlobalCountry()
{
var req = MakeValid();
req.Country = "GLOBAL";
Assert.Empty(MakeService().Validate(req));
}
[Fact]
public void Validate_RejectsLowerCaseCountry()
{
var req = MakeValid();
req.Country = "de";
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Country));
}
[Fact]
public void Validate_RejectsThreeLetterCountry()
{
var req = MakeValid();
req.Country = "DEU";
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Country));
}
[Fact]
public void Validate_RejectsNonAssignedTwoLetterCountry()
{
// ZZ is reserved / not assigned in ISO 3166-1, so the validator must
// reject it even though it passes the length + casing check.
var req = MakeValid();
req.Country = "ZZ";
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Country));
}
[Fact]
public void Validate_AcceptsOnionHttpsUrl()
{
var req = MakeValid();
req.OnionUrl = "https://abc123.onion";
Assert.Empty(MakeService().Validate(req));
}
[Fact]
public void Validate_AcceptsOnionHttpUrl()
{
// Onion v3 addresses are commonly served over http (Tor provides the transport
// encryption); the validator allows http on a .onion host explicitly.
var req = MakeValid();
req.OnionUrl = "http://abc123.onion";
Assert.Empty(MakeService().Validate(req));
}
[Fact]
public void Validate_RejectsNonOnionOnionUrl()
{
var req = MakeValid();
req.OnionUrl = "https://example.com";
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.OnionUrl));
}
// BTC Map import-RPC fields are mandatory only when SubmitToBtcMap=true. The
// default-false path preserves the directory-only callers untouched.
private static BtcMapsSubmitRequest MakeValidBtcMap()
{
var req = MakeValid();
req.SubmitToBtcMap = true;
req.Lat = 51.5074;
req.Lon = -0.1278;
req.Category = "cafe";
req.ExternalId = "store.example.com:abc123";
return req;
}
[Fact]
public void Validate_AcceptsValidBtcMapSubmission()
{
Assert.Empty(MakeService().Validate(MakeValidBtcMap()));
}
[Fact]
public void Validate_DoesNotRequireBtcMapFieldsByDefault()
{
// Directory-only callers (the pre-existing shape) must not break: a
// request with SubmitToBtcMap unset (default false) and no Lat / Lon /
// Category / ExternalId is still valid.
Assert.Empty(MakeService().Validate(MakeValid()));
}
[Fact]
public void Validate_RejectsMissingLatWhenSubmitToBtcMap()
{
var req = MakeValidBtcMap();
req.Lat = null;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
}
[Fact]
public void Validate_RejectsOutOfRangeLat()
{
var req = MakeValidBtcMap();
req.Lat = 91.0;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
}
[Fact]
public void Validate_RejectsNaNLat()
{
var req = MakeValidBtcMap();
req.Lat = double.NaN;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
}
[Fact]
public void Validate_RejectsMissingLonWhenSubmitToBtcMap()
{
var req = MakeValidBtcMap();
req.Lon = null;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lon));
}
[Fact]
public void Validate_RejectsOutOfRangeLon()
{
var req = MakeValidBtcMap();
req.Lon = -180.5;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lon));
}
[Fact]
public void Validate_RejectsMissingCategoryWhenSubmitToBtcMap()
{
var req = MakeValidBtcMap();
req.Category = null;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
}
[Fact]
public void Validate_RejectsUppercaseCategoryWhenSubmitToBtcMap()
{
// BTC Map docs: "Use a short, single-word (if possible), lowercase identifier."
var req = MakeValidBtcMap();
req.Category = "Cafe";
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
}
[Fact]
public void Validate_RejectsCategoryWithInvalidCharacters()
{
var req = MakeValidBtcMap();
req.Category = "cafe!";
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
}
[Fact]
public void Validate_RejectsMissingExternalIdWhenSubmitToBtcMap()
{
var req = MakeValidBtcMap();
req.ExternalId = null;
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.ExternalId));
}
[Fact]
public void Validate_RejectsOverlongExternalId()
{
var req = MakeValidBtcMap();
req.ExternalId = new string('x', 201);
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.ExternalId));
}
private static BtcMapsService MakeServiceWithConfig(IDictionary<string, string?> config) =>
new BtcMapsService(
configuration: new ConfigurationBuilder().AddInMemoryCollection(config).Build(),
httpClientFactory: new StubHttpClientFactory(),
logger: NullLogger<BtcMapsService>.Instance);
[Fact]
public async System.Threading.Tasks.Task SubmitToBtcMapAsync_RejectsHttpEndpoint()
{
// Bearer token must never cross the wire over plaintext http://. Misconfigured
// endpoint surfaces as InvalidOperationException before HttpClient.SendAsync,
// so the token is never built into a request header.
var service = MakeServiceWithConfig(new Dictionary<string, string?>
{
["BTCMAPS:BtcMapImportToken"] = "test-token",
["BTCMAPS:BtcMapImportEndpoint"] = "http://api.btcmap.org/rpc"
});
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => service.SubmitToBtcMapAsync(MakeValidBtcMap()));
Assert.Contains("https", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async System.Threading.Tasks.Task SubmitToBtcMapAsync_RejectsNonAbsoluteEndpoint()
{
var service = MakeServiceWithConfig(new Dictionary<string, string?>
{
["BTCMAPS:BtcMapImportToken"] = "test-token",
["BTCMAPS:BtcMapImportEndpoint"] = "/rpc"
});
await Assert.ThrowsAsync<InvalidOperationException>(
() => service.SubmitToBtcMapAsync(MakeValidBtcMap()));
}
[Fact]
public async System.Threading.Tasks.Task SubmitToBtcMapAsync_ThrowsTokenMissingWhenUnset()
{
// Token unset is the ops-deployment-pending shape; controller maps this to
// 503 btcmap-not-configured. Test the underlying exception type so the
// controller exception ladder stays wired.
var service = MakeServiceWithConfig(new Dictionary<string, string?>());
await Assert.ThrowsAsync<BtcMapsService.BtcMapTokenMissingException>(
() => service.SubmitToBtcMapAsync(MakeValidBtcMap()));
}
[Fact]
public void NormalizeUrl_LowercasesSchemeAndHostOnly()
{
// Scheme + host are case-insensitive (DNS + RFC); path + query are not, so
// they must be preserved verbatim. Trailing slash is stripped only when the
// path is non-root.
Assert.Equal("https://example.com/", BtcMapsService.NormalizeUrl("HTTPS://Example.com/"));
Assert.Equal("https://example.com/", BtcMapsService.NormalizeUrl(" https://example.com "));
}
[Fact]
public void NormalizeUrl_PreservesPathCase()
{
Assert.Equal("https://example.com/Foo/Bar",
BtcMapsService.NormalizeUrl("HTTPS://Example.com/Foo/Bar/"));
}
[Fact]
public void NormalizeUrl_PreservesQueryCase()
{
Assert.Equal("https://example.com/path?ID=ABC",
BtcMapsService.NormalizeUrl("https://EXAMPLE.com/path?ID=ABC"));
}
[Fact]
public void BuildBranchName_DeterministicForSameUrl()
{
var a = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/foo");
var b = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/foo");
Assert.Equal(a, b);
Assert.StartsWith("btcmaps/good-shop-", a);
}
[Fact]
public void BuildBranchName_DiffersForDifferentUrls()
{
var a = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/foo");
var b = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/bar");
Assert.NotEqual(a, b);
}
[Fact]
public void Slugify_ProducesUrlSafeSegment()
{
Assert.Equal("good-shop", BtcMapsService.Slugify("Good Shop!"));
Assert.Equal("merchant", BtcMapsService.Slugify("!!!"));
}
[Fact]
public void Slugify_CapsLengthAtFortyChars()
{
var input = new string('a', 80);
var slug = BtcMapsService.Slugify(input);
Assert.True(slug.Length <= 40);
}
}

View File

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

View File

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

View File

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

View File

@ -1,22 +0,0 @@
namespace PluginBuilder.APIModels;
public sealed class BtcMapsSubmitResponse
{
public BtcMapsDirectoryResult? Directory { get; set; }
public BtcMapsBtcMapResult? BtcMap { get; set; }
}
public sealed class BtcMapsDirectoryResult
{
public string? PrUrl { get; set; }
public int? PrNumber { get; set; }
public string? Branch { get; set; }
public string? Skipped { get; set; }
}
public sealed class BtcMapsBtcMapResult
{
public long? Id { get; set; }
public string? Origin { get; set; }
public string? ExternalId { get; set; }
}

View File

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

View File

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

View File

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

View File

@ -1203,6 +1203,7 @@ public class AdminController(
SubmittedAt = request.SubmittedAt,
ReviewedAt = request.ReviewedAt,
ReviewedByEmail = reviewedByEmail,
ReviewerFeedback = request.ReviewerFeedback,
RejectionReason = request.RejectionReason,
Owners = ownerVerifications,
PrimaryOwnerEmail = owners.FirstOrDefault(o => o.IsPrimary)?.Email ?? "Unknown"
@ -1266,9 +1267,9 @@ public class AdminController(
if (request == null)
return NotFound();
if (request.Status != PluginListingRequestStatus.Pending)
if (request.Status == PluginListingRequestStatus.Rejected)
{
TempData[TempDataConstant.WarningMessage] = "This request has already been processed";
TempData[TempDataConstant.WarningMessage] = "This request has already been rejected";
return RedirectToAction(nameof(ListingRequestDetail), new { requestId });
}
@ -1287,6 +1288,13 @@ public class AdminController(
return RedirectToAction(nameof(ListingRequestDetail), new { requestId });
}
var existingSettings = await conn.GetSettings(pluginSlug);
var visibilityUpdated = await conn.SetPluginSettings(pluginSlug, existingSettings, PluginVisibilityEnum.Unlisted);
if (!visibilityUpdated)
{
TempData[TempDataConstant.WarningMessage] = "Failed to update plugin visibility";
return RedirectToAction(nameof(ListingRequestDetail), new { requestId });
}
await outputCacheStore.EvictByTagAsync(CacheTags.Plugins, CancellationToken.None);
var pluginOwners = await conn.GetPluginOwners(pluginSlug);
var primaryOwner = pluginOwners.FirstOrDefault(o => o.IsPrimary);
if (primaryOwner != null && !string.IsNullOrEmpty(primaryOwner.Email))

View File

@ -1,109 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using PluginBuilder.APIModels;
using PluginBuilder.Services;
namespace PluginBuilder.Controllers;
[ApiController]
[AllowAnonymous]
[Route("~/apis/btcmaps/v1")]
public sealed class BtcMapsController(
BtcMapsService btcMapsService,
ILogger<BtcMapsController> logger)
: ControllerBase
{
[HttpGet("ping")]
public IActionResult Ping() => Ok(new { ok = true, service = "btcmaps", version = "v1" });
[HttpPost("submit")]
[EnableRateLimiting(Policies.BtcMapsSubmitRateLimit)]
public async Task<IActionResult> Submit(
[FromBody] BtcMapsSubmitRequest? request,
CancellationToken cancellationToken)
{
if (request is null)
return BadRequest(new { errors = new[] { new ValidationError("body", "Request body is required.") } });
var errors = btcMapsService.Validate(request);
if (errors.Count > 0)
return BadRequest(new { errors });
// At least one downstream lane must run; "submit nothing" is almost
// certainly a caller bug (forgot to set the flag) rather than a legit
// intent, and silently 200-ing an empty response would hide it.
if (!request.SubmitToDirectory && !request.SubmitToBtcMap)
{
return BadRequest(new { errors = new[] {
new ValidationError("body", "At least one of SubmitToDirectory or SubmitToBtcMap must be true.")
}});
}
var correlationId = Guid.NewGuid().ToString("N");
BtcMapsDirectoryResult? directory = null;
BtcMapsBtcMapResult? btcMap = null;
if (request.SubmitToDirectory)
{
try
{
directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
}
catch (BtcMapsService.DirectoryTokenMissingException ex)
{
logger.LogError(ex, "BTCMaps directory submission rejected: token not configured (correlationId={CorrelationId})", correlationId);
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "directory-not-configured", correlationId });
}
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
{
logger.LogInformation(ex, "BTCMaps directory submission cancelled by caller (correlationId={CorrelationId})", correlationId);
throw;
}
catch (OperationCanceledException ex)
{
logger.LogError(ex, "BTCMaps directory submission timed out upstream (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status504GatewayTimeout, new { error = "directory-upstream-timeout", correlationId });
}
catch (Exception ex)
{
logger.LogError(ex, "BTCMaps directory submission failed (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status502BadGateway, new { error = "directory-upstream-failed", correlationId });
}
}
if (request.SubmitToBtcMap)
{
try
{
btcMap = await btcMapsService.SubmitToBtcMapAsync(request, cancellationToken);
}
catch (BtcMapsService.BtcMapTokenMissingException ex)
{
logger.LogError(ex, "BTCMaps import submission rejected: token not configured (correlationId={CorrelationId})", correlationId);
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "btcmap-not-configured", correlationId });
}
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
{
logger.LogInformation(ex, "BTCMaps import submission cancelled by caller (correlationId={CorrelationId})", correlationId);
throw;
}
catch (OperationCanceledException ex)
{
logger.LogError(ex, "BTCMaps import submission timed out upstream (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status504GatewayTimeout, new { error = "btcmap-upstream-timeout", correlationId });
}
catch (Exception ex)
{
logger.LogError(ex, "BTCMaps import submission failed (correlationId={CorrelationId}) for {Name} ({Url})",
correlationId, request.Name, request.Url);
return StatusCode(StatusCodes.Status502BadGateway, new { error = "btcmap-upstream-failed", correlationId });
}
}
return Ok(new BtcMapsSubmitResponse { Directory = directory, BtcMap = btcMap });
}
}

View File

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

View File

@ -341,7 +341,8 @@ public class PluginController(
ReleaseNote = r.ReleaseNote,
SubmittedAt = r.SubmittedAt,
ReviewedAt = r.ReviewedAt,
RejectionReason = r.RejectionReason
RejectionReason = r.RejectionReason,
ReviewerFeedback = r.ReviewerFeedback
}).ToList()
};
return View(vm);

View File

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

@ -7,6 +7,7 @@ public class PluginListingRequest
public string ReleaseNote { get; set; } = null!;
public string TelegramVerificationMessage { get; set; } = null!;
public string UserReviews { get; set; } = null!;
public string? ReviewerFeedback { get; set; }
public DateTimeOffset? AnnouncementDate { get; set; }
public PluginListingRequestStatus Status { get; set; }
public DateTimeOffset SubmittedAt { get; set; }
@ -30,6 +31,7 @@ public class ListingHistoryItemViewModel
public DateTimeOffset SubmittedAt { get; set; }
public DateTimeOffset? ReviewedAt { get; set; }
public string? RejectionReason { get; set; }
public string? ReviewerFeedback { get; set; }
}
public enum PluginListingRequestStatus

View File

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

View File

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

View File

@ -17,7 +17,6 @@ using PluginBuilder.Authentication;
using PluginBuilder.Configuration;
using PluginBuilder.Controllers.Logic;
using PluginBuilder.DataModels;
using PluginBuilder.Filters;
using PluginBuilder.HostedServices;
using PluginBuilder.Hubs;
using PluginBuilder.Services;
@ -144,10 +143,7 @@ public class Program
public void AddServices(IConfiguration configuration, IServiceCollection services, IHostEnvironment env)
{
services.AddControllersWithViews(options =>
{
options.Filters.Add(new UIControllerAntiforgeryTokenAttribute());
})
services.AddControllersWithViews()
.AddRazorRuntimeCompilation()
.AddRazorOptions(options =>
{
@ -211,28 +207,6 @@ public class Program
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", token);
});
services.AddHttpClient(HttpClientNames.BtcMapsDirectory, client =>
{
// Per-call timeout caps a single GitHub round-trip at 15s. The directory
// submission makes ~5-7 GitHub calls sequentially; with the default 100s
// timeout a hung remote could pin the request for ~10min and tie up a
// rate-limit slot. 15s per call keeps the worst case bounded.
client.BaseAddress = new Uri("https://api.github.com/");
client.DefaultRequestHeaders.Add("User-Agent", "PluginBuilder-BtcMaps/1.0");
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
client.Timeout = TimeSpan.FromSeconds(15);
});
services.AddHttpClient(HttpClientNames.BtcMap, client =>
{
// BTC Map import RPC is a single JSON-RPC 2.0 dispatch endpoint.
// Per-call timeout caps a single round-trip at 15s, matching the
// BtcMapsDirectory budget so a hung remote can't pin the request
// longer than the per-IP rate-limit window.
client.DefaultRequestHeaders.Add("User-Agent", "PluginBuilder-BtcMap/1.0");
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
client.Timeout = TimeSpan.FromSeconds(15);
});
services.AddHttpClient(HttpClientNames.GitLab, client =>
{
client.BaseAddress = new Uri("https://gitlab.com/api/v4/");
@ -263,7 +237,6 @@ public class Program
});
services.AddScoped<PluginOwnershipService>();
services.AddScoped<VersionLifecycleService>();
services.AddSingleton<BtcMapsService>();
services.AddRateLimiter(options =>
{
@ -289,23 +262,6 @@ public class Program
QueueLimit = 0
});
});
options.AddPolicy(Policies.BtcMapsSubmitRateLimit, httpContext =>
{
// Per-source-IP fixed window: 3 submissions per 24h. Caps automation
// abuse of /apis/btcmaps/v1/submit without throttling honest single
// submissions from a merchant. Tightened from 5/24h with the
// multi-vendor BTC Map import-RPC lane (PR #226) since that path
// forwards into a moderator review queue and rate-limit is the
// primary spam control on the public endpoint.
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
return RateLimitPartition.GetFixedWindowLimiter(clientIp, _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 3,
Window = TimeSpan.FromHours(24),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0
});
});
});
services.AddOutputCache(options =>

View File

@ -1,668 +0,0 @@
using System.Globalization;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using Microsoft.Extensions.Configuration;
using PluginBuilder.APIModels;
using PluginBuilder.DataModels;
namespace PluginBuilder.Services;
public sealed class BtcMapsService
{
private const string DefaultDirectoryRepo = "btcpayserver/directory.btcpayserver.org";
private const string DefaultDirectoryMerchantsPath = "src/data/merchants.json";
private static readonly HashSet<string> ValidTypes = new(StringComparer.OrdinalIgnoreCase)
{
"merchants", "apps", "hosted-btcpay", "non-profits"
};
private static readonly HashSet<string> ValidMerchantSubTypes = new(StringComparer.OrdinalIgnoreCase)
{
"3d-printing", "adult", "appliances-furniture", "art", "books",
"cryptocurrency-paraphernalia", "domains-hosting-vpns", "education",
"electronics", "fashion", "food", "gambling", "gift-cards",
"health-household", "holiday-travel", "jewelry", "payment-services",
"pets", "services", "software-video-games", "sports", "tools"
};
// ISO 3166-1 alpha-2 codes derived from CultureInfo at startup. Cached because
// CultureInfo.GetCultures + RegionInfo enumeration is non-trivial and the set
// is stable for the process lifetime.
private static readonly HashSet<string> Iso3166Alpha2 = BuildIsoAlpha2Set();
private static HashSet<string> BuildIsoAlpha2Set()
{
var set = new HashSet<string>(StringComparer.Ordinal);
foreach (var culture in CultureInfo.GetCultures(CultureTypes.SpecificCultures))
{
try
{
var region = new RegionInfo(culture.Name);
if (region.TwoLetterISORegionName.Length == 2 &&
region.TwoLetterISORegionName.All(c => c is >= 'A' and <= 'Z'))
{
set.Add(region.TwoLetterISORegionName);
}
}
catch (ArgumentException)
{
// Some neutral cultures throw; skip.
}
}
return set;
}
private readonly IConfiguration _configuration;
private readonly IHttpClientFactory _httpClientFactory;
private readonly ILogger<BtcMapsService> _logger;
public BtcMapsService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
ILogger<BtcMapsService> logger)
{
_configuration = configuration;
_httpClientFactory = httpClientFactory;
_logger = logger;
}
public sealed class DirectoryTokenMissingException : Exception
{
public DirectoryTokenMissingException() : base("BTCMAPS:DirectoryGithubToken is not configured.") { }
}
public sealed class BtcMapTokenMissingException : Exception
{
public BtcMapTokenMissingException() : base("BTCMAPS:BtcMapImportToken is not configured.") { }
}
private const string DefaultBtcMapImportEndpoint = "https://api.btcmap.org/rpc";
private const string BtcMapImportOrigin = "btcpayserver";
private const string BtcMapImportMethod = "submit_place";
public IReadOnlyList<ValidationError> Validate(BtcMapsSubmitRequest request)
{
var errors = new List<ValidationError>();
// Name is the only field both lanes need - btcmap submit_place requires it
// as part of the params payload, and the directory merchants.json keys
// entries by name.
var name = (request.Name ?? string.Empty).Trim();
if (string.IsNullOrEmpty(name) || name.Length > 200)
errors.Add(new ValidationError(nameof(request.Name), "Required, 1-200 characters."));
// Directory-lane required fields. Skip when the caller opted out of the
// directory submission so a btcmap-only call doesn't need to fill in
// unrelated directory metadata.
if (request.SubmitToDirectory)
{
var url = (request.Url ?? string.Empty).Trim();
if (string.IsNullOrEmpty(url))
errors.Add(new ValidationError(nameof(request.Url), "Required when SubmitToDirectory=true."));
else if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed) || parsed.Scheme != Uri.UriSchemeHttps)
errors.Add(new ValidationError(nameof(request.Url), "Must be a valid https:// URL."));
var description = (request.Description ?? string.Empty).Trim();
if (string.IsNullOrEmpty(description) || description.Length > 1000)
errors.Add(new ValidationError(nameof(request.Description), "Required when SubmitToDirectory=true. 1-1000 characters."));
var type = (request.Type ?? string.Empty).Trim();
if (string.IsNullOrEmpty(type) || !ValidTypes.Contains(type))
errors.Add(new ValidationError(nameof(request.Type),
$"Required when SubmitToDirectory=true. One of: {string.Join(", ", ValidTypes)}."));
if (!string.IsNullOrWhiteSpace(request.SubType))
{
var subType = request.SubType.Trim();
if (string.Equals(type, "merchants", StringComparison.OrdinalIgnoreCase) &&
!ValidMerchantSubTypes.Contains(subType))
{
errors.Add(new ValidationError(nameof(request.SubType),
"Invalid merchant subtype."));
}
}
}
// Country format check applies to either lane. GLOBAL is the directory's
// pseudonym for online-only / multi-region merchants but it is NOT a valid
// physical-place country code for the btcmap directory map (every place
// there is geocoded). Reject GLOBAL when the btcmap lane is on; allow it
// when only the directory lane is in play.
if (!string.IsNullOrWhiteSpace(request.Country))
{
var country = request.Country.Trim();
if (country == "GLOBAL")
{
if (request.SubmitToBtcMap)
{
errors.Add(new ValidationError(nameof(request.Country),
"Country=GLOBAL is incompatible with SubmitToBtcMap=true (btcmap places are physical locations). Use an ISO 3166-1 alpha-2 code or omit Country."));
}
}
else if (!Iso3166Alpha2.Contains(country))
{
errors.Add(new ValidationError(nameof(request.Country),
"Must be ISO 3166-1 alpha-2 or GLOBAL."));
}
}
if (!string.IsNullOrWhiteSpace(request.OnionUrl))
{
if (!Uri.TryCreate(request.OnionUrl.Trim(), UriKind.Absolute, out var onionUri) ||
(onionUri.Scheme != Uri.UriSchemeHttp && onionUri.Scheme != Uri.UriSchemeHttps) ||
!onionUri.Host.EndsWith(".onion", StringComparison.OrdinalIgnoreCase))
{
errors.Add(new ValidationError(nameof(request.OnionUrl),
"Must be an http(s) .onion URL."));
}
}
// BTC Map import RPC fields become mandatory only when the caller
// opts into that lane via SubmitToBtcMap=true. Directory-only callers
// (the existing PR #224 shape) are unaffected.
if (request.SubmitToBtcMap)
{
if (!request.Lat.HasValue || request.Lat.Value is < -90 or > 90 || double.IsNaN(request.Lat.Value))
errors.Add(new ValidationError(nameof(request.Lat),
"Required when SubmitToBtcMap=true. Must be in [-90, 90]."));
if (!request.Lon.HasValue || request.Lon.Value is < -180 or > 180 || double.IsNaN(request.Lon.Value))
errors.Add(new ValidationError(nameof(request.Lon),
"Required when SubmitToBtcMap=true. Must be in [-180, 180]."));
var category = (request.Category ?? string.Empty).Trim();
if (string.IsNullOrEmpty(category) || category.Length > 50 ||
!category.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-' or '_'))
{
errors.Add(new ValidationError(nameof(request.Category),
"Required when SubmitToBtcMap=true. Short lowercase identifier (a-z, 0-9, -, _; max 50 chars)."));
}
var externalId = (request.ExternalId ?? string.Empty).Trim();
if (string.IsNullOrEmpty(externalId) || externalId.Length > 200)
errors.Add(new ValidationError(nameof(request.ExternalId),
"Required when SubmitToBtcMap=true. 1-200 characters."));
}
return errors;
}
public async Task<BtcMapsBtcMapResult> SubmitToBtcMapAsync(
BtcMapsSubmitRequest request,
CancellationToken cancellationToken = default)
{
var token = _configuration["BTCMAPS:BtcMapImportToken"];
if (string.IsNullOrWhiteSpace(token))
throw new BtcMapTokenMissingException();
// Bearer tokens MUST NOT cross the wire over http://; an operator-
// misconfigured endpoint would silently leak the scoped token to
// anyone on the network path. Parse the configured value into an
// absolute https URI before we even create the request, so a bad
// BTCMAPS:BtcMapImportEndpoint fails loudly with the offending
// value in the message instead of producing a quiet credential leak.
var endpoint = (_configuration["BTCMAPS:BtcMapImportEndpoint"] ?? DefaultBtcMapImportEndpoint).Trim();
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) ||
endpointUri.Scheme != Uri.UriSchemeHttps)
{
throw new InvalidOperationException(
$"BTCMAPS:BtcMapImportEndpoint must be an absolute https:// URL (got '{endpoint}').");
}
var client = _httpClientFactory.CreateClient(HttpClientNames.BtcMap);
// BTC Map import-RPC takes a JSON-RPC 2.0 envelope at /rpc with method=submit_place.
// Required params: origin, external_id, lat, lon, category, name. extra_fields uses
// the documented first-class keys (phone, website, description) and the osm:<tag_name>
// convention for granular OSM tags (osm:addr:*).
var extraFields = new Dictionary<string, object?>();
// First-class btcmap fields (plain keys per rest/v4/places.md).
if (!string.IsNullOrWhiteSpace(request.Url)) extraFields["website"] = request.Url.Trim();
if (!string.IsNullOrWhiteSpace(request.Description)) extraFields["description"] = request.Description.Trim();
if (!string.IsNullOrWhiteSpace(request.Phone)) extraFields["phone"] = request.Phone.Trim();
if (!string.IsNullOrWhiteSpace(request.Email)) extraFields["email"] = request.Email.Trim();
if (!string.IsNullOrWhiteSpace(request.Twitter))
{
// btcmap's `twitter` field is documented as a URL. Normalize the @handle
// shape the rest of the API accepts (with or without leading @) into
// the URL form the directory map expects.
var t = request.Twitter.Trim();
var handle = t.StartsWith("@") ? t[1..] : t;
extraFields["twitter"] = handle.StartsWith("http", StringComparison.OrdinalIgnoreCase)
? handle
: $"https://x.com/{handle}";
}
// OSM custom tags use osm:<tag_name> per the same doc.
if (!string.IsNullOrWhiteSpace(request.HouseNumber)) extraFields["osm:addr:housenumber"] = request.HouseNumber.Trim();
if (!string.IsNullOrWhiteSpace(request.Street)) extraFields["osm:addr:street"] = request.Street.Trim();
if (!string.IsNullOrWhiteSpace(request.City)) extraFields["osm:addr:city"] = request.City.Trim();
if (!string.IsNullOrWhiteSpace(request.Postcode)) extraFields["osm:addr:postcode"] = request.Postcode.Trim();
if (!string.IsNullOrWhiteSpace(request.Country)) extraFields["osm:addr:country"] = request.Country.Trim();
// Payment-rail flags. Plugin sets per the store's enabled rails - omit
// when null or false so a Lightning-only store doesn't claim on-chain
// support (or vice versa).
if (request.AcceptsOnchain == true) extraFields["osm:payment:onchain"] = "yes";
if (request.AcceptsLightning == true) extraFields["osm:payment:lightning"] = "yes";
var rpcParams = new Dictionary<string, object?>
{
["origin"] = BtcMapImportOrigin,
["external_id"] = request.ExternalId!.Trim(),
["lat"] = request.Lat!.Value,
["lon"] = request.Lon!.Value,
["category"] = request.Category!.Trim().ToLowerInvariant(),
["name"] = request.Name!.Trim(),
["extra_fields"] = extraFields
};
var envelope = new Dictionary<string, object?>
{
["jsonrpc"] = "2.0",
["method"] = BtcMapImportMethod,
["params"] = rpcParams,
["id"] = 1
};
using var req = new HttpRequestMessage(HttpMethod.Post, endpointUri);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Content = new StringContent(JsonSerializer.Serialize(envelope), Encoding.UTF8, "application/json");
using var response = await client.SendAsync(req, cancellationToken);
var body = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
throw new HttpRequestException($"BtcMap RPC {(int)response.StatusCode} {endpointUri}: {body}");
using var doc = JsonDocument.Parse(body);
var root = doc.RootElement;
// JSON-RPC 2.0 response shape: either {result: {...}} on success or {error: {...}}.
// 2xx status with an error body is a legal JSON-RPC outcome we must surface.
if (root.TryGetProperty("error", out var errorElement))
{
var errorJson = errorElement.GetRawText();
throw new HttpRequestException($"BtcMap RPC error response: {errorJson}");
}
if (!root.TryGetProperty("result", out var result))
throw new HttpRequestException($"BtcMap RPC missing result: {body}");
return new BtcMapsBtcMapResult
{
Id = result.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.Number ? id.GetInt64() : null,
Origin = result.TryGetProperty("origin", out var origin) ? origin.GetString() : null,
ExternalId = result.TryGetProperty("external_id", out var ext) ? ext.GetString() : null
};
}
public async Task<BtcMapsDirectoryResult> SubmitToDirectoryAsync(
BtcMapsSubmitRequest request,
CancellationToken cancellationToken = default)
{
var token = _configuration["BTCMAPS:DirectoryGithubToken"];
if (string.IsNullOrWhiteSpace(token))
throw new DirectoryTokenMissingException();
var repo = _configuration["BTCMAPS:DirectoryRepo"] ?? DefaultDirectoryRepo;
var merchantsPath = _configuration["BTCMAPS:DirectoryMerchantsPath"] ?? DefaultDirectoryMerchantsPath;
var client = _httpClientFactory.CreateClient(HttpClientNames.BtcMapsDirectory);
// Auth is per-call: the named-client registration sets BaseAddress + User-Agent
// + Accept + Timeout, but the BTCMAPS token is distinct from the global
// PluginBuilder GitHub token and must not be baked into the singleton handler.
using var authClient = new HttpRequestAuth(client, token);
var repoInfo = await GetJsonAsync(authClient, $"repos/{repo}", cancellationToken);
var defaultBranch = repoInfo.GetProperty("default_branch").GetString()
?? throw new InvalidOperationException("default_branch missing");
var fileInfo = await GetJsonAsync(
authClient,
$"repos/{repo}/contents/{merchantsPath}?ref={Uri.EscapeDataString(defaultBranch)}",
cancellationToken);
var contentB64 = fileInfo.GetProperty("content").GetString() ?? string.Empty;
var fileSha = fileInfo.GetProperty("sha").GetString() ?? string.Empty;
var currentJson = Encoding.UTF8.GetString(Convert.FromBase64String(contentB64.Replace("\n", string.Empty)));
var merchants = JsonSerializer.Deserialize<List<JsonElement>>(currentJson)
?? throw new InvalidOperationException("merchants.json must be a JSON array");
var normalizedUrl = NormalizeUrl(request.Url!);
foreach (var existing in merchants)
{
if (existing.TryGetProperty("url", out var existingUrl) &&
existingUrl.ValueKind == JsonValueKind.String &&
NormalizeUrl(existingUrl.GetString() ?? string.Empty) == normalizedUrl)
{
var existingName = existing.TryGetProperty("name", out var n) ? n.GetString() : "(unknown)";
return new BtcMapsDirectoryResult { Skipped = $"duplicate-url:{existingName}" };
}
}
// Deterministic branch name derived from the normalized URL. Two concurrent
// submissions of the same URL collide on the git/refs create instead of
// racing through preflight and opening duplicate PRs.
var branchName = BuildBranchName(request.Name!, normalizedUrl);
var marker = BuildUrlMarker(normalizedUrl);
var branchRef = await GetJsonAsync(
authClient,
$"repos/{repo}/git/ref/heads/{Uri.EscapeDataString(defaultBranch)}",
cancellationToken);
var baseSha = branchRef.GetProperty("object").GetProperty("sha").GetString()
?? throw new InvalidOperationException("base sha missing");
var newEntry = BuildMerchantEntry(request);
var updated = merchants
.Select(e => (JsonElement?)e)
.Append(newEntry)
.OrderBy(e => e!.Value.TryGetProperty("name", out var n) ? n.GetString() : string.Empty,
StringComparer.OrdinalIgnoreCase)
.Select(e => e!.Value)
.ToList();
// Use UnsafeRelaxedJsonEscaping so non-ASCII codepoints and HTML-only "unsafe"
// chars (`&`, `'`, `<`, `>`) are written raw in the file, matching the upstream
// merchants.json convention. The default JavaScriptEncoder is HTML-safe and
// would re-encode every entry containing `'` or non-ASCII as `\uXXXX`, which
// shows up as a noisy full-file diff on every append.
var updatedJson = JsonSerializer.Serialize(updated, new JsonSerializerOptions
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
}) + "\n";
var refCreateResponse = await PostJsonAllowConflictAsync(
authClient,
$"repos/{repo}/git/refs",
new { @ref = $"refs/heads/{branchName}", sha = baseSha },
cancellationToken);
if (refCreateResponse.IsConflict)
{
// Branch already exists. Look up the open PR keyed by the URL marker;
// if one is open, return its details; otherwise this is a stuck-branch
// from a prior failed run and we cannot safely reuse it.
var openPrSearch = await GetJsonAsync(
authClient,
$"search/issues?q={Uri.EscapeDataString($"repo:{repo} is:pr is:open in:body \"{marker}\"")}",
cancellationToken);
if (openPrSearch.TryGetProperty("total_count", out var totalCount) && totalCount.GetInt32() > 0)
{
var firstItem = openPrSearch.GetProperty("items")[0];
return new BtcMapsDirectoryResult
{
Skipped = "duplicate-open-pr",
PrUrl = firstItem.TryGetProperty("html_url", out var h) ? h.GetString() : null,
PrNumber = firstItem.TryGetProperty("number", out var n) ? n.GetInt32() : null
};
}
return new BtcMapsDirectoryResult { Skipped = "branch-exists-no-open-pr", Branch = branchName };
}
await PutJsonAsync(authClient, $"repos/{repo}/contents/{merchantsPath}",
new
{
message = $"Add {request.Name}",
content = Convert.ToBase64String(Encoding.UTF8.GetBytes(updatedJson)),
sha = fileSha,
branch = branchName
}, cancellationToken);
var prBody = BuildPrBody(request, marker);
var prResponse = await PostJsonAsync(authClient, $"repos/{repo}/pulls",
new
{
title = $"Add {request.Name}",
head = branchName,
@base = defaultBranch,
body = prBody
}, cancellationToken);
return new BtcMapsDirectoryResult
{
PrUrl = prResponse.GetProperty("html_url").GetString(),
PrNumber = prResponse.GetProperty("number").GetInt32(),
Branch = branchName
};
}
private static JsonElement BuildMerchantEntry(BtcMapsSubmitRequest request)
{
using var ms = new MemoryStream();
using (var w = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = false }))
{
w.WriteStartObject();
w.WriteString("name", request.Name!.Trim());
w.WriteString("url", request.Url!.Trim());
w.WriteString("description", request.Description!.Trim());
// type + subType are validated case-insensitively above
// (ValidTypes / ValidMerchantSubTypes use OrdinalIgnoreCase) but
// the upstream merchants.json convention is lowercase. Normalize
// on write so a submission of "Merchants" / "Books" lands as
// "merchants" / "books" in the file. Country uses a case-sensitive
// Ordinal set so the validator already rejects non-uppercase.
w.WriteString("type", request.Type!.Trim().ToLowerInvariant());
if (!string.IsNullOrWhiteSpace(request.SubType))
w.WriteString("subType", request.SubType.Trim().ToLowerInvariant());
if (!string.IsNullOrWhiteSpace(request.Country))
w.WriteString("country", request.Country.Trim());
if (!string.IsNullOrWhiteSpace(request.Twitter))
{
var t = request.Twitter.Trim();
w.WriteString("twitter", t.StartsWith("@") ? t : "@" + t);
}
if (!string.IsNullOrWhiteSpace(request.Github))
w.WriteString("github", request.Github.Trim());
if (!string.IsNullOrWhiteSpace(request.OnionUrl))
w.WriteString("onionUrl", request.OnionUrl.Trim());
w.WriteEndObject();
}
ms.Position = 0;
using var doc = JsonDocument.Parse(ms);
return doc.RootElement.Clone();
}
private static string BuildPrBody(BtcMapsSubmitRequest request, string urlMarker)
{
// User-supplied fields are wrapped in inline code spans so a doctored merchant
// name (e.g. `[click here](https://attacker.example)`) can't render as a clickable
// link in the maintainer-facing PR description. The URL is its own line and is
// displayed via a plain markdown link with a sanitized label so the maintainer
// sees the bare URL, not a renamed target.
var sb = new StringBuilder();
sb.AppendLine("Automated submission from the BTCPay Server plugin-builder `/apis/btcmaps/v1/submit` endpoint.");
sb.AppendLine();
sb.AppendLine($"- **Name:** {EscapeInlineCode(request.Name)}");
sb.AppendLine($"- **URL:** <{request.Url}>");
sb.AppendLine($"- **Type:** {EscapeInlineCode(request.Type)}{(string.IsNullOrWhiteSpace(request.SubType) ? string.Empty : " / " + EscapeInlineCode(request.SubType))}");
if (!string.IsNullOrWhiteSpace(request.Country)) sb.AppendLine($"- **Country:** {EscapeInlineCode(request.Country.Trim())}");
if (!string.IsNullOrWhiteSpace(request.Twitter))
{
// The Twitter handle is rendered inside an inline code span so a hostile
// value like `]( <evil-url> )` cannot escape into an active link. The
// maintainer can copy the handle and visit manually.
var raw = request.Twitter.Trim();
var handle = raw.StartsWith("@") ? raw[1..] : raw;
sb.AppendLine($"- **Twitter:** {EscapeInlineCode("@" + handle)}");
}
if (!string.IsNullOrWhiteSpace(request.Github)) sb.AppendLine($"- **GitHub:** {EscapeInlineCode(request.Github)}");
sb.AppendLine();
sb.AppendLine("**Description:**");
sb.AppendLine();
sb.AppendLine("```");
sb.AppendLine(request.Description?.Replace("```", "```") ?? string.Empty);
sb.AppendLine("```");
sb.AppendLine();
sb.AppendLine("_Please review before merge - this PR was opened programmatically by a BTCMap-plugin merchant submission, not by a maintainer._");
sb.AppendLine();
sb.AppendLine($"<!-- {urlMarker} -->");
return sb.ToString();
}
// Wrap user input as an inline code span. Use enough backticks to escape any
// backticks the input contains (CommonMark inline-code-fence rule).
private static string EscapeInlineCode(string? value)
{
var s = value ?? string.Empty;
if (s.Length == 0) return "``";
var longestRun = 0;
var current = 0;
foreach (var c in s)
{
if (c == '`') { current++; if (current > longestRun) longestRun = current; }
else current = 0;
}
var fence = new string('`', longestRun + 1);
// Pad with spaces if the value starts or ends with a backtick (CommonMark rule).
var needsPad = s.StartsWith("`") || s.EndsWith("`");
return needsPad ? $"{fence} {s} {fence}" : $"{fence}{s}{fence}";
}
private static string BuildUrlMarker(string normalizedUrl) =>
$"btcmaps-submit:url={normalizedUrl}";
public static string NormalizeUrl(string url)
{
// Normalize for duplicate detection without lying about case-sensitive parts.
// Scheme + host get lower-cased (DNS is case-insensitive, scheme is too); path
// and query are preserved as-is. Trailing slash is trimmed only when the path
// is the bare root, since /foo/ and /foo are sometimes distinct on real servers.
var trimmed = (url ?? string.Empty).Trim();
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var parsed))
return trimmed.TrimEnd('/');
var sb = new StringBuilder();
sb.Append(parsed.Scheme.ToLowerInvariant());
sb.Append("://");
sb.Append(parsed.Host.ToLowerInvariant());
if (!parsed.IsDefaultPort)
{
sb.Append(':');
sb.Append(parsed.Port);
}
var path = parsed.AbsolutePath;
if (path == "/")
sb.Append(path);
else
sb.Append(path.TrimEnd('/'));
if (!string.IsNullOrEmpty(parsed.Query))
sb.Append(parsed.Query);
return sb.ToString();
}
// Deterministic branch name from the normalized URL. Same URL always produces
// the same branch; second concurrent submission collides on the git/refs create
// and the controller surfaces the duplicate-open-PR shape.
public static string BuildBranchName(string name, string normalizedUrl)
{
var slug = Slugify(name);
var hash = SHA1.HashData(Encoding.UTF8.GetBytes(normalizedUrl));
var suffix = Convert.ToHexString(hash)[..8].ToLowerInvariant();
return $"btcmaps/{slug}-{suffix}";
}
public static string Slugify(string input)
{
var chars = new StringBuilder();
var lastWasDash = true;
foreach (var c in input.ToLowerInvariant())
{
if (c is >= 'a' and <= 'z' or >= '0' and <= '9')
{
chars.Append(c);
lastWasDash = false;
}
else if (!lastWasDash)
{
chars.Append('-');
lastWasDash = true;
}
}
var result = chars.ToString().Trim('-');
if (result.Length > 40) result = result[..40].TrimEnd('-');
return result.Length == 0 ? "merchant" : result;
}
// Lightweight wrapper that re-attaches the per-request Authorization header on
// each call. The named-client handler is reused (socket pool, factory rotation),
// but auth stays out of the singleton handler.
private sealed class HttpRequestAuth : IDisposable
{
public HttpClient Client { get; }
public string Token { get; }
public HttpRequestAuth(HttpClient client, string token) { Client = client; Token = token; }
public void Dispose() { /* HttpClient is owned by IHttpClientFactory; do not dispose */ }
}
private static HttpRequestMessage NewRequest(HttpMethod method, string path, string token)
{
var req = new HttpRequestMessage(method, path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
return req;
}
private static async Task<JsonElement> GetJsonAsync(HttpRequestAuth auth, string path, CancellationToken ct)
{
using var req = NewRequest(HttpMethod.Get, path, auth.Token);
using var response = await auth.Client.SendAsync(req, ct);
await EnsureSuccess(response, path, ct);
var text = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
return doc.RootElement.Clone();
}
private static async Task<JsonElement> PostJsonAsync(HttpRequestAuth auth, string path, object body, CancellationToken ct)
{
using var req = NewRequest(HttpMethod.Post, path, auth.Token);
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
using var response = await auth.Client.SendAsync(req, ct);
await EnsureSuccess(response, path, ct);
var text = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
return doc.RootElement.Clone();
}
private readonly record struct ConflictAware(JsonElement? Body, bool IsConflict);
private static async Task<ConflictAware> PostJsonAllowConflictAsync(HttpRequestAuth auth, string path, object body, CancellationToken ct)
{
using var req = NewRequest(HttpMethod.Post, path, auth.Token);
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
using var response = await auth.Client.SendAsync(req, ct);
if (response.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity)
{
// GitHub returns 422 "Reference already exists" when the branch ref is
// pre-claimed by a concurrent or earlier submission. That's the idempotency
// signal we want; surface it.
var conflictText = await response.Content.ReadAsStringAsync(ct);
if (conflictText.Contains("Reference already exists", StringComparison.OrdinalIgnoreCase))
return new ConflictAware(null, true);
throw new HttpRequestException($"GitHub {(int)response.StatusCode} {path}: {conflictText}");
}
await EnsureSuccess(response, path, ct);
var text = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
return new ConflictAware(doc.RootElement.Clone(), false);
}
private static async Task<JsonElement> PutJsonAsync(HttpRequestAuth auth, string path, object body, CancellationToken ct)
{
using var req = NewRequest(HttpMethod.Put, path, auth.Token);
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
using var response = await auth.Client.SendAsync(req, ct);
await EnsureSuccess(response, path, ct);
var text = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(text);
return doc.RootElement.Clone();
}
private static async Task EnsureSuccess(HttpResponseMessage response, string path, CancellationToken ct)
{
if (response.IsSuccessStatusCode) return;
var body = await response.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"GitHub {(int)response.StatusCode} {path}: {body}");
}
}

View File

@ -38,6 +38,19 @@ Reason:
You may update your submission and request a another review at any time.
Thank you,
BTCPay Server Plugin Builder Team
";
public const string ReviewerFeedbackTemplate = @"
Hello Plugin Owner,
Your plugin ""{0}"" has been reviewed and the reviewer has left the following feedback:
{1}
Please address the feedback and update your submission. Your listing request remains under review.
Thank you,
BTCPay Server Plugin Builder Team
";
@ -171,6 +184,25 @@ BTCPay Server Plugin Builder";
}
}
public async Task NotifyPluginOwnerOfReviewerFeedback(string email, string pluginTitle, string feedback)
{
var subject = $"Feedback on your plugin {pluginTitle}";
var body = string.Format(ReviewerFeedbackTemplate, pluginTitle, feedback);
try
{
await DeliverEmail(new[] { MailboxAddressValidator.Parse(email) }, subject, body);
}
catch (InvalidOperationException)
{
logger.LogInformation("Email settings not configured. Plugin owner {Email} will not receive feedback email", email);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to send feedback email to {Email}", email);
}
}
public async Task<EmailSettingsViewModel?> GetEmailSettingsFromDb()
{
await using var conn = await connectionFactory.Open();

View File

@ -821,7 +821,7 @@ public static class NpgsqlConnectionExtensions
const string sql = """
UPDATE plugin_listing_requests
SET status = 'rejected', reviewed_at = CURRENT_TIMESTAMP, reviewed_by = @reviewedBy, rejection_reason = @rejectionReason
WHERE id = @requestId AND status = 'pending'
WHERE id = @requestId AND status IN ('pending', 'approved')
""";
var affected = await connection.ExecuteAsync(sql, new { requestId, reviewedBy, rejectionReason });

View File

@ -37,6 +37,7 @@ public class ListingRequestDetailViewModel
public DateTimeOffset? ReviewedAt { get; set; }
public string? ReviewedByEmail { get; set; }
public string? RejectionReason { get; set; }
public string? ReviewerFeedback { get; set; }
public List<OwnerVerificationViewModel> Owners { get; set; } = new();
public string PrimaryOwnerEmail { get; set; } = null!;
}

View File

@ -93,7 +93,14 @@
</div>
<h5 class="fw-bold mb-4">Listing Request Details</h5>
@if (!string.IsNullOrEmpty(Model.RejectionReason))
@if (!string.IsNullOrEmpty(Model.ReviewerFeedback))
{
<div class="mb-4">
<div class="text-muted fw-semibold small text-uppercase mb-2" style="letter-spacing:.05em;">Reviewer Feedback</div>
<p class="mb-0" style="white-space: pre-wrap;">@Model.ReviewerFeedback</p>
</div>
}
@if (!string.IsNullOrEmpty(Model.RejectionReason))
{
<div class="mb-4">
<div class="text-muted fw-semibold small text-uppercase mb-2" style="letter-spacing:.05em;">Rejection Reason</div>

View File

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

View File

@ -2,7 +2,7 @@
@model ListingHistoryViewModel
@{
Layout = "_Layout";
ViewData.SetActivePage(PluginNavPages.RequestListing, "Listing History");
ViewData.SetActivePage(PluginNavPages.Dashboard, "Listing History");
ViewData["Title"] = "Listing History";
}
<div class="d-flex align-items-center justify-content-between mb-4">
@ -24,6 +24,7 @@ else
<tr>
<th>Submitted</th>
<th>Release Note</th>
<th>Reviewer Feedback</th>
<th>Rejection Reason</th>
<th>Reviewed</th>
<th>Status</th>
@ -35,6 +36,16 @@ else
<tr>
<td class="text-nowrap small">@request.SubmittedAt.UtcDateTime.ToString("MMM dd, yyyy · HH:mm UTC")</td>
<td>@request.ReleaseNote</td>
<td>
@if (!string.IsNullOrEmpty(request.ReviewerFeedback))
{
<span style="white-space: pre-wrap;">@request.ReviewerFeedback</span>
}
else
{
<span class="text-muted">—</span>
}
</td>
<td>
@if (!string.IsNullOrEmpty(request.RejectionReason))
{

View File

@ -1,7 +1,7 @@
@model RequestListingViewModel
@{
Layout = "_Layout";
ViewData.SetActivePage(PluginNavPages.RequestListing, "Plugin Listing Request");
ViewData.SetActivePage(PluginNavPages.Dashboard, "Plugin Listing Request");
var step3Completed = Model.PendingListing;
var step1Completed = Model.Step != RequestListingViewModel.State.UpdatePluginSettings;
var step2Completed = Model.Step != RequestListingViewModel.State.UpdateOwnerAccountSettings && Model.Step != RequestListingViewModel.State.UpdatePluginSettings;