Compare commits
37 Commits
ft/plugin_
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec4359ec1c | ||
|
|
62d160a29b | ||
|
|
2119281141 | ||
|
|
cac72c5682 | ||
|
|
034b272882 | ||
|
|
90bd954a47 | ||
|
|
373c1cbd6a | ||
|
|
edd7e8d2ed | ||
|
|
5faf9ce9c0 | ||
|
|
717f55d2a6 | ||
|
|
3836fe6b38 | ||
|
|
d34405970b | ||
|
|
8ffea888af | ||
|
|
19d6c58325 | ||
|
|
474264419a | ||
|
|
3d4068aa43 | ||
|
|
ad5bb7bdc3 | ||
|
|
0dea08d032 | ||
|
|
61218fa8bb | ||
|
|
21c2a93fdd | ||
|
|
170d56d6d8 | ||
|
|
5c29872ca4 | ||
|
|
66d9d5e172 | ||
|
|
b84538f2c2 | ||
|
|
33148aafcd | ||
|
|
4480aa01d4 | ||
|
|
4616f040dd | ||
|
|
1901893d69 | ||
|
|
e0c8972366 | ||
|
|
d3af9746b8 | ||
|
|
0750690a44 | ||
|
|
85a8581e06 | ||
|
|
cf99e25872 | ||
|
|
68058ed408 | ||
|
|
53472297c1 | ||
|
|
ee45a5464c | ||
|
|
5ee5f70c6d |
132
PluginBuilder.Tests/ApiTests/CreateBuildValidationApiTests.cs
Normal file
132
PluginBuilder.Tests/ApiTests/CreateBuildValidationApiTests.cs
Normal file
@ -0,0 +1,132 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using Dapper;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Serialization;
|
||||||
|
using PluginBuilder.APIModels;
|
||||||
|
using PluginBuilder.Services;
|
||||||
|
using PluginBuilder.Util.Extensions;
|
||||||
|
using Xunit;
|
||||||
|
using Xunit.Abstractions;
|
||||||
|
|
||||||
|
namespace PluginBuilder.Tests.ApiTests;
|
||||||
|
|
||||||
|
public class CreateBuildValidationApiTests(ITestOutputHelper logs) : UnitTestBase(logs)
|
||||||
|
{
|
||||||
|
private const string Password = "123456";
|
||||||
|
private const string MediaType = "application/json";
|
||||||
|
|
||||||
|
private static readonly JsonSerializerSettings SerializerSettings = new() { ContractResolver = new CamelCasePropertyNamesContractResolver() };
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_Return422_If_InvalidPluginDirectory_WithoutCsproj()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var tester = Create();
|
||||||
|
tester.ReuseDatabase = false;
|
||||||
|
await tester.Start();
|
||||||
|
|
||||||
|
var email = $"test-{Guid.NewGuid():N}@example.com";
|
||||||
|
var ownerId = await tester.CreateFakeUserAsync(email, Password);
|
||||||
|
var pluginSlug = "test-no-csproj-" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
|
||||||
|
await using var conn = await tester.GetService<DBConnectionFactory>().Open();
|
||||||
|
await conn.NewPlugin(pluginSlug, ownerId);
|
||||||
|
|
||||||
|
var client = tester.CreateHttpClient().SetBasicAuth(email, Password);
|
||||||
|
|
||||||
|
var request = new CreateBuildRequest
|
||||||
|
{
|
||||||
|
GitRepository = ServerTester.RepoUrl,
|
||||||
|
GitRef = ServerTester.GitRef,
|
||||||
|
PluginDirectory = "Invalid/Path/Without/Csproj",
|
||||||
|
BuildConfig = "Release"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var content = new StringContent(
|
||||||
|
JsonConvert.SerializeObject(request, SerializerSettings),
|
||||||
|
Encoding.UTF8,
|
||||||
|
MediaType);
|
||||||
|
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
$"/api/v1/plugins/{pluginSlug}/builds",
|
||||||
|
content);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||||
|
|
||||||
|
var result = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("Manifest validation failed:", result);
|
||||||
|
|
||||||
|
Assert.False(await conn.ExecuteScalarAsync<bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM builds WHERE plugin_slug = @pluginSlug)",
|
||||||
|
new { pluginSlug }));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Should_Return422_If_SameRepo_UsedForAnotherPluginSlug()
|
||||||
|
{
|
||||||
|
// Arrange
|
||||||
|
await using var tester = Create();
|
||||||
|
tester.ReuseDatabase = false;
|
||||||
|
await tester.Start();
|
||||||
|
|
||||||
|
var email1 = $"owner1-{Guid.NewGuid():N}@example.com";
|
||||||
|
var email2 = $"owner2-{Guid.NewGuid():N}@example.com";
|
||||||
|
|
||||||
|
var ownerId1 = await tester.CreateFakeUserAsync(email1, Password);
|
||||||
|
var ownerId2 = await tester.CreateFakeUserAsync(email2, Password);
|
||||||
|
var pluginSlug1 = "first-plugin-" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
|
||||||
|
await using var conn = await tester.GetService<DBConnectionFactory>().Open();
|
||||||
|
await conn.NewPlugin(pluginSlug1, ownerId1);
|
||||||
|
|
||||||
|
var buildService = tester.GetService<BuildService>();
|
||||||
|
var actualIdentifier = await buildService.FetchIdentifierFromCsprojAsync(
|
||||||
|
ServerTester.RepoUrl,
|
||||||
|
ServerTester.GitRef,
|
||||||
|
ServerTester.PluginDir);
|
||||||
|
|
||||||
|
await conn.ExecuteAsync(
|
||||||
|
"UPDATE plugins SET identifier = @identifier WHERE slug = @slug",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
identifier = actualIdentifier,
|
||||||
|
slug = pluginSlug1
|
||||||
|
});
|
||||||
|
|
||||||
|
var client = tester.CreateHttpClient().SetBasicAuth(email2, Password);
|
||||||
|
|
||||||
|
var pluginSlug2 = "second-plugin-" + Guid.NewGuid().ToString("N")[..8];
|
||||||
|
await conn.NewPlugin(pluginSlug2, ownerId2);
|
||||||
|
|
||||||
|
var request = new CreateBuildRequest
|
||||||
|
{
|
||||||
|
GitRepository = ServerTester.RepoUrl,
|
||||||
|
GitRef = ServerTester.GitRef,
|
||||||
|
PluginDirectory = ServerTester.PluginDir,
|
||||||
|
BuildConfig = "Release"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
var content = new StringContent(
|
||||||
|
JsonConvert.SerializeObject(request, SerializerSettings),
|
||||||
|
Encoding.UTF8,
|
||||||
|
MediaType);
|
||||||
|
|
||||||
|
var response = await client.PostAsync(
|
||||||
|
$"/api/v1/plugins/{pluginSlug2}/builds",
|
||||||
|
content);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||||
|
|
||||||
|
var result = await response.Content.ReadAsStringAsync();
|
||||||
|
Assert.Contains("does not belong to plugin slug", result);
|
||||||
|
|
||||||
|
Assert.False(await conn.ExecuteScalarAsync<bool>(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM builds WHERE plugin_slug = @pluginSlug)",
|
||||||
|
new { pluginSlug = pluginSlug2 }));
|
||||||
|
}
|
||||||
|
}
|
||||||
388
PluginBuilder.Tests/BtcMapsServiceTests.cs
Normal file
388
PluginBuilder.Tests/BtcMapsServiceTests.cs
Normal file
@ -0,0 +1,388 @@
|
|||||||
|
using System.Net.Http;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using PluginBuilder.APIModels;
|
||||||
|
using PluginBuilder.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PluginBuilder.Tests;
|
||||||
|
|
||||||
|
public class BtcMapsServiceTests
|
||||||
|
{
|
||||||
|
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||||
|
{
|
||||||
|
public HttpClient CreateClient(string name) => new HttpClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BtcMapsService MakeService() =>
|
||||||
|
new BtcMapsService(
|
||||||
|
configuration: new ConfigurationBuilder().Build(),
|
||||||
|
httpClientFactory: new StubHttpClientFactory(),
|
||||||
|
logger: NullLogger<BtcMapsService>.Instance);
|
||||||
|
|
||||||
|
private static BtcMapsSubmitRequest MakeValid() => new()
|
||||||
|
{
|
||||||
|
Name = "Good Shop",
|
||||||
|
Url = "https://goodshop.example",
|
||||||
|
Description = "A very good shop.",
|
||||||
|
Type = "merchants"
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_AcceptsMinimalDirectoryRequest()
|
||||||
|
{
|
||||||
|
Assert.Empty(MakeService().Validate(MakeValid()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsMissingName()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.Name = null;
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsOverlongName()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.Name = new string('x', 201);
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Name));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsNonHttpsUrl()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.Url = "http://plain.example";
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Url));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsMissingDescription()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.Description = null;
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsOverlongDescription()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.Description = new string('x', 1001);
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsMissingType()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.Type = null;
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Type));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsInvalidType()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.Type = "shops";
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Type));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsInvalidMerchantSubType()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.SubType = "not-a-real-subtype";
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.SubType));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_AcceptsValidMerchantSubType()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.SubType = "books";
|
||||||
|
Assert.Empty(MakeService().Validate(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_AcceptsIsoAlpha2Country()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.Country = "DE";
|
||||||
|
Assert.Empty(MakeService().Validate(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_AcceptsGlobalCountry()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.Country = "GLOBAL";
|
||||||
|
Assert.Empty(MakeService().Validate(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsLowerCaseCountry()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.Country = "de";
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Country));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsThreeLetterCountry()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.Country = "DEU";
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Country));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsNonAssignedTwoLetterCountry()
|
||||||
|
{
|
||||||
|
// ZZ is reserved / not assigned in ISO 3166-1, so the validator must
|
||||||
|
// reject it even though it passes the length + casing check.
|
||||||
|
var req = MakeValid();
|
||||||
|
req.Country = "ZZ";
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Country));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_AcceptsOnionHttpsUrl()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.OnionUrl = "https://abc123.onion";
|
||||||
|
Assert.Empty(MakeService().Validate(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_AcceptsOnionHttpUrl()
|
||||||
|
{
|
||||||
|
// Onion v3 addresses are commonly served over http (Tor provides the transport
|
||||||
|
// encryption); the validator allows http on a .onion host explicitly.
|
||||||
|
var req = MakeValid();
|
||||||
|
req.OnionUrl = "http://abc123.onion";
|
||||||
|
Assert.Empty(MakeService().Validate(req));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsNonOnionOnionUrl()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.OnionUrl = "https://example.com";
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.OnionUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
// BTC Map import-RPC fields are mandatory only when SubmitToBtcMap=true. The
|
||||||
|
// default-false path preserves the directory-only callers untouched.
|
||||||
|
|
||||||
|
private static BtcMapsSubmitRequest MakeValidBtcMap()
|
||||||
|
{
|
||||||
|
var req = MakeValid();
|
||||||
|
req.SubmitToBtcMap = true;
|
||||||
|
req.Lat = 51.5074;
|
||||||
|
req.Lon = -0.1278;
|
||||||
|
req.Category = "cafe";
|
||||||
|
req.ExternalId = "store.example.com:abc123";
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_AcceptsValidBtcMapSubmission()
|
||||||
|
{
|
||||||
|
Assert.Empty(MakeService().Validate(MakeValidBtcMap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_DoesNotRequireBtcMapFieldsByDefault()
|
||||||
|
{
|
||||||
|
// Directory-only callers (the pre-existing shape) must not break: a
|
||||||
|
// request with SubmitToBtcMap unset (default false) and no Lat / Lon /
|
||||||
|
// Category / ExternalId is still valid.
|
||||||
|
Assert.Empty(MakeService().Validate(MakeValid()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsMissingLatWhenSubmitToBtcMap()
|
||||||
|
{
|
||||||
|
var req = MakeValidBtcMap();
|
||||||
|
req.Lat = null;
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsOutOfRangeLat()
|
||||||
|
{
|
||||||
|
var req = MakeValidBtcMap();
|
||||||
|
req.Lat = 91.0;
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsNaNLat()
|
||||||
|
{
|
||||||
|
var req = MakeValidBtcMap();
|
||||||
|
req.Lat = double.NaN;
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsMissingLonWhenSubmitToBtcMap()
|
||||||
|
{
|
||||||
|
var req = MakeValidBtcMap();
|
||||||
|
req.Lon = null;
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lon));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsOutOfRangeLon()
|
||||||
|
{
|
||||||
|
var req = MakeValidBtcMap();
|
||||||
|
req.Lon = -180.5;
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lon));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsMissingCategoryWhenSubmitToBtcMap()
|
||||||
|
{
|
||||||
|
var req = MakeValidBtcMap();
|
||||||
|
req.Category = null;
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsUppercaseCategoryWhenSubmitToBtcMap()
|
||||||
|
{
|
||||||
|
// BTC Map docs: "Use a short, single-word (if possible), lowercase identifier."
|
||||||
|
var req = MakeValidBtcMap();
|
||||||
|
req.Category = "Cafe";
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsCategoryWithInvalidCharacters()
|
||||||
|
{
|
||||||
|
var req = MakeValidBtcMap();
|
||||||
|
req.Category = "cafe!";
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsMissingExternalIdWhenSubmitToBtcMap()
|
||||||
|
{
|
||||||
|
var req = MakeValidBtcMap();
|
||||||
|
req.ExternalId = null;
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.ExternalId));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Validate_RejectsOverlongExternalId()
|
||||||
|
{
|
||||||
|
var req = MakeValidBtcMap();
|
||||||
|
req.ExternalId = new string('x', 201);
|
||||||
|
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.ExternalId));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BtcMapsService MakeServiceWithConfig(IDictionary<string, string?> config) =>
|
||||||
|
new BtcMapsService(
|
||||||
|
configuration: new ConfigurationBuilder().AddInMemoryCollection(config).Build(),
|
||||||
|
httpClientFactory: new StubHttpClientFactory(),
|
||||||
|
logger: NullLogger<BtcMapsService>.Instance);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task SubmitToBtcMapAsync_RejectsHttpEndpoint()
|
||||||
|
{
|
||||||
|
// Bearer token must never cross the wire over plaintext http://. Misconfigured
|
||||||
|
// endpoint surfaces as InvalidOperationException before HttpClient.SendAsync,
|
||||||
|
// so the token is never built into a request header.
|
||||||
|
var service = MakeServiceWithConfig(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["BTCMAPS:BtcMapImportToken"] = "test-token",
|
||||||
|
["BTCMAPS:BtcMapImportEndpoint"] = "http://api.btcmap.org/rpc"
|
||||||
|
});
|
||||||
|
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => service.SubmitToBtcMapAsync(MakeValidBtcMap()));
|
||||||
|
Assert.Contains("https", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task SubmitToBtcMapAsync_RejectsNonAbsoluteEndpoint()
|
||||||
|
{
|
||||||
|
var service = MakeServiceWithConfig(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["BTCMAPS:BtcMapImportToken"] = "test-token",
|
||||||
|
["BTCMAPS:BtcMapImportEndpoint"] = "/rpc"
|
||||||
|
});
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => service.SubmitToBtcMapAsync(MakeValidBtcMap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async System.Threading.Tasks.Task SubmitToBtcMapAsync_ThrowsTokenMissingWhenUnset()
|
||||||
|
{
|
||||||
|
// Token unset is the ops-deployment-pending shape; controller maps this to
|
||||||
|
// 503 btcmap-not-configured. Test the underlying exception type so the
|
||||||
|
// controller exception ladder stays wired.
|
||||||
|
var service = MakeServiceWithConfig(new Dictionary<string, string?>());
|
||||||
|
await Assert.ThrowsAsync<BtcMapsService.BtcMapTokenMissingException>(
|
||||||
|
() => service.SubmitToBtcMapAsync(MakeValidBtcMap()));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeUrl_LowercasesSchemeAndHostOnly()
|
||||||
|
{
|
||||||
|
// Scheme + host are case-insensitive (DNS + RFC); path + query are not, so
|
||||||
|
// they must be preserved verbatim. Trailing slash is stripped only when the
|
||||||
|
// path is non-root.
|
||||||
|
Assert.Equal("https://example.com/", BtcMapsService.NormalizeUrl("HTTPS://Example.com/"));
|
||||||
|
Assert.Equal("https://example.com/", BtcMapsService.NormalizeUrl(" https://example.com "));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeUrl_PreservesPathCase()
|
||||||
|
{
|
||||||
|
Assert.Equal("https://example.com/Foo/Bar",
|
||||||
|
BtcMapsService.NormalizeUrl("HTTPS://Example.com/Foo/Bar/"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NormalizeUrl_PreservesQueryCase()
|
||||||
|
{
|
||||||
|
Assert.Equal("https://example.com/path?ID=ABC",
|
||||||
|
BtcMapsService.NormalizeUrl("https://EXAMPLE.com/path?ID=ABC"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildBranchName_DeterministicForSameUrl()
|
||||||
|
{
|
||||||
|
var a = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/foo");
|
||||||
|
var b = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/foo");
|
||||||
|
Assert.Equal(a, b);
|
||||||
|
Assert.StartsWith("btcmaps/good-shop-", a);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildBranchName_DiffersForDifferentUrls()
|
||||||
|
{
|
||||||
|
var a = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/foo");
|
||||||
|
var b = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/bar");
|
||||||
|
Assert.NotEqual(a, b);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Slugify_ProducesUrlSafeSegment()
|
||||||
|
{
|
||||||
|
Assert.Equal("good-shop", BtcMapsService.Slugify("Good Shop!"));
|
||||||
|
Assert.Equal("merchant", BtcMapsService.Slugify("!!!"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Slugify_CapsLengthAtFortyChars()
|
||||||
|
{
|
||||||
|
var input = new string('a', 80);
|
||||||
|
var slug = BtcMapsService.Slugify(input);
|
||||||
|
Assert.True(slug.Length <= 40);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,157 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using Microsoft.AspNetCore.Antiforgery;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.AspNetCore.Routing;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using PluginBuilder.Controllers;
|
||||||
|
using PluginBuilder.Filters;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace PluginBuilder.Tests.FilterTests;
|
||||||
|
|
||||||
|
public class UIControllerAntiforgeryTokenAttributeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task OnAuthorizationAsync_WithPostUiRequest_ValidationFailure_SetsResultAndErrorDetails()
|
||||||
|
{
|
||||||
|
var filter = new UIControllerAntiforgeryTokenAttribute();
|
||||||
|
var services = new ServiceCollection()
|
||||||
|
.AddSingleton<IAntiforgery, ThrowingAntiforgery>()
|
||||||
|
.BuildServiceProvider();
|
||||||
|
|
||||||
|
var context = CreateContext(filter, typeof(DummyUiController), HttpMethods.Post, services);
|
||||||
|
|
||||||
|
await filter.OnAuthorizationAsync(context);
|
||||||
|
|
||||||
|
Assert.IsType<AntiforgeryValidationFailedResult>(context.Result);
|
||||||
|
Assert.Equal("CSRF token validation failed.", context.HttpContext.Items[UIErrorController.ErrorDetailsKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnAuthorizationAsync_WithPostApiRequest_DoesNotValidate()
|
||||||
|
{
|
||||||
|
var filter = new UIControllerAntiforgeryTokenAttribute();
|
||||||
|
var services = new ServiceCollection()
|
||||||
|
.AddSingleton<IAntiforgery, ThrowingAntiforgery>()
|
||||||
|
.BuildServiceProvider();
|
||||||
|
|
||||||
|
var context = CreateContext(filter, typeof(DummyApiController), HttpMethods.Post, services);
|
||||||
|
|
||||||
|
await filter.OnAuthorizationAsync(context);
|
||||||
|
|
||||||
|
Assert.Null(context.Result);
|
||||||
|
Assert.False(context.HttpContext.Items.ContainsKey(UIErrorController.ErrorDetailsKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnAuthorizationAsync_WithIgnoreAntiforgeryPolicy_DoesNotValidate()
|
||||||
|
{
|
||||||
|
var filter = new UIControllerAntiforgeryTokenAttribute();
|
||||||
|
var services = new ServiceCollection()
|
||||||
|
.AddSingleton<IAntiforgery, ThrowingAntiforgery>()
|
||||||
|
.BuildServiceProvider();
|
||||||
|
|
||||||
|
var context = CreateContext(
|
||||||
|
filter,
|
||||||
|
typeof(DummyUiController),
|
||||||
|
HttpMethods.Post,
|
||||||
|
services,
|
||||||
|
new IgnoreAntiforgeryTokenAttribute());
|
||||||
|
|
||||||
|
await filter.OnAuthorizationAsync(context);
|
||||||
|
|
||||||
|
Assert.Null(context.Result);
|
||||||
|
Assert.False(context.HttpContext.Items.ContainsKey(UIErrorController.ErrorDetailsKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task OnResultExecutionAsync_WithAntiforgeryFailureResult_AddsErrorDetails()
|
||||||
|
{
|
||||||
|
var filter = new UIControllerAntiforgeryTokenAttribute();
|
||||||
|
var httpContext = new DefaultHttpContext();
|
||||||
|
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
|
||||||
|
List<IFilterMetadata> filters = new() { filter };
|
||||||
|
var result = new AntiforgeryValidationFailedResult();
|
||||||
|
var controller = new object();
|
||||||
|
var context = new ResultExecutingContext(actionContext, filters, result, controller);
|
||||||
|
|
||||||
|
await filter.OnResultExecutionAsync(context, () =>
|
||||||
|
{
|
||||||
|
var executedContext = new ResultExecutedContext(actionContext, filters, result, controller);
|
||||||
|
return Task.FromResult(executedContext);
|
||||||
|
});
|
||||||
|
|
||||||
|
Assert.Equal("CSRF token validation failed.", context.HttpContext.Items[UIErrorController.ErrorDetailsKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void NostrVerifyNip07_HasIgnoreAntiforgeryTokenAttribute()
|
||||||
|
{
|
||||||
|
var method = typeof(AccountController).GetMethod(nameof(AccountController.NostrVerifyNip07));
|
||||||
|
|
||||||
|
Assert.NotNull(method);
|
||||||
|
Assert.NotNull(method!.GetCustomAttribute<IgnoreAntiforgeryTokenAttribute>());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AuthorizationFilterContext CreateContext(
|
||||||
|
UIControllerAntiforgeryTokenAttribute filter,
|
||||||
|
Type controllerType,
|
||||||
|
string method,
|
||||||
|
IServiceProvider services,
|
||||||
|
params IFilterMetadata[] extraFilters)
|
||||||
|
{
|
||||||
|
var httpContext = new DefaultHttpContext
|
||||||
|
{
|
||||||
|
RequestServices = services
|
||||||
|
};
|
||||||
|
httpContext.Request.Method = method;
|
||||||
|
|
||||||
|
var descriptor = new ControllerActionDescriptor
|
||||||
|
{
|
||||||
|
ActionName = "Action",
|
||||||
|
ControllerName = controllerType.Name.Replace("Controller", string.Empty, StringComparison.Ordinal),
|
||||||
|
ControllerTypeInfo = controllerType.GetTypeInfo()
|
||||||
|
};
|
||||||
|
|
||||||
|
var actionContext = new ActionContext(httpContext, new RouteData(), descriptor);
|
||||||
|
List<IFilterMetadata> filters = new();
|
||||||
|
filters.Add(filter);
|
||||||
|
filters.AddRange(extraFilters);
|
||||||
|
|
||||||
|
// Match MVC's ordered execution so IAntiforgeryPolicy precedence is realistic in tests.
|
||||||
|
var orderedFilters = filters
|
||||||
|
.Select((metadata, index) => new
|
||||||
|
{
|
||||||
|
Metadata = metadata,
|
||||||
|
Order = (metadata as IOrderedFilter)?.Order ?? 0,
|
||||||
|
Index = index
|
||||||
|
})
|
||||||
|
.OrderBy(x => x.Order)
|
||||||
|
.ThenBy(x => x.Index)
|
||||||
|
.Select(x => x.Metadata)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new AuthorizationFilterContext(actionContext, orderedFilters);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DummyUiController : Controller;
|
||||||
|
|
||||||
|
private sealed class DummyApiController : ControllerBase;
|
||||||
|
|
||||||
|
private sealed class ThrowingAntiforgery : IAntiforgery
|
||||||
|
{
|
||||||
|
public AntiforgeryTokenSet GetAndStoreTokens(HttpContext httpContext) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public AntiforgeryTokenSet GetTokens(HttpContext httpContext) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task<bool> IsRequestValidAsync(HttpContext httpContext) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public void SetCookieTokenAndHeader(HttpContext httpContext) => throw new NotSupportedException();
|
||||||
|
|
||||||
|
public Task ValidateRequestAsync(HttpContext httpContext) => throw new AntiforgeryValidationException("Invalid CSRF token.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -59,7 +59,7 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
|
|||||||
await t.Page.ClickAsync("button:text-is('Release')");
|
await t.Page.ClickAsync("button:text-is('Release')");
|
||||||
|
|
||||||
await t.Page!.ClickAsync("#StoreNav-Dashboard");
|
await t.Page!.ClickAsync("#StoreNav-Dashboard");
|
||||||
await t.Page.ClickAsync("a.btn.btn-primary:text('Request Listing')");
|
await t.Page.ClickAsync("#StoreNav-RequestListing");
|
||||||
await Expect(t.Page.Locator("#collapsePluginSettings")).ToBeVisibleAsync();
|
await Expect(t.Page.Locator("#collapsePluginSettings")).ToBeVisibleAsync();
|
||||||
await Expect(t.Page.Locator("#pluginSettingsHeader")).ToContainTextAsync("Update Plugin Settings");
|
await Expect(t.Page.Locator("#pluginSettingsHeader")).ToContainTextAsync("Update Plugin Settings");
|
||||||
await Expect(t.Page.Locator("#collapseOwnerSettings")).Not.ToBeVisibleAsync();
|
await Expect(t.Page.Locator("#collapseOwnerSettings")).Not.ToBeVisibleAsync();
|
||||||
@ -78,7 +78,7 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
|
|||||||
await t.AssertNoError();
|
await t.AssertNoError();
|
||||||
|
|
||||||
await t.Page!.ClickAsync("#StoreNav-Dashboard");
|
await t.Page!.ClickAsync("#StoreNav-Dashboard");
|
||||||
await t.Page.ClickAsync("a.btn.btn-primary:text('Request Listing')");
|
await t.Page.ClickAsync("#StoreNav-RequestListing");
|
||||||
await Expect(t.Page.Locator("#collapseRequestForm")).ToBeVisibleAsync();
|
await Expect(t.Page.Locator("#collapseRequestForm")).ToBeVisibleAsync();
|
||||||
await Expect(t.Page.Locator("#collapsePluginSettings")).Not.ToBeVisibleAsync();
|
await Expect(t.Page.Locator("#collapsePluginSettings")).Not.ToBeVisibleAsync();
|
||||||
await Expect(t.Page.Locator("#collapseOwnerSettings")).Not.ToBeVisibleAsync();
|
await Expect(t.Page.Locator("#collapseOwnerSettings")).Not.ToBeVisibleAsync();
|
||||||
@ -87,7 +87,7 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
|
|||||||
await t.Page.FillAsync("textarea[name='UserReviews']", "Great plugin, works as expected!");
|
await t.Page.FillAsync("textarea[name='UserReviews']", "Great plugin, works as expected!");
|
||||||
await t.Page.ClickAsync("button[type='submit']:text('Submit')");
|
await t.Page.ClickAsync("button[type='submit']:text('Submit')");
|
||||||
await t.AssertNoError();
|
await t.AssertNoError();
|
||||||
await t.Page.ClickAsync("a.btn.btn-primary:text('Request Listing')");
|
await t.Page.ClickAsync("#StoreNav-RequestListing");
|
||||||
|
|
||||||
await Expect(t.Page.Locator("#collapsePluginSettings")).Not.ToBeVisibleAsync();
|
await Expect(t.Page.Locator("#collapsePluginSettings")).Not.ToBeVisibleAsync();
|
||||||
await Expect(t.Page.Locator("#collapseOwnerSettings")).Not.ToBeVisibleAsync();
|
await Expect(t.Page.Locator("#collapseOwnerSettings")).Not.ToBeVisibleAsync();
|
||||||
@ -145,7 +145,7 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
|
|||||||
await Expect(row.Locator(".badge.bg-warning:text('Pending')")).ToBeVisibleAsync();
|
await Expect(row.Locator(".badge.bg-warning:text('Pending')")).ToBeVisibleAsync();
|
||||||
|
|
||||||
// Click to view details - scoped to the specific row
|
// Click to view details - scoped to the specific row
|
||||||
await row.Locator("a.btn.btn-sm.btn-primary:text('View Details')").ClickAsync();
|
await row.Locator($"a[href*='/admin/listing-requests/{requestId}']").ClickAsync();
|
||||||
await Expect(t.Page).ToHaveURLAsync(new Regex($".*/admin/listing-requests/{requestId}", RegexOptions.IgnoreCase));
|
await Expect(t.Page).ToHaveURLAsync(new Regex($".*/admin/listing-requests/{requestId}", RegexOptions.IgnoreCase));
|
||||||
|
|
||||||
// Verify request details are displayed
|
// Verify request details are displayed
|
||||||
@ -172,6 +172,44 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
|
|||||||
Assert.Equal(PluginVisibilityEnum.Listed, plugin!.Visibility);
|
Assert.Equal(PluginVisibilityEnum.Listed, plugin!.Visibility);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Admin_Can_Reject_Pending_ListingRequest()
|
||||||
|
{
|
||||||
|
await using var t = new PlaywrightTester(_log);
|
||||||
|
t.Server.ReuseDatabase = false;
|
||||||
|
await t.StartAsync();
|
||||||
|
await using var conn = await t.Server.GetService<DBConnectionFactory>().Open();
|
||||||
|
|
||||||
|
var pluginSlug = "test-plugin-" + PlaywrightTester.GetRandomUInt256()[..8];
|
||||||
|
var userId = await t.Server.CreateFakeUserAsync();
|
||||||
|
await t.Server.CreateAndBuildPluginAsync(userId, pluginSlug);
|
||||||
|
|
||||||
|
var requestId = await conn.CreateListingRequest(
|
||||||
|
pluginSlug,
|
||||||
|
"Test plugin release note",
|
||||||
|
"https://t.me/btcpayserver/12345",
|
||||||
|
"https://example.com/review",
|
||||||
|
null);
|
||||||
|
|
||||||
|
var adminEmail = await t.CreateServerAdminAsync();
|
||||||
|
await t.LogIn(adminEmail);
|
||||||
|
await t.GoToUrl($"/admin/listing-requests/{requestId}");
|
||||||
|
|
||||||
|
await t.Page.ClickAsync("button.btn.btn-danger:text('Reject')");
|
||||||
|
await t.Page.FillAsync("#rejectionReason", "Plugin does not meet quality standards");
|
||||||
|
await t.Page.ClickAsync("button[type='submit'].btn.btn-danger:text('Reject')");
|
||||||
|
|
||||||
|
await Expect(t.Page).ToHaveURLAsync(new Regex(".*/admin/listing-requests$", RegexOptions.IgnoreCase));
|
||||||
|
|
||||||
|
var rejected = await conn.GetListingRequest(requestId);
|
||||||
|
Assert.NotNull(rejected);
|
||||||
|
Assert.Equal(PluginListingRequestStatus.Rejected, rejected.Status);
|
||||||
|
Assert.Equal("Plugin does not meet quality standards", rejected.RejectionReason);
|
||||||
|
|
||||||
|
var plugin = await conn.GetPluginDetails(pluginSlug);
|
||||||
|
Assert.Equal(PluginVisibilityEnum.Unlisted, plugin!.Visibility);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Admin_Can_Reject_ListingRequest()
|
public async Task Admin_Can_Reject_ListingRequest()
|
||||||
{
|
{
|
||||||
|
|||||||
@ -172,4 +172,23 @@ public class ErrorPageTests(ITestOutputHelper logs) : UnitTestBase(logs)
|
|||||||
Assert.Contains("404 - Page not found", body, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("404 - Page not found", body, StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.DoesNotContain("399 -", body, StringComparison.OrdinalIgnoreCase);
|
Assert.DoesNotContain("399 -", body, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ForgotPassword_WithoutAntiforgeryToken_ShowsCsrfDetails()
|
||||||
|
{
|
||||||
|
await using var tester = await Start();
|
||||||
|
var client = tester.CreateHttpClient();
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html"));
|
||||||
|
|
||||||
|
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
|
||||||
|
{
|
||||||
|
["Email"] = "test@example.com"
|
||||||
|
});
|
||||||
|
var response = await client.PostAsync("/forgotpassword", content);
|
||||||
|
var body = await response.Content.ReadAsStringAsync();
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
Assert.Contains("400 - Bad Request", body, StringComparison.OrdinalIgnoreCase);
|
||||||
|
Assert.Contains("CSRF token validation failed.", body, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
46
PluginBuilder/APIModels/BtcMapsSubmitRequest.cs
Normal file
46
PluginBuilder/APIModels/BtcMapsSubmitRequest.cs
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
namespace PluginBuilder.APIModels;
|
||||||
|
|
||||||
|
public sealed class BtcMapsSubmitRequest
|
||||||
|
{
|
||||||
|
public string? Name { get; set; }
|
||||||
|
public string? Url { get; set; }
|
||||||
|
public string? Description { get; set; }
|
||||||
|
|
||||||
|
public string? Type { get; set; }
|
||||||
|
public string? SubType { get; set; }
|
||||||
|
public string? Country { get; set; }
|
||||||
|
public string? Twitter { get; set; }
|
||||||
|
public string? Github { get; set; }
|
||||||
|
public string? OnionUrl { get; set; }
|
||||||
|
public string? Phone { get; set; }
|
||||||
|
|
||||||
|
// BTC Map import RPC fields. Required iff SubmitToBtcMap=true.
|
||||||
|
// Plugin captures lat/lon and composes external_id as hostname:storeId
|
||||||
|
// so this endpoint just passes through to the btcmap submit_place RPC.
|
||||||
|
public double? Lat { get; set; }
|
||||||
|
public double? Lon { get; set; }
|
||||||
|
public string? Category { get; set; }
|
||||||
|
public string? ExternalId { get; set; }
|
||||||
|
|
||||||
|
// Address fields. Optional; forwarded to btcmap as osm:addr:* tags per the
|
||||||
|
// BTC Map import-RPC doc's osm:<tag_name> custom-field convention.
|
||||||
|
public string? HouseNumber { get; set; }
|
||||||
|
public string? Street { get; set; }
|
||||||
|
public string? City { get; set; }
|
||||||
|
public string? Postcode { get; set; }
|
||||||
|
|
||||||
|
// Email - first-class field on btcmap rest/v4/places.md. Plain key, no prefix.
|
||||||
|
public string? Email { get; set; }
|
||||||
|
|
||||||
|
// Payment-rail flags. Plugin sets these per the store's enabled rails;
|
||||||
|
// each true value emits the corresponding `payment:onchain=yes` /
|
||||||
|
// `payment:lightning=yes` marker in extra_fields. Null / false = omitted.
|
||||||
|
public bool? AcceptsOnchain { get; set; }
|
||||||
|
public bool? AcceptsLightning { get; set; }
|
||||||
|
|
||||||
|
// Routing flags. Default-true preserves the existing call-site semantics
|
||||||
|
// for SubmitToDirectory; SubmitToBtcMap defaults false so callers must
|
||||||
|
// opt in to the new path.
|
||||||
|
public bool SubmitToDirectory { get; set; } = true;
|
||||||
|
public bool SubmitToBtcMap { get; set; }
|
||||||
|
}
|
||||||
22
PluginBuilder/APIModels/BtcMapsSubmitResponse.cs
Normal file
22
PluginBuilder/APIModels/BtcMapsSubmitResponse.cs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
namespace PluginBuilder.APIModels;
|
||||||
|
|
||||||
|
public sealed class BtcMapsSubmitResponse
|
||||||
|
{
|
||||||
|
public BtcMapsDirectoryResult? Directory { get; set; }
|
||||||
|
public BtcMapsBtcMapResult? BtcMap { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BtcMapsDirectoryResult
|
||||||
|
{
|
||||||
|
public string? PrUrl { get; set; }
|
||||||
|
public int? PrNumber { get; set; }
|
||||||
|
public string? Branch { get; set; }
|
||||||
|
public string? Skipped { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BtcMapsBtcMapResult
|
||||||
|
{
|
||||||
|
public long? Id { get; set; }
|
||||||
|
public string? Origin { get; set; }
|
||||||
|
public string? ExternalId { get; set; }
|
||||||
|
}
|
||||||
@ -39,6 +39,7 @@ public class PublishedPlugin : PublishedVersion
|
|||||||
get => BuildInfo?["pluginDir"]?.ToString();
|
get => BuildInfo?["pluginDir"]?.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public bool IsUnlisted { get; set; }
|
||||||
public PluginRatingSummary RatingSummary { get; set; } = new();
|
public PluginRatingSummary RatingSummary { get; set; } = new();
|
||||||
|
|
||||||
public string GetSourceUrl(GitHostingProviderFactory providerFactory)
|
public string GetSourceUrl(GitHostingProviderFactory providerFactory)
|
||||||
|
|||||||
@ -19,6 +19,16 @@
|
|||||||
<span>Builds</span>
|
<span>Builds</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@if (Model.RequestListing && Model.Versions.Any())
|
||||||
|
{
|
||||||
|
<li class="nav-item">
|
||||||
|
<a asp-area="" asp-controller="Plugin" asp-action="RequestListing" asp-route-pluginSlug="@Model.PluginSlug"
|
||||||
|
class="nav-link js-scroll-trigger @ViewData.IsActivePage(PluginNavPages.RequestListing)" id="StoreNav-RequestListing">
|
||||||
|
<vc:icon symbol="notification" />
|
||||||
|
<span>Request Listing</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a asp-area="" asp-controller="Plugin" asp-action="Settings" asp-route-pluginSlug="@Model.PluginSlug"
|
<a asp-area="" asp-controller="Plugin" asp-action="Settings" asp-route-pluginSlug="@Model.PluginSlug"
|
||||||
class="nav-link js-scroll-trigger @ViewData.IsActivePage(PluginNavPages.Settings)" id="StoreNav-Settings">
|
class="nav-link js-scroll-trigger @ViewData.IsActivePage(PluginNavPages.Settings)" id="StoreNav-Settings">
|
||||||
|
|||||||
@ -2,6 +2,7 @@ using Dapper;
|
|||||||
using Microsoft.AspNetCore.Identity;
|
using Microsoft.AspNetCore.Identity;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using PluginBuilder.Components.PluginVersion;
|
using PluginBuilder.Components.PluginVersion;
|
||||||
|
using PluginBuilder.DataModels;
|
||||||
using PluginBuilder.Services;
|
using PluginBuilder.Services;
|
||||||
using PluginBuilder.Util;
|
using PluginBuilder.Util;
|
||||||
using PluginBuilder.Util.Extensions;
|
using PluginBuilder.Util.Extensions;
|
||||||
@ -26,22 +27,28 @@ public class MainNav : ViewComponent
|
|||||||
|
|
||||||
using var conn = await ConnectionFactory.Open();
|
using var conn = await ConnectionFactory.Open();
|
||||||
|
|
||||||
if (pluginSlug != null)
|
if (pluginSlug is { } currentPluginSlug)
|
||||||
{
|
{
|
||||||
|
var slug = currentPluginSlug.ToString();
|
||||||
var rows = await conn.QueryAsync<(int[] ver, bool pre_release)>(
|
var rows = await conn.QueryAsync<(int[] ver, bool pre_release)>(
|
||||||
"SELECT ver, pre_release FROM users_plugins up " +
|
"SELECT ver, pre_release FROM users_plugins up " +
|
||||||
"JOIN versions v USING (plugin_slug) " +
|
"JOIN versions v USING (plugin_slug) " +
|
||||||
"WHERE up.user_id=@userId AND up.plugin_slug=@pluginSlug " +
|
"WHERE up.user_id=@userId AND up.plugin_slug=@pluginSlug " +
|
||||||
"ORDER BY v.ver DESC LIMIT 10", new { pluginSlug = pluginSlug.ToString(), userId = UserManager.GetUserId(UserClaimsPrincipal) });
|
"ORDER BY v.ver DESC LIMIT 10", new { pluginSlug = slug, userId = UserManager.GetUserId(UserClaimsPrincipal) });
|
||||||
foreach (var r in rows)
|
foreach (var r in rows)
|
||||||
vm.Versions.Add(new PluginVersionViewModel
|
vm.Versions.Add(new PluginVersionViewModel
|
||||||
{
|
{
|
||||||
PluginSlug = pluginSlug?.ToString(),
|
PluginSlug = slug,
|
||||||
Version = new PluginBuilder.PluginVersion(r.ver).ToString(),
|
Version = new PluginBuilder.PluginVersion(r.ver).ToString(),
|
||||||
PreRelease = r.pre_release,
|
PreRelease = r.pre_release,
|
||||||
Published = true,
|
Published = true,
|
||||||
HidePublishBadge = true
|
HidePublishBadge = true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
var visibility = await conn.ExecuteScalarAsync<string?>(
|
||||||
|
"SELECT visibility FROM plugins WHERE slug=@pluginSlug",
|
||||||
|
new { pluginSlug = slug });
|
||||||
|
vm.RequestListing = string.Equals(visibility, nameof(PluginVisibilityEnum.Unlisted).ToLowerInvariant(), StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only load pending count for admins to avoid burdening database
|
// Only load pending count for admins to avoid burdening database
|
||||||
|
|||||||
@ -12,4 +12,5 @@ public class MainNavViewModel
|
|||||||
public List<PluginVersionViewModel> Versions { get; set; } = new();
|
public List<PluginVersionViewModel> Versions { get; set; } = new();
|
||||||
|
|
||||||
public int PendingListingRequestsCount { get; set; }
|
public int PendingListingRequestsCount { get; set; }
|
||||||
|
public bool RequestListing { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,5 +4,6 @@ public enum PluginNavPages
|
|||||||
{
|
{
|
||||||
Dashboard,
|
Dashboard,
|
||||||
Settings,
|
Settings,
|
||||||
Owners
|
Owners,
|
||||||
|
RequestListing
|
||||||
}
|
}
|
||||||
|
|||||||
@ -164,6 +164,7 @@ public class AccountController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("nostr/verify-nip07")]
|
[HttpPost("nostr/verify-nip07")]
|
||||||
|
[IgnoreAntiforgeryToken]
|
||||||
public async Task<IActionResult> NostrVerifyNip07([FromBody] VerifyNip07Request req)
|
public async Task<IActionResult> NostrVerifyNip07([FromBody] VerifyNip07Request req)
|
||||||
{
|
{
|
||||||
var user = await userManager.GetUserAsync(User) ?? throw new Exception("User not found");
|
var user = await userManager.GetUserAsync(User) ?? throw new Exception("User not found");
|
||||||
|
|||||||
@ -1286,7 +1286,6 @@ public class AdminController(
|
|||||||
TempData[TempDataConstant.WarningMessage] = "Failed to reject the listing request";
|
TempData[TempDataConstant.WarningMessage] = "Failed to reject the listing request";
|
||||||
return RedirectToAction(nameof(ListingRequestDetail), new { requestId });
|
return RedirectToAction(nameof(ListingRequestDetail), new { requestId });
|
||||||
}
|
}
|
||||||
|
|
||||||
var existingSettings = await conn.GetSettings(pluginSlug);
|
var existingSettings = await conn.GetSettings(pluginSlug);
|
||||||
var pluginOwners = await conn.GetPluginOwners(pluginSlug);
|
var pluginOwners = await conn.GetPluginOwners(pluginSlug);
|
||||||
var primaryOwner = pluginOwners.FirstOrDefault(o => o.IsPrimary);
|
var primaryOwner = pluginOwners.FirstOrDefault(o => o.IsPrimary);
|
||||||
@ -1296,6 +1295,5 @@ public class AdminController(
|
|||||||
TempData[TempDataConstant.SuccessMessage] = $"Plugin listing request for '{request.PluginSlug}' has been rejected";
|
TempData[TempDataConstant.SuccessMessage] = $"Plugin listing request for '{request.PluginSlug}' has been rejected";
|
||||||
return RedirectToAction(nameof(ListingRequests));
|
return RedirectToAction(nameof(ListingRequests));
|
||||||
}
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@ -324,6 +324,26 @@ public class ApiController(
|
|||||||
if (!ModelState.IsValid)
|
if (!ModelState.IsValid)
|
||||||
return ValidationErrorResult(ModelState);
|
return ValidationErrorResult(ModelState);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var identifier = await buildService.FetchIdentifierFromCsprojAsync(
|
||||||
|
model.GitRepository,
|
||||||
|
model.GitRef,
|
||||||
|
model.PluginDirectory);
|
||||||
|
|
||||||
|
var owns = await conn.EnsureIdentifierOwnership(pluginSlug, identifier);
|
||||||
|
if (!owns)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, $"The plugin identifier {identifier} does not belong to plugin slug {pluginSlug}.");
|
||||||
|
return ValidationErrorResult(ModelState);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (BuildServiceException ex)
|
||||||
|
{
|
||||||
|
ModelState.AddModelError(string.Empty, $"Manifest validation failed: {ex.Message}");
|
||||||
|
return ValidationErrorResult(ModelState);
|
||||||
|
}
|
||||||
|
|
||||||
var buildId = await conn.NewBuild(pluginSlug, model.ToBuildParameter());
|
var buildId = await conn.NewBuild(pluginSlug, model.ToBuildParameter());
|
||||||
var buildUrl = Url.ActionLink(nameof(PluginController.Build), "Plugin",
|
var buildUrl = Url.ActionLink(nameof(PluginController.Build), "Plugin",
|
||||||
new { pluginSlug = pluginSlug.ToString(), buildId });
|
new { pluginSlug = pluginSlug.ToString(), buildId });
|
||||||
|
|||||||
109
PluginBuilder/Controllers/BtcMapsController.cs
Normal file
109
PluginBuilder/Controllers/BtcMapsController.cs
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.RateLimiting;
|
||||||
|
using PluginBuilder.APIModels;
|
||||||
|
using PluginBuilder.Services;
|
||||||
|
|
||||||
|
namespace PluginBuilder.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[AllowAnonymous]
|
||||||
|
[Route("~/apis/btcmaps/v1")]
|
||||||
|
public sealed class BtcMapsController(
|
||||||
|
BtcMapsService btcMapsService,
|
||||||
|
ILogger<BtcMapsController> logger)
|
||||||
|
: ControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("ping")]
|
||||||
|
public IActionResult Ping() => Ok(new { ok = true, service = "btcmaps", version = "v1" });
|
||||||
|
|
||||||
|
[HttpPost("submit")]
|
||||||
|
[EnableRateLimiting(Policies.BtcMapsSubmitRateLimit)]
|
||||||
|
public async Task<IActionResult> Submit(
|
||||||
|
[FromBody] BtcMapsSubmitRequest? request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request is null)
|
||||||
|
return BadRequest(new { errors = new[] { new ValidationError("body", "Request body is required.") } });
|
||||||
|
|
||||||
|
var errors = btcMapsService.Validate(request);
|
||||||
|
if (errors.Count > 0)
|
||||||
|
return BadRequest(new { errors });
|
||||||
|
|
||||||
|
// At least one downstream lane must run; "submit nothing" is almost
|
||||||
|
// certainly a caller bug (forgot to set the flag) rather than a legit
|
||||||
|
// intent, and silently 200-ing an empty response would hide it.
|
||||||
|
if (!request.SubmitToDirectory && !request.SubmitToBtcMap)
|
||||||
|
{
|
||||||
|
return BadRequest(new { errors = new[] {
|
||||||
|
new ValidationError("body", "At least one of SubmitToDirectory or SubmitToBtcMap must be true.")
|
||||||
|
}});
|
||||||
|
}
|
||||||
|
|
||||||
|
var correlationId = Guid.NewGuid().ToString("N");
|
||||||
|
BtcMapsDirectoryResult? directory = null;
|
||||||
|
BtcMapsBtcMapResult? btcMap = null;
|
||||||
|
|
||||||
|
if (request.SubmitToDirectory)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (BtcMapsService.DirectoryTokenMissingException ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "BTCMaps directory submission rejected: token not configured (correlationId={CorrelationId})", correlationId);
|
||||||
|
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "directory-not-configured", correlationId });
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogInformation(ex, "BTCMaps directory submission cancelled by caller (correlationId={CorrelationId})", correlationId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "BTCMaps directory submission timed out upstream (correlationId={CorrelationId}) for {Name} ({Url})",
|
||||||
|
correlationId, request.Name, request.Url);
|
||||||
|
return StatusCode(StatusCodes.Status504GatewayTimeout, new { error = "directory-upstream-timeout", correlationId });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "BTCMaps directory submission failed (correlationId={CorrelationId}) for {Name} ({Url})",
|
||||||
|
correlationId, request.Name, request.Url);
|
||||||
|
return StatusCode(StatusCodes.Status502BadGateway, new { error = "directory-upstream-failed", correlationId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.SubmitToBtcMap)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
btcMap = await btcMapsService.SubmitToBtcMapAsync(request, cancellationToken);
|
||||||
|
}
|
||||||
|
catch (BtcMapsService.BtcMapTokenMissingException ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "BTCMaps import submission rejected: token not configured (correlationId={CorrelationId})", correlationId);
|
||||||
|
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "btcmap-not-configured", correlationId });
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
logger.LogInformation(ex, "BTCMaps import submission cancelled by caller (correlationId={CorrelationId})", correlationId);
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "BTCMaps import submission timed out upstream (correlationId={CorrelationId}) for {Name} ({Url})",
|
||||||
|
correlationId, request.Name, request.Url);
|
||||||
|
return StatusCode(StatusCodes.Status504GatewayTimeout, new { error = "btcmap-upstream-timeout", correlationId });
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
logger.LogError(ex, "BTCMaps import submission failed (correlationId={CorrelationId}) for {Name} ({Url})",
|
||||||
|
correlationId, request.Name, request.Url);
|
||||||
|
return StatusCode(StatusCodes.Status502BadGateway, new { error = "btcmap-upstream-failed", correlationId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new BtcMapsSubmitResponse { Directory = directory, BtcMap = btcMap });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -230,6 +230,7 @@ public class HomeController(
|
|||||||
lv.plugin_slug,
|
lv.plugin_slug,
|
||||||
lv.ver,
|
lv.ver,
|
||||||
p.settings,
|
p.settings,
|
||||||
|
p.visibility,
|
||||||
b.id,
|
b.id,
|
||||||
b.manifest_info,
|
b.manifest_info,
|
||||||
b.build_info,
|
b.build_info,
|
||||||
@ -278,7 +279,7 @@ public class HomeController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rows = await conn
|
var rows = await conn
|
||||||
.QueryAsync<(string plugin_slug, int[] ver, string settings, long id, string manifest_info, string build_info, decimal avg_rating, int total_reviews
|
.QueryAsync<(string plugin_slug, int[] ver, string settings, PluginVisibilityEnum visibility, long id, string manifest_info, string build_info, decimal avg_rating, int total_reviews
|
||||||
)>(
|
)>(
|
||||||
query,
|
query,
|
||||||
new
|
new
|
||||||
@ -304,6 +305,7 @@ public class HomeController(
|
|||||||
BuildInfo = JObject.Parse(r.build_info),
|
BuildInfo = JObject.Parse(r.build_info),
|
||||||
ManifestInfo = manifestInfo,
|
ManifestInfo = manifestInfo,
|
||||||
PluginLogo = settings?.Logo,
|
PluginLogo = settings?.Logo,
|
||||||
|
IsUnlisted = r.visibility == PluginVisibilityEnum.Unlisted,
|
||||||
RatingSummary = new PluginRatingSummary
|
RatingSummary = new PluginRatingSummary
|
||||||
{
|
{
|
||||||
Average = r.avg_rating,
|
Average = r.avg_rating,
|
||||||
|
|||||||
@ -318,6 +318,35 @@ public class PluginController(
|
|||||||
return RedirectToAction(nameof(Build), new { pluginSlug = pluginSlug.ToString(), buildId });
|
return RedirectToAction(nameof(Build), new { pluginSlug = pluginSlug.ToString(), buildId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("listing-history")]
|
||||||
|
public async Task<IActionResult> ListingHistory(
|
||||||
|
[ModelBinder(typeof(PluginSlugModelBinder))]
|
||||||
|
PluginSlug pluginSlug)
|
||||||
|
{
|
||||||
|
await using var conn = await connectionFactory.Open();
|
||||||
|
var plugin = await conn.GetPluginDetails(pluginSlug);
|
||||||
|
if (plugin is null)
|
||||||
|
return NotFound();
|
||||||
|
|
||||||
|
var pluginSettings = SafeJson.Deserialize<PluginSettings>(plugin.Settings);
|
||||||
|
var requests = await conn.GetAllListingRequestsForPlugin(pluginSlug);
|
||||||
|
var vm = new ListingHistoryViewModel
|
||||||
|
{
|
||||||
|
PluginSlug = pluginSlug.ToString(),
|
||||||
|
PluginTitle = pluginSettings?.PluginTitle,
|
||||||
|
Requests = requests.Select(r => new ListingHistoryItemViewModel
|
||||||
|
{
|
||||||
|
Id = r.Id,
|
||||||
|
Status = r.Status,
|
||||||
|
ReleaseNote = r.ReleaseNote,
|
||||||
|
SubmittedAt = r.SubmittedAt,
|
||||||
|
ReviewedAt = r.ReviewedAt,
|
||||||
|
RejectionReason = r.RejectionReason
|
||||||
|
}).ToList()
|
||||||
|
};
|
||||||
|
return View(vm);
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("request-listing")]
|
[HttpGet("request-listing")]
|
||||||
public async Task<IActionResult> RequestListing(
|
public async Task<IActionResult> RequestListing(
|
||||||
[ModelBinder(typeof(PluginSlugModelBinder))]
|
[ModelBinder(typeof(PluginSlugModelBinder))]
|
||||||
@ -335,6 +364,7 @@ public class PluginController(
|
|||||||
if (plugin.Visibility == PluginVisibilityEnum.Hidden)
|
if (plugin.Visibility == PluginVisibilityEnum.Hidden)
|
||||||
return NotFound();
|
return NotFound();
|
||||||
|
|
||||||
|
var allRequests = await conn.GetAllListingRequestsForPlugin(pluginSlug);
|
||||||
var pluginOwners = await conn.GetPluginOwners(pluginSlug);
|
var pluginOwners = await conn.GetPluginOwners(pluginSlug);
|
||||||
var pluginSettings = SafeJson.Deserialize<PluginSettings>(plugin.Settings);
|
var pluginSettings = SafeJson.Deserialize<PluginSettings>(plugin.Settings);
|
||||||
var pendingRequest = await conn.GetPendingListingRequestForPlugin(pluginSlug);
|
var pendingRequest = await conn.GetPendingListingRequestForPlugin(pluginSlug);
|
||||||
@ -342,6 +372,7 @@ public class PluginController(
|
|||||||
|
|
||||||
model.ReleaseNote = pluginSettings?.Description;
|
model.ReleaseNote = pluginSettings?.Description;
|
||||||
model.HasPreviousRejection = rejectedRequest != null;
|
model.HasPreviousRejection = rejectedRequest != null;
|
||||||
|
model.HasRequests = allRequests.Any();
|
||||||
|
|
||||||
if (pendingRequest != null)
|
if (pendingRequest != null)
|
||||||
{
|
{
|
||||||
@ -737,7 +768,6 @@ public class PluginController(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var pluginSettings = await conn.GetPluginDetails(pluginSlug);
|
var pluginSettings = await conn.GetPluginDetails(pluginSlug);
|
||||||
vm.RequestListing = pluginSettings?.Visibility == PluginVisibilityEnum.Unlisted;
|
|
||||||
return View(vm);
|
return View(vm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,4 +4,6 @@ public static class HttpClientNames
|
|||||||
{
|
{
|
||||||
public const string GitHub = nameof(GitHub);
|
public const string GitHub = nameof(GitHub);
|
||||||
public const string GitLab = nameof(GitLab);
|
public const string GitLab = nameof(GitLab);
|
||||||
|
public const string BtcMapsDirectory = nameof(BtcMapsDirectory);
|
||||||
|
public const string BtcMap = nameof(BtcMap);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,6 +15,23 @@ public class PluginListingRequest
|
|||||||
public string? RejectionReason { get; set; }
|
public string? RejectionReason { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class ListingHistoryViewModel
|
||||||
|
{
|
||||||
|
public string PluginSlug { get; set; } = null!;
|
||||||
|
public string? PluginTitle { get; set; }
|
||||||
|
public List<ListingHistoryItemViewModel> Requests { get; set; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ListingHistoryItemViewModel
|
||||||
|
{
|
||||||
|
public int Id { get; set; }
|
||||||
|
public PluginListingRequestStatus Status { get; set; }
|
||||||
|
public string ReleaseNote { get; set; } = null!;
|
||||||
|
public DateTimeOffset SubmittedAt { get; set; }
|
||||||
|
public DateTimeOffset? ReviewedAt { get; set; }
|
||||||
|
public string? RejectionReason { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
public enum PluginListingRequestStatus
|
public enum PluginListingRequestStatus
|
||||||
{
|
{
|
||||||
Pending,
|
Pending,
|
||||||
|
|||||||
@ -0,0 +1,95 @@
|
|||||||
|
#nullable enable
|
||||||
|
using Microsoft.AspNetCore.Antiforgery;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||||
|
using Microsoft.AspNetCore.Mvc.Filters;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
||||||
|
using PluginBuilder.Controllers;
|
||||||
|
|
||||||
|
namespace PluginBuilder.Filters;
|
||||||
|
|
||||||
|
public class UIControllerAntiforgeryTokenAttribute :
|
||||||
|
Attribute,
|
||||||
|
IFilterMetadata,
|
||||||
|
IAntiforgeryPolicy,
|
||||||
|
IAsyncAuthorizationFilter,
|
||||||
|
IAsyncAlwaysRunResultFilter,
|
||||||
|
IOrderedFilter
|
||||||
|
{
|
||||||
|
public int Order => 1000;
|
||||||
|
|
||||||
|
public async Task OnAuthorizationAsync(AuthorizationFilterContext context)
|
||||||
|
{
|
||||||
|
if (context.Result is AntiforgeryValidationFailedResult)
|
||||||
|
{
|
||||||
|
AddErrorDetails(context.HttpContext);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var antiforgery = context.HttpContext.RequestServices.GetService<IAntiforgery>();
|
||||||
|
if (
|
||||||
|
antiforgery is not null &&
|
||||||
|
context.IsEffectivePolicy<IAntiforgeryPolicy>(this) &&
|
||||||
|
ShouldValidate(context))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await antiforgery.ValidateRequestAsync(context.HttpContext);
|
||||||
|
}
|
||||||
|
catch (AntiforgeryValidationException)
|
||||||
|
{
|
||||||
|
context.Result = new AntiforgeryValidationFailedResult();
|
||||||
|
AddErrorDetails(context.HttpContext);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task OnResultExecutionAsync(ResultExecutingContext context, ResultExecutionDelegate next)
|
||||||
|
{
|
||||||
|
if (context.Result is AntiforgeryValidationFailedResult)
|
||||||
|
AddErrorDetails(context.HttpContext);
|
||||||
|
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddErrorDetails(HttpContext context, string? message = null)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(message))
|
||||||
|
{
|
||||||
|
context.Items[UIErrorController.ErrorDetailsKey] = message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.Items.TryGetValue(UIErrorController.ErrorDetailsKey, out var existing) &&
|
||||||
|
existing is string existingMessage &&
|
||||||
|
!string.IsNullOrWhiteSpace(existingMessage))
|
||||||
|
return;
|
||||||
|
|
||||||
|
context.Items[UIErrorController.ErrorDetailsKey] = "CSRF token validation failed.";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldValidate(AuthorizationFilterContext context)
|
||||||
|
{
|
||||||
|
var isUi = IsUi(context);
|
||||||
|
if (isUi is not true)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
var method = context.HttpContext.Request.Method;
|
||||||
|
return !HttpMethods.IsGet(method) && !HttpMethods.IsHead(method) && !HttpMethods.IsTrace(method) && !HttpMethods.IsOptions(method);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool? IsUi(AuthorizationFilterContext context)
|
||||||
|
{
|
||||||
|
if (context.ActionDescriptor is not ControllerActionDescriptor controllerActionDescriptor)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if (controllerActionDescriptor.ControllerName.StartsWith("UI", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
if (controllerActionDescriptor.ControllerName.StartsWith("Greenfield", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return typeof(Controller).IsAssignableFrom(controllerActionDescriptor.ControllerTypeInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,4 +4,5 @@ public class Policies
|
|||||||
{
|
{
|
||||||
public const string OwnPlugin = "OwnPlugin";
|
public const string OwnPlugin = "OwnPlugin";
|
||||||
public const string PublicApiRateLimit = "PublicApiRateLimit";
|
public const string PublicApiRateLimit = "PublicApiRateLimit";
|
||||||
|
public const string BtcMapsSubmitRateLimit = "BtcMapsSubmitRateLimit";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,7 @@ using PluginBuilder.Authentication;
|
|||||||
using PluginBuilder.Configuration;
|
using PluginBuilder.Configuration;
|
||||||
using PluginBuilder.Controllers.Logic;
|
using PluginBuilder.Controllers.Logic;
|
||||||
using PluginBuilder.DataModels;
|
using PluginBuilder.DataModels;
|
||||||
|
using PluginBuilder.Filters;
|
||||||
using PluginBuilder.HostedServices;
|
using PluginBuilder.HostedServices;
|
||||||
using PluginBuilder.Hubs;
|
using PluginBuilder.Hubs;
|
||||||
using PluginBuilder.Services;
|
using PluginBuilder.Services;
|
||||||
@ -143,7 +144,10 @@ public class Program
|
|||||||
|
|
||||||
public void AddServices(IConfiguration configuration, IServiceCollection services, IHostEnvironment env)
|
public void AddServices(IConfiguration configuration, IServiceCollection services, IHostEnvironment env)
|
||||||
{
|
{
|
||||||
services.AddControllersWithViews()
|
services.AddControllersWithViews(options =>
|
||||||
|
{
|
||||||
|
options.Filters.Add(new UIControllerAntiforgeryTokenAttribute());
|
||||||
|
})
|
||||||
.AddRazorRuntimeCompilation()
|
.AddRazorRuntimeCompilation()
|
||||||
.AddRazorOptions(options =>
|
.AddRazorOptions(options =>
|
||||||
{
|
{
|
||||||
@ -207,6 +211,28 @@ public class Program
|
|||||||
client.DefaultRequestHeaders.Authorization =
|
client.DefaultRequestHeaders.Authorization =
|
||||||
new AuthenticationHeaderValue("Bearer", token);
|
new AuthenticationHeaderValue("Bearer", token);
|
||||||
});
|
});
|
||||||
|
services.AddHttpClient(HttpClientNames.BtcMapsDirectory, client =>
|
||||||
|
{
|
||||||
|
// Per-call timeout caps a single GitHub round-trip at 15s. The directory
|
||||||
|
// submission makes ~5-7 GitHub calls sequentially; with the default 100s
|
||||||
|
// timeout a hung remote could pin the request for ~10min and tie up a
|
||||||
|
// rate-limit slot. 15s per call keeps the worst case bounded.
|
||||||
|
client.BaseAddress = new Uri("https://api.github.com/");
|
||||||
|
client.DefaultRequestHeaders.Add("User-Agent", "PluginBuilder-BtcMaps/1.0");
|
||||||
|
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(15);
|
||||||
|
});
|
||||||
|
services.AddHttpClient(HttpClientNames.BtcMap, client =>
|
||||||
|
{
|
||||||
|
// BTC Map import RPC is a single JSON-RPC 2.0 dispatch endpoint.
|
||||||
|
// Per-call timeout caps a single round-trip at 15s, matching the
|
||||||
|
// BtcMapsDirectory budget so a hung remote can't pin the request
|
||||||
|
// longer than the per-IP rate-limit window.
|
||||||
|
client.DefaultRequestHeaders.Add("User-Agent", "PluginBuilder-BtcMap/1.0");
|
||||||
|
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(15);
|
||||||
|
});
|
||||||
services.AddHttpClient(HttpClientNames.GitLab, client =>
|
services.AddHttpClient(HttpClientNames.GitLab, client =>
|
||||||
{
|
{
|
||||||
client.BaseAddress = new Uri("https://gitlab.com/api/v4/");
|
client.BaseAddress = new Uri("https://gitlab.com/api/v4/");
|
||||||
@ -237,6 +263,7 @@ public class Program
|
|||||||
});
|
});
|
||||||
services.AddScoped<PluginOwnershipService>();
|
services.AddScoped<PluginOwnershipService>();
|
||||||
services.AddScoped<VersionLifecycleService>();
|
services.AddScoped<VersionLifecycleService>();
|
||||||
|
services.AddSingleton<BtcMapsService>();
|
||||||
|
|
||||||
services.AddRateLimiter(options =>
|
services.AddRateLimiter(options =>
|
||||||
{
|
{
|
||||||
@ -262,6 +289,23 @@ public class Program
|
|||||||
QueueLimit = 0
|
QueueLimit = 0
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
options.AddPolicy(Policies.BtcMapsSubmitRateLimit, httpContext =>
|
||||||
|
{
|
||||||
|
// Per-source-IP fixed window: 3 submissions per 24h. Caps automation
|
||||||
|
// abuse of /apis/btcmaps/v1/submit without throttling honest single
|
||||||
|
// submissions from a merchant. Tightened from 5/24h with the
|
||||||
|
// multi-vendor BTC Map import-RPC lane (PR #226) since that path
|
||||||
|
// forwards into a moderator review queue and rate-limit is the
|
||||||
|
// primary spam control on the public endpoint.
|
||||||
|
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||||
|
return RateLimitPartition.GetFixedWindowLimiter(clientIp, _ => new FixedWindowRateLimiterOptions
|
||||||
|
{
|
||||||
|
PermitLimit = 3,
|
||||||
|
Window = TimeSpan.FromHours(24),
|
||||||
|
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||||
|
QueueLimit = 0
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddOutputCache(options =>
|
services.AddOutputCache(options =>
|
||||||
|
|||||||
668
PluginBuilder/Services/BtcMapsService.cs
Normal file
668
PluginBuilder/Services/BtcMapsService.cs
Normal file
@ -0,0 +1,668 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using System.Net.Http.Headers;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Encodings.Web;
|
||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using PluginBuilder.APIModels;
|
||||||
|
using PluginBuilder.DataModels;
|
||||||
|
|
||||||
|
namespace PluginBuilder.Services;
|
||||||
|
|
||||||
|
public sealed class BtcMapsService
|
||||||
|
{
|
||||||
|
private const string DefaultDirectoryRepo = "btcpayserver/directory.btcpayserver.org";
|
||||||
|
private const string DefaultDirectoryMerchantsPath = "src/data/merchants.json";
|
||||||
|
|
||||||
|
private static readonly HashSet<string> ValidTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"merchants", "apps", "hosted-btcpay", "non-profits"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly HashSet<string> ValidMerchantSubTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
"3d-printing", "adult", "appliances-furniture", "art", "books",
|
||||||
|
"cryptocurrency-paraphernalia", "domains-hosting-vpns", "education",
|
||||||
|
"electronics", "fashion", "food", "gambling", "gift-cards",
|
||||||
|
"health-household", "holiday-travel", "jewelry", "payment-services",
|
||||||
|
"pets", "services", "software-video-games", "sports", "tools"
|
||||||
|
};
|
||||||
|
|
||||||
|
// ISO 3166-1 alpha-2 codes derived from CultureInfo at startup. Cached because
|
||||||
|
// CultureInfo.GetCultures + RegionInfo enumeration is non-trivial and the set
|
||||||
|
// is stable for the process lifetime.
|
||||||
|
private static readonly HashSet<string> Iso3166Alpha2 = BuildIsoAlpha2Set();
|
||||||
|
|
||||||
|
private static HashSet<string> BuildIsoAlpha2Set()
|
||||||
|
{
|
||||||
|
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||||
|
foreach (var culture in CultureInfo.GetCultures(CultureTypes.SpecificCultures))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var region = new RegionInfo(culture.Name);
|
||||||
|
if (region.TwoLetterISORegionName.Length == 2 &&
|
||||||
|
region.TwoLetterISORegionName.All(c => c is >= 'A' and <= 'Z'))
|
||||||
|
{
|
||||||
|
set.Add(region.TwoLetterISORegionName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (ArgumentException)
|
||||||
|
{
|
||||||
|
// Some neutral cultures throw; skip.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return set;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly IConfiguration _configuration;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
|
private readonly ILogger<BtcMapsService> _logger;
|
||||||
|
|
||||||
|
public BtcMapsService(
|
||||||
|
IConfiguration configuration,
|
||||||
|
IHttpClientFactory httpClientFactory,
|
||||||
|
ILogger<BtcMapsService> logger)
|
||||||
|
{
|
||||||
|
_configuration = configuration;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class DirectoryTokenMissingException : Exception
|
||||||
|
{
|
||||||
|
public DirectoryTokenMissingException() : base("BTCMAPS:DirectoryGithubToken is not configured.") { }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class BtcMapTokenMissingException : Exception
|
||||||
|
{
|
||||||
|
public BtcMapTokenMissingException() : base("BTCMAPS:BtcMapImportToken is not configured.") { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const string DefaultBtcMapImportEndpoint = "https://api.btcmap.org/rpc";
|
||||||
|
private const string BtcMapImportOrigin = "btcpayserver";
|
||||||
|
private const string BtcMapImportMethod = "submit_place";
|
||||||
|
|
||||||
|
public IReadOnlyList<ValidationError> Validate(BtcMapsSubmitRequest request)
|
||||||
|
{
|
||||||
|
var errors = new List<ValidationError>();
|
||||||
|
|
||||||
|
// Name is the only field both lanes need - btcmap submit_place requires it
|
||||||
|
// as part of the params payload, and the directory merchants.json keys
|
||||||
|
// entries by name.
|
||||||
|
var name = (request.Name ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrEmpty(name) || name.Length > 200)
|
||||||
|
errors.Add(new ValidationError(nameof(request.Name), "Required, 1-200 characters."));
|
||||||
|
|
||||||
|
// Directory-lane required fields. Skip when the caller opted out of the
|
||||||
|
// directory submission so a btcmap-only call doesn't need to fill in
|
||||||
|
// unrelated directory metadata.
|
||||||
|
if (request.SubmitToDirectory)
|
||||||
|
{
|
||||||
|
var url = (request.Url ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrEmpty(url))
|
||||||
|
errors.Add(new ValidationError(nameof(request.Url), "Required when SubmitToDirectory=true."));
|
||||||
|
else if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed) || parsed.Scheme != Uri.UriSchemeHttps)
|
||||||
|
errors.Add(new ValidationError(nameof(request.Url), "Must be a valid https:// URL."));
|
||||||
|
|
||||||
|
var description = (request.Description ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrEmpty(description) || description.Length > 1000)
|
||||||
|
errors.Add(new ValidationError(nameof(request.Description), "Required when SubmitToDirectory=true. 1-1000 characters."));
|
||||||
|
|
||||||
|
var type = (request.Type ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrEmpty(type) || !ValidTypes.Contains(type))
|
||||||
|
errors.Add(new ValidationError(nameof(request.Type),
|
||||||
|
$"Required when SubmitToDirectory=true. One of: {string.Join(", ", ValidTypes)}."));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.SubType))
|
||||||
|
{
|
||||||
|
var subType = request.SubType.Trim();
|
||||||
|
if (string.Equals(type, "merchants", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
!ValidMerchantSubTypes.Contains(subType))
|
||||||
|
{
|
||||||
|
errors.Add(new ValidationError(nameof(request.SubType),
|
||||||
|
"Invalid merchant subtype."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Country format check applies to either lane. GLOBAL is the directory's
|
||||||
|
// pseudonym for online-only / multi-region merchants but it is NOT a valid
|
||||||
|
// physical-place country code for the btcmap directory map (every place
|
||||||
|
// there is geocoded). Reject GLOBAL when the btcmap lane is on; allow it
|
||||||
|
// when only the directory lane is in play.
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Country))
|
||||||
|
{
|
||||||
|
var country = request.Country.Trim();
|
||||||
|
if (country == "GLOBAL")
|
||||||
|
{
|
||||||
|
if (request.SubmitToBtcMap)
|
||||||
|
{
|
||||||
|
errors.Add(new ValidationError(nameof(request.Country),
|
||||||
|
"Country=GLOBAL is incompatible with SubmitToBtcMap=true (btcmap places are physical locations). Use an ISO 3166-1 alpha-2 code or omit Country."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (!Iso3166Alpha2.Contains(country))
|
||||||
|
{
|
||||||
|
errors.Add(new ValidationError(nameof(request.Country),
|
||||||
|
"Must be ISO 3166-1 alpha-2 or GLOBAL."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.OnionUrl))
|
||||||
|
{
|
||||||
|
if (!Uri.TryCreate(request.OnionUrl.Trim(), UriKind.Absolute, out var onionUri) ||
|
||||||
|
(onionUri.Scheme != Uri.UriSchemeHttp && onionUri.Scheme != Uri.UriSchemeHttps) ||
|
||||||
|
!onionUri.Host.EndsWith(".onion", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
errors.Add(new ValidationError(nameof(request.OnionUrl),
|
||||||
|
"Must be an http(s) .onion URL."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// BTC Map import RPC fields become mandatory only when the caller
|
||||||
|
// opts into that lane via SubmitToBtcMap=true. Directory-only callers
|
||||||
|
// (the existing PR #224 shape) are unaffected.
|
||||||
|
if (request.SubmitToBtcMap)
|
||||||
|
{
|
||||||
|
if (!request.Lat.HasValue || request.Lat.Value is < -90 or > 90 || double.IsNaN(request.Lat.Value))
|
||||||
|
errors.Add(new ValidationError(nameof(request.Lat),
|
||||||
|
"Required when SubmitToBtcMap=true. Must be in [-90, 90]."));
|
||||||
|
|
||||||
|
if (!request.Lon.HasValue || request.Lon.Value is < -180 or > 180 || double.IsNaN(request.Lon.Value))
|
||||||
|
errors.Add(new ValidationError(nameof(request.Lon),
|
||||||
|
"Required when SubmitToBtcMap=true. Must be in [-180, 180]."));
|
||||||
|
|
||||||
|
var category = (request.Category ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrEmpty(category) || category.Length > 50 ||
|
||||||
|
!category.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-' or '_'))
|
||||||
|
{
|
||||||
|
errors.Add(new ValidationError(nameof(request.Category),
|
||||||
|
"Required when SubmitToBtcMap=true. Short lowercase identifier (a-z, 0-9, -, _; max 50 chars)."));
|
||||||
|
}
|
||||||
|
|
||||||
|
var externalId = (request.ExternalId ?? string.Empty).Trim();
|
||||||
|
if (string.IsNullOrEmpty(externalId) || externalId.Length > 200)
|
||||||
|
errors.Add(new ValidationError(nameof(request.ExternalId),
|
||||||
|
"Required when SubmitToBtcMap=true. 1-200 characters."));
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BtcMapsBtcMapResult> SubmitToBtcMapAsync(
|
||||||
|
BtcMapsSubmitRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var token = _configuration["BTCMAPS:BtcMapImportToken"];
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
throw new BtcMapTokenMissingException();
|
||||||
|
|
||||||
|
// Bearer tokens MUST NOT cross the wire over http://; an operator-
|
||||||
|
// misconfigured endpoint would silently leak the scoped token to
|
||||||
|
// anyone on the network path. Parse the configured value into an
|
||||||
|
// absolute https URI before we even create the request, so a bad
|
||||||
|
// BTCMAPS:BtcMapImportEndpoint fails loudly with the offending
|
||||||
|
// value in the message instead of producing a quiet credential leak.
|
||||||
|
var endpoint = (_configuration["BTCMAPS:BtcMapImportEndpoint"] ?? DefaultBtcMapImportEndpoint).Trim();
|
||||||
|
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) ||
|
||||||
|
endpointUri.Scheme != Uri.UriSchemeHttps)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"BTCMAPS:BtcMapImportEndpoint must be an absolute https:// URL (got '{endpoint}').");
|
||||||
|
}
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient(HttpClientNames.BtcMap);
|
||||||
|
|
||||||
|
// BTC Map import-RPC takes a JSON-RPC 2.0 envelope at /rpc with method=submit_place.
|
||||||
|
// Required params: origin, external_id, lat, lon, category, name. extra_fields uses
|
||||||
|
// the documented first-class keys (phone, website, description) and the osm:<tag_name>
|
||||||
|
// convention for granular OSM tags (osm:addr:*).
|
||||||
|
var extraFields = new Dictionary<string, object?>();
|
||||||
|
// First-class btcmap fields (plain keys per rest/v4/places.md).
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Url)) extraFields["website"] = request.Url.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Description)) extraFields["description"] = request.Description.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Phone)) extraFields["phone"] = request.Phone.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Email)) extraFields["email"] = request.Email.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Twitter))
|
||||||
|
{
|
||||||
|
// btcmap's `twitter` field is documented as a URL. Normalize the @handle
|
||||||
|
// shape the rest of the API accepts (with or without leading @) into
|
||||||
|
// the URL form the directory map expects.
|
||||||
|
var t = request.Twitter.Trim();
|
||||||
|
var handle = t.StartsWith("@") ? t[1..] : t;
|
||||||
|
extraFields["twitter"] = handle.StartsWith("http", StringComparison.OrdinalIgnoreCase)
|
||||||
|
? handle
|
||||||
|
: $"https://x.com/{handle}";
|
||||||
|
}
|
||||||
|
// OSM custom tags use osm:<tag_name> per the same doc.
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.HouseNumber)) extraFields["osm:addr:housenumber"] = request.HouseNumber.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Street)) extraFields["osm:addr:street"] = request.Street.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.City)) extraFields["osm:addr:city"] = request.City.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Postcode)) extraFields["osm:addr:postcode"] = request.Postcode.Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Country)) extraFields["osm:addr:country"] = request.Country.Trim();
|
||||||
|
// Payment-rail flags. Plugin sets per the store's enabled rails - omit
|
||||||
|
// when null or false so a Lightning-only store doesn't claim on-chain
|
||||||
|
// support (or vice versa).
|
||||||
|
if (request.AcceptsOnchain == true) extraFields["osm:payment:onchain"] = "yes";
|
||||||
|
if (request.AcceptsLightning == true) extraFields["osm:payment:lightning"] = "yes";
|
||||||
|
|
||||||
|
var rpcParams = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["origin"] = BtcMapImportOrigin,
|
||||||
|
["external_id"] = request.ExternalId!.Trim(),
|
||||||
|
["lat"] = request.Lat!.Value,
|
||||||
|
["lon"] = request.Lon!.Value,
|
||||||
|
["category"] = request.Category!.Trim().ToLowerInvariant(),
|
||||||
|
["name"] = request.Name!.Trim(),
|
||||||
|
["extra_fields"] = extraFields
|
||||||
|
};
|
||||||
|
|
||||||
|
var envelope = new Dictionary<string, object?>
|
||||||
|
{
|
||||||
|
["jsonrpc"] = "2.0",
|
||||||
|
["method"] = BtcMapImportMethod,
|
||||||
|
["params"] = rpcParams,
|
||||||
|
["id"] = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
using var req = new HttpRequestMessage(HttpMethod.Post, endpointUri);
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
req.Content = new StringContent(JsonSerializer.Serialize(envelope), Encoding.UTF8, "application/json");
|
||||||
|
|
||||||
|
using var response = await client.SendAsync(req, cancellationToken);
|
||||||
|
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
throw new HttpRequestException($"BtcMap RPC {(int)response.StatusCode} {endpointUri}: {body}");
|
||||||
|
|
||||||
|
using var doc = JsonDocument.Parse(body);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
// JSON-RPC 2.0 response shape: either {result: {...}} on success or {error: {...}}.
|
||||||
|
// 2xx status with an error body is a legal JSON-RPC outcome we must surface.
|
||||||
|
if (root.TryGetProperty("error", out var errorElement))
|
||||||
|
{
|
||||||
|
var errorJson = errorElement.GetRawText();
|
||||||
|
throw new HttpRequestException($"BtcMap RPC error response: {errorJson}");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!root.TryGetProperty("result", out var result))
|
||||||
|
throw new HttpRequestException($"BtcMap RPC missing result: {body}");
|
||||||
|
|
||||||
|
return new BtcMapsBtcMapResult
|
||||||
|
{
|
||||||
|
Id = result.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.Number ? id.GetInt64() : null,
|
||||||
|
Origin = result.TryGetProperty("origin", out var origin) ? origin.GetString() : null,
|
||||||
|
ExternalId = result.TryGetProperty("external_id", out var ext) ? ext.GetString() : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<BtcMapsDirectoryResult> SubmitToDirectoryAsync(
|
||||||
|
BtcMapsSubmitRequest request,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var token = _configuration["BTCMAPS:DirectoryGithubToken"];
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
throw new DirectoryTokenMissingException();
|
||||||
|
|
||||||
|
var repo = _configuration["BTCMAPS:DirectoryRepo"] ?? DefaultDirectoryRepo;
|
||||||
|
var merchantsPath = _configuration["BTCMAPS:DirectoryMerchantsPath"] ?? DefaultDirectoryMerchantsPath;
|
||||||
|
|
||||||
|
var client = _httpClientFactory.CreateClient(HttpClientNames.BtcMapsDirectory);
|
||||||
|
// Auth is per-call: the named-client registration sets BaseAddress + User-Agent
|
||||||
|
// + Accept + Timeout, but the BTCMAPS token is distinct from the global
|
||||||
|
// PluginBuilder GitHub token and must not be baked into the singleton handler.
|
||||||
|
using var authClient = new HttpRequestAuth(client, token);
|
||||||
|
|
||||||
|
var repoInfo = await GetJsonAsync(authClient, $"repos/{repo}", cancellationToken);
|
||||||
|
var defaultBranch = repoInfo.GetProperty("default_branch").GetString()
|
||||||
|
?? throw new InvalidOperationException("default_branch missing");
|
||||||
|
|
||||||
|
var fileInfo = await GetJsonAsync(
|
||||||
|
authClient,
|
||||||
|
$"repos/{repo}/contents/{merchantsPath}?ref={Uri.EscapeDataString(defaultBranch)}",
|
||||||
|
cancellationToken);
|
||||||
|
var contentB64 = fileInfo.GetProperty("content").GetString() ?? string.Empty;
|
||||||
|
var fileSha = fileInfo.GetProperty("sha").GetString() ?? string.Empty;
|
||||||
|
var currentJson = Encoding.UTF8.GetString(Convert.FromBase64String(contentB64.Replace("\n", string.Empty)));
|
||||||
|
|
||||||
|
var merchants = JsonSerializer.Deserialize<List<JsonElement>>(currentJson)
|
||||||
|
?? throw new InvalidOperationException("merchants.json must be a JSON array");
|
||||||
|
|
||||||
|
var normalizedUrl = NormalizeUrl(request.Url!);
|
||||||
|
foreach (var existing in merchants)
|
||||||
|
{
|
||||||
|
if (existing.TryGetProperty("url", out var existingUrl) &&
|
||||||
|
existingUrl.ValueKind == JsonValueKind.String &&
|
||||||
|
NormalizeUrl(existingUrl.GetString() ?? string.Empty) == normalizedUrl)
|
||||||
|
{
|
||||||
|
var existingName = existing.TryGetProperty("name", out var n) ? n.GetString() : "(unknown)";
|
||||||
|
return new BtcMapsDirectoryResult { Skipped = $"duplicate-url:{existingName}" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic branch name derived from the normalized URL. Two concurrent
|
||||||
|
// submissions of the same URL collide on the git/refs create instead of
|
||||||
|
// racing through preflight and opening duplicate PRs.
|
||||||
|
var branchName = BuildBranchName(request.Name!, normalizedUrl);
|
||||||
|
var marker = BuildUrlMarker(normalizedUrl);
|
||||||
|
|
||||||
|
var branchRef = await GetJsonAsync(
|
||||||
|
authClient,
|
||||||
|
$"repos/{repo}/git/ref/heads/{Uri.EscapeDataString(defaultBranch)}",
|
||||||
|
cancellationToken);
|
||||||
|
var baseSha = branchRef.GetProperty("object").GetProperty("sha").GetString()
|
||||||
|
?? throw new InvalidOperationException("base sha missing");
|
||||||
|
|
||||||
|
var newEntry = BuildMerchantEntry(request);
|
||||||
|
var updated = merchants
|
||||||
|
.Select(e => (JsonElement?)e)
|
||||||
|
.Append(newEntry)
|
||||||
|
.OrderBy(e => e!.Value.TryGetProperty("name", out var n) ? n.GetString() : string.Empty,
|
||||||
|
StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Select(e => e!.Value)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// Use UnsafeRelaxedJsonEscaping so non-ASCII codepoints and HTML-only "unsafe"
|
||||||
|
// chars (`&`, `'`, `<`, `>`) are written raw in the file, matching the upstream
|
||||||
|
// merchants.json convention. The default JavaScriptEncoder is HTML-safe and
|
||||||
|
// would re-encode every entry containing `'` or non-ASCII as `\uXXXX`, which
|
||||||
|
// shows up as a noisy full-file diff on every append.
|
||||||
|
var updatedJson = JsonSerializer.Serialize(updated, new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
|
}) + "\n";
|
||||||
|
|
||||||
|
var refCreateResponse = await PostJsonAllowConflictAsync(
|
||||||
|
authClient,
|
||||||
|
$"repos/{repo}/git/refs",
|
||||||
|
new { @ref = $"refs/heads/{branchName}", sha = baseSha },
|
||||||
|
cancellationToken);
|
||||||
|
if (refCreateResponse.IsConflict)
|
||||||
|
{
|
||||||
|
// Branch already exists. Look up the open PR keyed by the URL marker;
|
||||||
|
// if one is open, return its details; otherwise this is a stuck-branch
|
||||||
|
// from a prior failed run and we cannot safely reuse it.
|
||||||
|
var openPrSearch = await GetJsonAsync(
|
||||||
|
authClient,
|
||||||
|
$"search/issues?q={Uri.EscapeDataString($"repo:{repo} is:pr is:open in:body \"{marker}\"")}",
|
||||||
|
cancellationToken);
|
||||||
|
if (openPrSearch.TryGetProperty("total_count", out var totalCount) && totalCount.GetInt32() > 0)
|
||||||
|
{
|
||||||
|
var firstItem = openPrSearch.GetProperty("items")[0];
|
||||||
|
return new BtcMapsDirectoryResult
|
||||||
|
{
|
||||||
|
Skipped = "duplicate-open-pr",
|
||||||
|
PrUrl = firstItem.TryGetProperty("html_url", out var h) ? h.GetString() : null,
|
||||||
|
PrNumber = firstItem.TryGetProperty("number", out var n) ? n.GetInt32() : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return new BtcMapsDirectoryResult { Skipped = "branch-exists-no-open-pr", Branch = branchName };
|
||||||
|
}
|
||||||
|
|
||||||
|
await PutJsonAsync(authClient, $"repos/{repo}/contents/{merchantsPath}",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
message = $"Add {request.Name}",
|
||||||
|
content = Convert.ToBase64String(Encoding.UTF8.GetBytes(updatedJson)),
|
||||||
|
sha = fileSha,
|
||||||
|
branch = branchName
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
var prBody = BuildPrBody(request, marker);
|
||||||
|
var prResponse = await PostJsonAsync(authClient, $"repos/{repo}/pulls",
|
||||||
|
new
|
||||||
|
{
|
||||||
|
title = $"Add {request.Name}",
|
||||||
|
head = branchName,
|
||||||
|
@base = defaultBranch,
|
||||||
|
body = prBody
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return new BtcMapsDirectoryResult
|
||||||
|
{
|
||||||
|
PrUrl = prResponse.GetProperty("html_url").GetString(),
|
||||||
|
PrNumber = prResponse.GetProperty("number").GetInt32(),
|
||||||
|
Branch = branchName
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonElement BuildMerchantEntry(BtcMapsSubmitRequest request)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
using (var w = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = false }))
|
||||||
|
{
|
||||||
|
w.WriteStartObject();
|
||||||
|
w.WriteString("name", request.Name!.Trim());
|
||||||
|
w.WriteString("url", request.Url!.Trim());
|
||||||
|
w.WriteString("description", request.Description!.Trim());
|
||||||
|
// type + subType are validated case-insensitively above
|
||||||
|
// (ValidTypes / ValidMerchantSubTypes use OrdinalIgnoreCase) but
|
||||||
|
// the upstream merchants.json convention is lowercase. Normalize
|
||||||
|
// on write so a submission of "Merchants" / "Books" lands as
|
||||||
|
// "merchants" / "books" in the file. Country uses a case-sensitive
|
||||||
|
// Ordinal set so the validator already rejects non-uppercase.
|
||||||
|
w.WriteString("type", request.Type!.Trim().ToLowerInvariant());
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.SubType))
|
||||||
|
w.WriteString("subType", request.SubType.Trim().ToLowerInvariant());
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Country))
|
||||||
|
w.WriteString("country", request.Country.Trim());
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Twitter))
|
||||||
|
{
|
||||||
|
var t = request.Twitter.Trim();
|
||||||
|
w.WriteString("twitter", t.StartsWith("@") ? t : "@" + t);
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Github))
|
||||||
|
w.WriteString("github", request.Github.Trim());
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.OnionUrl))
|
||||||
|
w.WriteString("onionUrl", request.OnionUrl.Trim());
|
||||||
|
w.WriteEndObject();
|
||||||
|
}
|
||||||
|
ms.Position = 0;
|
||||||
|
using var doc = JsonDocument.Parse(ms);
|
||||||
|
return doc.RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPrBody(BtcMapsSubmitRequest request, string urlMarker)
|
||||||
|
{
|
||||||
|
// User-supplied fields are wrapped in inline code spans so a doctored merchant
|
||||||
|
// name (e.g. `[click here](https://attacker.example)`) can't render as a clickable
|
||||||
|
// link in the maintainer-facing PR description. The URL is its own line and is
|
||||||
|
// displayed via a plain markdown link with a sanitized label so the maintainer
|
||||||
|
// sees the bare URL, not a renamed target.
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine("Automated submission from the BTCPay Server plugin-builder `/apis/btcmaps/v1/submit` endpoint.");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"- **Name:** {EscapeInlineCode(request.Name)}");
|
||||||
|
sb.AppendLine($"- **URL:** <{request.Url}>");
|
||||||
|
sb.AppendLine($"- **Type:** {EscapeInlineCode(request.Type)}{(string.IsNullOrWhiteSpace(request.SubType) ? string.Empty : " / " + EscapeInlineCode(request.SubType))}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Country)) sb.AppendLine($"- **Country:** {EscapeInlineCode(request.Country.Trim())}");
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Twitter))
|
||||||
|
{
|
||||||
|
// The Twitter handle is rendered inside an inline code span so a hostile
|
||||||
|
// value like `]( <evil-url> )` cannot escape into an active link. The
|
||||||
|
// maintainer can copy the handle and visit manually.
|
||||||
|
var raw = request.Twitter.Trim();
|
||||||
|
var handle = raw.StartsWith("@") ? raw[1..] : raw;
|
||||||
|
sb.AppendLine($"- **Twitter:** {EscapeInlineCode("@" + handle)}");
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(request.Github)) sb.AppendLine($"- **GitHub:** {EscapeInlineCode(request.Github)}");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("**Description:**");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("```");
|
||||||
|
sb.AppendLine(request.Description?.Replace("```", "```") ?? string.Empty);
|
||||||
|
sb.AppendLine("```");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("_Please review before merge - this PR was opened programmatically by a BTCMap-plugin merchant submission, not by a maintainer._");
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine($"<!-- {urlMarker} -->");
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap user input as an inline code span. Use enough backticks to escape any
|
||||||
|
// backticks the input contains (CommonMark inline-code-fence rule).
|
||||||
|
private static string EscapeInlineCode(string? value)
|
||||||
|
{
|
||||||
|
var s = value ?? string.Empty;
|
||||||
|
if (s.Length == 0) return "``";
|
||||||
|
var longestRun = 0;
|
||||||
|
var current = 0;
|
||||||
|
foreach (var c in s)
|
||||||
|
{
|
||||||
|
if (c == '`') { current++; if (current > longestRun) longestRun = current; }
|
||||||
|
else current = 0;
|
||||||
|
}
|
||||||
|
var fence = new string('`', longestRun + 1);
|
||||||
|
// Pad with spaces if the value starts or ends with a backtick (CommonMark rule).
|
||||||
|
var needsPad = s.StartsWith("`") || s.EndsWith("`");
|
||||||
|
return needsPad ? $"{fence} {s} {fence}" : $"{fence}{s}{fence}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildUrlMarker(string normalizedUrl) =>
|
||||||
|
$"btcmaps-submit:url={normalizedUrl}";
|
||||||
|
|
||||||
|
public static string NormalizeUrl(string url)
|
||||||
|
{
|
||||||
|
// Normalize for duplicate detection without lying about case-sensitive parts.
|
||||||
|
// Scheme + host get lower-cased (DNS is case-insensitive, scheme is too); path
|
||||||
|
// and query are preserved as-is. Trailing slash is trimmed only when the path
|
||||||
|
// is the bare root, since /foo/ and /foo are sometimes distinct on real servers.
|
||||||
|
var trimmed = (url ?? string.Empty).Trim();
|
||||||
|
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var parsed))
|
||||||
|
return trimmed.TrimEnd('/');
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.Append(parsed.Scheme.ToLowerInvariant());
|
||||||
|
sb.Append("://");
|
||||||
|
sb.Append(parsed.Host.ToLowerInvariant());
|
||||||
|
if (!parsed.IsDefaultPort)
|
||||||
|
{
|
||||||
|
sb.Append(':');
|
||||||
|
sb.Append(parsed.Port);
|
||||||
|
}
|
||||||
|
var path = parsed.AbsolutePath;
|
||||||
|
if (path == "/")
|
||||||
|
sb.Append(path);
|
||||||
|
else
|
||||||
|
sb.Append(path.TrimEnd('/'));
|
||||||
|
if (!string.IsNullOrEmpty(parsed.Query))
|
||||||
|
sb.Append(parsed.Query);
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic branch name from the normalized URL. Same URL always produces
|
||||||
|
// the same branch; second concurrent submission collides on the git/refs create
|
||||||
|
// and the controller surfaces the duplicate-open-PR shape.
|
||||||
|
public static string BuildBranchName(string name, string normalizedUrl)
|
||||||
|
{
|
||||||
|
var slug = Slugify(name);
|
||||||
|
var hash = SHA1.HashData(Encoding.UTF8.GetBytes(normalizedUrl));
|
||||||
|
var suffix = Convert.ToHexString(hash)[..8].ToLowerInvariant();
|
||||||
|
return $"btcmaps/{slug}-{suffix}";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string Slugify(string input)
|
||||||
|
{
|
||||||
|
var chars = new StringBuilder();
|
||||||
|
var lastWasDash = true;
|
||||||
|
foreach (var c in input.ToLowerInvariant())
|
||||||
|
{
|
||||||
|
if (c is >= 'a' and <= 'z' or >= '0' and <= '9')
|
||||||
|
{
|
||||||
|
chars.Append(c);
|
||||||
|
lastWasDash = false;
|
||||||
|
}
|
||||||
|
else if (!lastWasDash)
|
||||||
|
{
|
||||||
|
chars.Append('-');
|
||||||
|
lastWasDash = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var result = chars.ToString().Trim('-');
|
||||||
|
if (result.Length > 40) result = result[..40].TrimEnd('-');
|
||||||
|
return result.Length == 0 ? "merchant" : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lightweight wrapper that re-attaches the per-request Authorization header on
|
||||||
|
// each call. The named-client handler is reused (socket pool, factory rotation),
|
||||||
|
// but auth stays out of the singleton handler.
|
||||||
|
private sealed class HttpRequestAuth : IDisposable
|
||||||
|
{
|
||||||
|
public HttpClient Client { get; }
|
||||||
|
public string Token { get; }
|
||||||
|
public HttpRequestAuth(HttpClient client, string token) { Client = client; Token = token; }
|
||||||
|
public void Dispose() { /* HttpClient is owned by IHttpClientFactory; do not dispose */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static HttpRequestMessage NewRequest(HttpMethod method, string path, string token)
|
||||||
|
{
|
||||||
|
var req = new HttpRequestMessage(method, path);
|
||||||
|
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> GetJsonAsync(HttpRequestAuth auth, string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var req = NewRequest(HttpMethod.Get, path, auth.Token);
|
||||||
|
using var response = await auth.Client.SendAsync(req, ct);
|
||||||
|
await EnsureSuccess(response, path, ct);
|
||||||
|
var text = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
using var doc = JsonDocument.Parse(text);
|
||||||
|
return doc.RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> PostJsonAsync(HttpRequestAuth auth, string path, object body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var req = NewRequest(HttpMethod.Post, path, auth.Token);
|
||||||
|
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||||
|
using var response = await auth.Client.SendAsync(req, ct);
|
||||||
|
await EnsureSuccess(response, path, ct);
|
||||||
|
var text = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
using var doc = JsonDocument.Parse(text);
|
||||||
|
return doc.RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private readonly record struct ConflictAware(JsonElement? Body, bool IsConflict);
|
||||||
|
|
||||||
|
private static async Task<ConflictAware> PostJsonAllowConflictAsync(HttpRequestAuth auth, string path, object body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var req = NewRequest(HttpMethod.Post, path, auth.Token);
|
||||||
|
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||||
|
using var response = await auth.Client.SendAsync(req, ct);
|
||||||
|
if (response.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity)
|
||||||
|
{
|
||||||
|
// GitHub returns 422 "Reference already exists" when the branch ref is
|
||||||
|
// pre-claimed by a concurrent or earlier submission. That's the idempotency
|
||||||
|
// signal we want; surface it.
|
||||||
|
var conflictText = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
if (conflictText.Contains("Reference already exists", StringComparison.OrdinalIgnoreCase))
|
||||||
|
return new ConflictAware(null, true);
|
||||||
|
throw new HttpRequestException($"GitHub {(int)response.StatusCode} {path}: {conflictText}");
|
||||||
|
}
|
||||||
|
await EnsureSuccess(response, path, ct);
|
||||||
|
var text = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
using var doc = JsonDocument.Parse(text);
|
||||||
|
return new ConflictAware(doc.RootElement.Clone(), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<JsonElement> PutJsonAsync(HttpRequestAuth auth, string path, object body, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var req = NewRequest(HttpMethod.Put, path, auth.Token);
|
||||||
|
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||||
|
using var response = await auth.Client.SendAsync(req, ct);
|
||||||
|
await EnsureSuccess(response, path, ct);
|
||||||
|
var text = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
using var doc = JsonDocument.Parse(text);
|
||||||
|
return doc.RootElement.Clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureSuccess(HttpResponseMessage response, string path, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (response.IsSuccessStatusCode) return;
|
||||||
|
var body = await response.Content.ReadAsStringAsync(ct);
|
||||||
|
throw new HttpRequestException($"GitHub {(int)response.StatusCode} {path}: {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -42,7 +42,6 @@ Thank you,
|
|||||||
BTCPay Server Plugin Builder Team
|
BTCPay Server Plugin Builder Team
|
||||||
";
|
";
|
||||||
|
|
||||||
|
|
||||||
public Task<List<string>> SendEmail(string toCsvList, string subject, string messageText)
|
public Task<List<string>> SendEmail(string toCsvList, string subject, string messageText)
|
||||||
{
|
{
|
||||||
List<InternetAddress> toList = toCsvList.Split([","], StringSplitOptions.RemoveEmptyEntries)
|
List<InternetAddress> toList = toCsvList.Split([","], StringSplitOptions.RemoveEmptyEntries)
|
||||||
@ -172,7 +171,6 @@ BTCPay Server Plugin Builder";
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public async Task<EmailSettingsViewModel?> GetEmailSettingsFromDb()
|
public async Task<EmailSettingsViewModel?> GetEmailSettingsFromDb()
|
||||||
{
|
{
|
||||||
await using var conn = await connectionFactory.Open();
|
await using var conn = await connectionFactory.Open();
|
||||||
|
|||||||
@ -860,6 +860,29 @@ public static class NpgsqlConnectionExtensions
|
|||||||
return await connection.QueryFirstOrDefaultAsync<PluginListingRequest>(sql, new { pluginSlug = pluginSlug.ToString() });
|
return await connection.QueryFirstOrDefaultAsync<PluginListingRequest>(sql, new { pluginSlug = pluginSlug.ToString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static async Task<List<PluginListingRequest>> GetAllListingRequestsForPlugin(this NpgsqlConnection connection, PluginSlug pluginSlug)
|
||||||
|
{
|
||||||
|
const string sql = """
|
||||||
|
SELECT id AS "Id",
|
||||||
|
plugin_slug AS "PluginSlug",
|
||||||
|
release_note AS "ReleaseNote",
|
||||||
|
telegram_verification_message AS "TelegramVerificationMessage",
|
||||||
|
user_reviews AS "UserReviews",
|
||||||
|
announcement_date AS "AnnouncementDate",
|
||||||
|
status AS "Status",
|
||||||
|
submitted_at AS "SubmittedAt",
|
||||||
|
reviewed_at AS "ReviewedAt",
|
||||||
|
reviewed_by AS "ReviewedBy",
|
||||||
|
rejection_reason AS "RejectionReason"
|
||||||
|
FROM plugin_listing_requests
|
||||||
|
WHERE plugin_slug = @pluginSlug
|
||||||
|
ORDER BY submitted_at DESC
|
||||||
|
""";
|
||||||
|
|
||||||
|
var results = await connection.QueryAsync<PluginListingRequest>(sql, new { pluginSlug = pluginSlug.ToString() });
|
||||||
|
return results.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<int> GetPendingListingRequestsCount(this NpgsqlConnection connection)
|
public static async Task<int> GetPendingListingRequestsCount(this NpgsqlConnection connection)
|
||||||
{
|
{
|
||||||
const string sql = """
|
const string sql = """
|
||||||
|
|||||||
@ -5,7 +5,6 @@ namespace PluginBuilder.ViewModels;
|
|||||||
|
|
||||||
public class BuildListViewModel
|
public class BuildListViewModel
|
||||||
{
|
{
|
||||||
public bool RequestListing { get; set; }
|
|
||||||
public List<BuildViewModel> Builds { get; set; } = [];
|
public List<BuildViewModel> Builds { get; set; } = [];
|
||||||
|
|
||||||
public class BuildViewModel
|
public class BuildViewModel
|
||||||
|
|||||||
@ -25,7 +25,7 @@ public class RequestListingViewModel
|
|||||||
[Required]
|
[Required]
|
||||||
[Display(Name = "User Reviews")]
|
[Display(Name = "User Reviews")]
|
||||||
public string UserReviews { get; set; } = string.Empty;
|
public string UserReviews { get; set; } = string.Empty;
|
||||||
|
public bool HasRequests { get; set; }
|
||||||
public bool PendingListing { get; set; }
|
public bool PendingListing { get; set; }
|
||||||
public bool HasPreviousRejection { get; set; }
|
public bool HasPreviousRejection { get; set; }
|
||||||
public bool CanSendEmailReminder { get; set; }
|
public bool CanSendEmailReminder { get; set; }
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
@model ListingRequestDetailViewModel
|
@model ListingRequestDetailViewModel
|
||||||
@{
|
@{
|
||||||
Layout = "_Layout";
|
Layout = "_Layout";
|
||||||
ViewData.SetActivePage(AdminNavPages.ListingRequests);
|
ViewData.SetActivePage(PluginNavPages.RequestListing, "Request Listing");
|
||||||
ViewData["Title"] = $"Listing Request - {Model.PluginTitle ?? Model.PluginSlug}";
|
ViewData["Title"] = $"Listing Request - {Model.PluginTitle ?? Model.PluginSlug}";
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -15,18 +15,6 @@
|
|||||||
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#rejectModal">Reject</button>
|
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#rejectModal">Reject</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
else
|
|
||||||
{
|
|
||||||
@switch (Model.Status)
|
|
||||||
{
|
|
||||||
case PluginListingRequestStatus.Approved:
|
|
||||||
<span class="badge bg-success fs-6 px-3 py-2">Approved</span>
|
|
||||||
break;
|
|
||||||
case PluginListingRequestStatus.Rejected:
|
|
||||||
<span class="badge bg-danger fs-6 px-3 py-2">Rejected</span>
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mt-0">
|
<div class="row mt-0">
|
||||||
@ -37,13 +25,24 @@
|
|||||||
<img src="@Model.Logo" alt="Logo" style="width:64px;height:64px;object-fit:contain;border-radius:12px;flex-shrink:0;" />
|
<img src="@Model.Logo" alt="Logo" style="width:64px;height:64px;object-fit:contain;border-radius:12px;flex-shrink:0;" />
|
||||||
}
|
}
|
||||||
<div>
|
<div>
|
||||||
<h3 class="mb-0 fw-bold">@Model.PluginTitle</h3>
|
<div class="d-flex align-items-center gap-2">
|
||||||
|
<h3 class="mb-0 fw-bold">@Model.PluginTitle</h3>
|
||||||
|
@switch (Model.Status)
|
||||||
|
{
|
||||||
|
case PluginListingRequestStatus.Approved:
|
||||||
|
<span class="badge bg-success">Approved</span>
|
||||||
|
break;
|
||||||
|
case PluginListingRequestStatus.Rejected:
|
||||||
|
<span class="badge bg-danger">Rejected</span>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2 mt-1">
|
<div class="d-flex align-items-center gap-2 mt-1">
|
||||||
<a asp-controller="Home" asp-action="GetPluginDetails" asp-route-pluginSlug="@Model.PluginSlug" target="_blank" class="small">View Public Page</a>
|
<a asp-controller="Home" asp-action="GetPluginDetails" asp-route-pluginSlug="@Model.PluginSlug" target="_blank" class="small">View Public Page</a>
|
||||||
</div>
|
</div>
|
||||||
@if (!string.IsNullOrEmpty(Model.PluginDescription))
|
@if (!string.IsNullOrEmpty(Model.PluginDescription))
|
||||||
{
|
{
|
||||||
<p class="mt-2 mb-0 text-muted">@Model.PluginDescription</p>
|
<p class="mt-2 mb-0">@Model.PluginDescription</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -94,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h5 class="fw-bold mb-4">Listing Request Details</h5>
|
<h5 class="fw-bold mb-4">Listing Request Details</h5>
|
||||||
@if (!string.IsNullOrEmpty(Model.RejectionReason))
|
@if (!string.IsNullOrEmpty(Model.RejectionReason))
|
||||||
{
|
{
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<div class="text-muted fw-semibold small text-uppercase mb-2" style="letter-spacing:.05em;">Rejection Reason</div>
|
<div class="text-muted fw-semibold small text-uppercase mb-2" style="letter-spacing:.05em;">Rejection Reason</div>
|
||||||
@ -174,7 +173,7 @@
|
|||||||
@Html.AntiForgeryToken()
|
@Html.AntiForgeryToken()
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p>Are you sure you want to approve this listing request?</p>
|
<p>Are you sure you want to approve this listing request?</p>
|
||||||
<p class="mb-0">The plugin <strong>@Model.PluginSlug</strong> will be set to <strong>Listed</strong> visibility.</p>
|
<p class="mb-0">The plugin <strong>@Model.PluginSlug</strong> will be <strong>Listed</strong> in the plugin directory.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer border-0">
|
<div class="modal-footer border-0">
|
||||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
|||||||
@ -99,7 +99,7 @@ else
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<a asp-action="ListingRequestDetail" asp-route-requestId="@request.Id" class="btn btn-sm btn-primary">
|
<a asp-action="ListingRequestDetail" asp-route-requestId="@request.Id">
|
||||||
View Details
|
View Details
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -102,6 +102,12 @@
|
|||||||
<div class="col-md-6 mb-4">
|
<div class="col-md-6 mb-4">
|
||||||
<div class="card h-100 plugin-card" data-type="community">
|
<div class="card h-100 plugin-card" data-type="community">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
@if (plugin.IsUnlisted)
|
||||||
|
{
|
||||||
|
<div class="position-absolute top-0 end-0 mt-3 me-3 d-flex flex-wrap justify-content-end gap-2">
|
||||||
|
<span class="badge fw-normal text-muted bg-medium rounded-pill px-2 py-1" title="This plugin is only shown in search results">Unlisted</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div style="display: flex; align-items: flex-start; margin-bottom: 20px;">
|
<div style="display: flex; align-items: flex-start; margin-bottom: 20px;">
|
||||||
<div style="margin-right: 15px;">
|
<div style="margin-right: 15px;">
|
||||||
|
|||||||
@ -44,10 +44,6 @@
|
|||||||
Public Page
|
Public Page
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@if (Model.RequestListing && Model.Builds.Any())
|
|
||||||
{
|
|
||||||
<a asp-controller="Plugin" asp-action="RequestListing" asp-route-pluginSlug="@pluginSlug" class="btn btn-primary">Request Listing</a>
|
|
||||||
}
|
|
||||||
<a id="CreateNewBuild" asp-action="CreateBuild" asp-route-pluginSlug="@pluginSlug" class="btn btn-primary"><span class="fa fa-plus"></span> Create a
|
<a id="CreateNewBuild" asp-action="CreateBuild" asp-route-pluginSlug="@pluginSlug" class="btn btn-primary"><span class="fa fa-plus"></span> Create a
|
||||||
new build</a>
|
new build</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
77
PluginBuilder/Views/Plugin/ListingHistory.cshtml
Normal file
77
PluginBuilder/Views/Plugin/ListingHistory.cshtml
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
@using PluginBuilder.DataModels
|
||||||
|
@model ListingHistoryViewModel
|
||||||
|
@{
|
||||||
|
Layout = "_Layout";
|
||||||
|
ViewData.SetActivePage(PluginNavPages.RequestListing, "Listing History");
|
||||||
|
ViewData["Title"] = "Listing History";
|
||||||
|
}
|
||||||
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
|
<h2 class="mb-0">@ViewData["Title"]</h2>
|
||||||
|
<a asp-action="RequestListing" asp-route-pluginSlug="@Model.PluginSlug" class="btn btn-outline-primary btn-sm">
|
||||||
|
Back to Listing Request
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!Model.Requests.Any())
|
||||||
|
{
|
||||||
|
<div class="alert alert-info">No listing requests have been submitted yet.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Submitted</th>
|
||||||
|
<th>Release Note</th>
|
||||||
|
<th>Rejection Reason</th>
|
||||||
|
<th>Reviewed</th>
|
||||||
|
<th>Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var request in Model.Requests)
|
||||||
|
{
|
||||||
|
<tr>
|
||||||
|
<td class="text-nowrap small">@request.SubmittedAt.UtcDateTime.ToString("MMM dd, yyyy · HH:mm UTC")</td>
|
||||||
|
<td>@request.ReleaseNote</td>
|
||||||
|
<td>
|
||||||
|
@if (!string.IsNullOrEmpty(request.RejectionReason))
|
||||||
|
{
|
||||||
|
<span class="text-danger" style="white-space: pre-wrap;">@request.RejectionReason</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td class="text-nowrap small">
|
||||||
|
@if (request.ReviewedAt.HasValue)
|
||||||
|
{
|
||||||
|
@request.ReviewedAt.Value.UtcDateTime.ToString("MMM dd, yyyy · HH:mm UTC")
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="text-muted">—</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
@switch (request.Status)
|
||||||
|
{
|
||||||
|
case PluginListingRequestStatus.Pending:
|
||||||
|
<span class="badge bg-warning">Pending</span>
|
||||||
|
break;
|
||||||
|
case PluginListingRequestStatus.Approved:
|
||||||
|
<span class="badge bg-success">Approved</span>
|
||||||
|
break;
|
||||||
|
case PluginListingRequestStatus.Rejected:
|
||||||
|
<span class="badge bg-danger">Rejected</span>
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@ -1,7 +1,7 @@
|
|||||||
@model RequestListingViewModel
|
@model RequestListingViewModel
|
||||||
@{
|
@{
|
||||||
Layout = "_Layout";
|
Layout = "_Layout";
|
||||||
ViewData.SetActivePage(PluginNavPages.Dashboard, "Plugin Listing Request");
|
ViewData.SetActivePage(PluginNavPages.RequestListing, "Plugin Listing Request");
|
||||||
var step3Completed = Model.PendingListing;
|
var step3Completed = Model.PendingListing;
|
||||||
var step1Completed = Model.Step != RequestListingViewModel.State.UpdatePluginSettings;
|
var step1Completed = Model.Step != RequestListingViewModel.State.UpdatePluginSettings;
|
||||||
var step2Completed = Model.Step != RequestListingViewModel.State.UpdateOwnerAccountSettings && Model.Step != RequestListingViewModel.State.UpdatePluginSettings;
|
var step2Completed = Model.Step != RequestListingViewModel.State.UpdateOwnerAccountSettings && Model.Step != RequestListingViewModel.State.UpdatePluginSettings;
|
||||||
@ -11,27 +11,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||||
<h2 class="mb-0">
|
<h2 class="mb-0">@ViewData["Title"]</h2>
|
||||||
@ViewData["Title"]
|
<div class="d-flex align-items-center gap-3 mt-3 mt-sm-0">
|
||||||
</h2>
|
@if (Model.HasRequests)
|
||||||
@if (!Model.PendingListing)
|
{
|
||||||
{
|
<a asp-action="ListingHistory" asp-route-pluginSlug="@Model.PluginSlug" class="btn btn-outline-secondary btn-sm">
|
||||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
View History
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
@if (!Model.PendingListing)
|
||||||
|
{
|
||||||
<button type="submit" form="request-listing-form" class="btn btn-success" @(Model.Step == RequestListingViewModel.State.Done ? "" : "disabled")>
|
<button type="submit" form="request-listing-form" class="btn btn-success" @(Model.Step == RequestListingViewModel.State.Done ? "" : "disabled")>
|
||||||
@(Model.HasPreviousRejection ? "Re-submit" : "Submit")
|
@(Model.HasPreviousRejection ? "Re-submit" : "Submit")
|
||||||
</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
}
|
@if (Model.PendingListing && Model.CanSendEmailReminder)
|
||||||
@if (Model.PendingListing && Model.CanSendEmailReminder)
|
{
|
||||||
{
|
<form asp-controller="Plugin" asp-action="SendReminder" asp-route-pluginSlug="@Model.PluginSlug" method="post" class="d-inline">
|
||||||
<form asp-controller="Plugin" asp-action="SendReminder" asp-route-pluginSlug="@Model.PluginSlug" method="post" class="d-inline">
|
<button type="submit" class="btn btn-success">Send Reminder</button>
|
||||||
<button type="submit" class="btn btn-success">Send Reminder</button>
|
</form>
|
||||||
</form>
|
}
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<div class="accordion" id="requestListingAccordion">
|
<div class="accordion" id="requestListingAccordion">
|
||||||
<div class="accordion-item">
|
<div class="accordion-item">
|
||||||
<h3 class="accordion-header" id="pluginSettingsHeader">
|
<h3 class="accordion-header" id="pluginSettingsHeader">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user