Compare commits
47 Commits
btcmaps-v1
...
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 | ||
|
|
05011e0fc5 | ||
|
|
95f67b3fda | ||
|
|
4582d96b3d | ||
|
|
ee45a5464c | ||
|
|
8c42cf7d70 | ||
|
|
7518da1cc6 | ||
|
|
fd49a37c63 | ||
|
|
ac1dc009cd | ||
|
|
3752a94d6c | ||
|
|
5ee5f70c6d | ||
|
|
04a64d9178 | ||
|
|
650f74781d |
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 }));
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,6 @@
|
||||
using System.Linq;
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using PluginBuilder.APIModels;
|
||||
using PluginBuilder.Services;
|
||||
@ -9,543 +10,379 @@ 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);
|
||||
|
||||
[Fact]
|
||||
public void Validate_RequiresAtLeastOneAction_NotEnforcedHere()
|
||||
private static BtcMapsSubmitRequest MakeValid() => new()
|
||||
{
|
||||
// The controller enforces (submitToDirectory || tagOnOsm). The service
|
||||
// validator focuses on field-level validity, so an all-false request
|
||||
// with only core fields should still pass Validate cleanly.
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Good Shop",
|
||||
Url = "https://goodshop.example",
|
||||
Description = "A very good shop."
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
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 svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Url = "https://shop.example",
|
||||
Description = "desc"
|
||||
};
|
||||
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Name));
|
||||
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 svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "http://plain.example",
|
||||
Description = "desc"
|
||||
};
|
||||
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Url));
|
||||
var req = MakeValid();
|
||||
req.Url = "http://plain.example";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Url));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsOverlongDescription_OnDirectorySubmit()
|
||||
public void Validate_RejectsMissingDescription()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = new string('x', 1001),
|
||||
Type = "merchants",
|
||||
SubmitToDirectory = true
|
||||
};
|
||||
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
|
||||
var req = MakeValid();
|
||||
req.Description = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RequiresDescription_OnDirectorySubmit()
|
||||
public void Validate_RejectsOverlongDescription()
|
||||
{
|
||||
// Description is the directory PR body content; required only when actually
|
||||
// submitting to the directory.
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Type = "merchants",
|
||||
SubmitToDirectory = true
|
||||
};
|
||||
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
|
||||
var req = MakeValid();
|
||||
req.Description = new string('x', 1001);
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllowsMissingDescription_WhenTagOnOsmOnly()
|
||||
public void Validate_RejectsMissingType()
|
||||
{
|
||||
// tagOnOsm-only requests do not consume Description (the OSM tag set is
|
||||
// name + amenity + currency:XBT + payment:lightning + website). Description
|
||||
// is exclusively a directory-PR field.
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
OsmNodeId = 12345,
|
||||
OsmNodeType = "node",
|
||||
TagOnOsm = true
|
||||
};
|
||||
Assert.DoesNotContain(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
|
||||
var req = MakeValid();
|
||||
req.Type = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Type));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllowsMissingDescription_WhenUnlistOnly()
|
||||
public void Validate_RejectsInvalidType()
|
||||
{
|
||||
// unlistFromOsm-only requests strip tags from an existing OSM element; no
|
||||
// Description path on the wire.
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
UnlistFromOsm = true,
|
||||
OsmNodeId = 12345,
|
||||
OsmNodeType = "node"
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("merchants", "books", true)]
|
||||
[InlineData("merchants", "not-a-subtype", false)]
|
||||
[InlineData("apps", "not-a-subtype", true)]
|
||||
public void Validate_ChecksMerchantSubType(string type, string subType, bool expectValid)
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
Type = type,
|
||||
SubType = subType,
|
||||
SubmitToDirectory = true
|
||||
};
|
||||
var errors = svc.Validate(req);
|
||||
if (expectValid)
|
||||
Assert.DoesNotContain(errors, e => e.Path == nameof(BtcMapsSubmitRequest.SubType));
|
||||
else
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.SubType));
|
||||
var req = MakeValid();
|
||||
req.Type = "shops";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Type));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsUnknownType_OnDirectorySubmit()
|
||||
public void Validate_RejectsInvalidMerchantSubType()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
Type = "unicorns",
|
||||
SubmitToDirectory = true
|
||||
};
|
||||
Assert.Contains(svc.Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Type));
|
||||
var req = MakeValid();
|
||||
req.SubType = "not-a-real-subtype";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.SubType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_SkipsDirectoryFieldsWhenNotSubmitting()
|
||||
public void Validate_AcceptsValidMerchantSubType()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
Type = "unicorns",
|
||||
SubmitToDirectory = false
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("GLOBAL", true)]
|
||||
[InlineData("US", true)]
|
||||
[InlineData("us", false)]
|
||||
[InlineData("USA", false)]
|
||||
public void Validate_ChecksCountryOnDirectorySubmit(string country, bool expectValid)
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
Type = "merchants",
|
||||
Country = country,
|
||||
SubmitToDirectory = true
|
||||
};
|
||||
var errors = svc.Validate(req);
|
||||
if (expectValid)
|
||||
Assert.DoesNotContain(errors, e => e.Path == nameof(BtcMapsSubmitRequest.Country));
|
||||
else
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.Country));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("http://example.onion", true)]
|
||||
[InlineData("ftp://abc.onion", false)]
|
||||
[InlineData("https://abc.example", false)]
|
||||
[InlineData("http://abc.onion", true)]
|
||||
[InlineData("https://abc.onion", true)]
|
||||
public void Validate_ChecksOnionUrl(string onion, bool expectValid)
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
Type = "merchants",
|
||||
OnionUrl = onion,
|
||||
SubmitToDirectory = true
|
||||
};
|
||||
var errors = svc.Validate(req);
|
||||
if (expectValid)
|
||||
Assert.DoesNotContain(errors, e => e.Path == nameof(BtcMapsSubmitRequest.OnionUrl));
|
||||
else
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.OnionUrl));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(123L, "node", true)]
|
||||
[InlineData(123L, "Node", true)]
|
||||
[InlineData(123L, "relation", true)]
|
||||
[InlineData(123L, "line", false)]
|
||||
[InlineData(-1L, "node", false)]
|
||||
public void Validate_ChecksExistingNodeFields(long? nodeId, string? nodeType, bool expectValid)
|
||||
{
|
||||
// Existing-update path: OsmNodeId is set, NodeType must be one of the
|
||||
// known OSM types and the ID positive.
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
OsmNodeId = nodeId,
|
||||
OsmNodeType = nodeType,
|
||||
TagOnOsm = true
|
||||
};
|
||||
var errors = svc.Validate(req)
|
||||
.Where(e => e.Path is nameof(BtcMapsSubmitRequest.OsmNodeId) or nameof(BtcMapsSubmitRequest.OsmNodeType))
|
||||
.ToList();
|
||||
if (expectValid)
|
||||
Assert.Empty(errors);
|
||||
else
|
||||
Assert.NotEmpty(errors);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(40.7128, -74.0060, true)]
|
||||
[InlineData(0.0, 0.0, true)]
|
||||
[InlineData(-90.0, 180.0, true)]
|
||||
[InlineData(90.0, -180.0, true)]
|
||||
[InlineData(91.0, 0.0, false)]
|
||||
[InlineData(-91.0, 0.0, false)]
|
||||
[InlineData(0.0, 181.0, false)]
|
||||
[InlineData(0.0, -181.0, false)]
|
||||
public void Validate_CreatePath_RequiresValidLatLon(double lat, double lon, bool expectValid)
|
||||
{
|
||||
// Create-new path: OsmNodeId is null, lat + lon are required and must be
|
||||
// in valid geographic ranges. NodeType is irrelevant on this path
|
||||
// (server defaults the created element to a node).
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
OsmNodeId = null,
|
||||
Latitude = lat,
|
||||
Longitude = lon,
|
||||
TagOnOsm = true
|
||||
};
|
||||
var errors = svc.Validate(req)
|
||||
.Where(e => e.Path is nameof(BtcMapsSubmitRequest.Latitude) or nameof(BtcMapsSubmitRequest.Longitude))
|
||||
.ToList();
|
||||
if (expectValid)
|
||||
Assert.Empty(errors);
|
||||
else
|
||||
Assert.NotEmpty(errors);
|
||||
var req = MakeValid();
|
||||
req.SubType = "books";
|
||||
Assert.Empty(MakeService().Validate(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_CreatePath_RejectsMissingCoordinates()
|
||||
public void Validate_AcceptsIsoAlpha2Country()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
OsmNodeId = null,
|
||||
TagOnOsm = true
|
||||
};
|
||||
var errors = svc.Validate(req).ToList();
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.Latitude));
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.Longitude));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://shop.example/", "https://shop.example/", true)]
|
||||
[InlineData("https://shop.example", "https://shop.example/", true)]
|
||||
[InlineData("https://Shop.Example/", "https://shop.example", true)]
|
||||
[InlineData("https://shop.example/a", "https://shop.example/b", false)]
|
||||
public void NormalizeUrl_IgnoresTrailingSlashAndCase(string a, string b, bool equal)
|
||||
{
|
||||
Assert.Equal(equal, BtcMapsService.NormalizeUrl(a) == BtcMapsService.NormalizeUrl(b));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("9 Bravos", "9-bravos")]
|
||||
[InlineData("Altair Technology", "altair-technology")]
|
||||
[InlineData("!!!", "merchant")]
|
||||
[InlineData(" leading and trailing ", "leading-and-trailing")]
|
||||
public void Slugify_ProducesUrlSafeSlug(string input, string expected)
|
||||
{
|
||||
Assert.Equal(expected, BtcMapsService.Slugify(input));
|
||||
var req = MakeValid();
|
||||
req.Country = "DE";
|
||||
Assert.Empty(MakeService().Validate(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Unlist_RequiresOsmNodeIdAndType()
|
||||
public void Validate_AcceptsGlobalCountry()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
UnlistFromOsm = true
|
||||
};
|
||||
var errors = svc.Validate(req).ToList();
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.OsmNodeId));
|
||||
Assert.Contains(errors, e => e.Path == nameof(BtcMapsSubmitRequest.OsmNodeType));
|
||||
var req = MakeValid();
|
||||
req.Country = "GLOBAL";
|
||||
Assert.Empty(MakeService().Validate(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Unlist_AcceptsNodeIdAndType()
|
||||
public void Validate_RejectsLowerCaseCountry()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
UnlistFromOsm = true,
|
||||
OsmNodeId = 1234,
|
||||
OsmNodeType = "node"
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
var req = MakeValid();
|
||||
req.Country = "de";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Country));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Unlist_RejectsCombinationWithTagOnOsm()
|
||||
public void Validate_RejectsThreeLetterCountry()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
UnlistFromOsm = true,
|
||||
TagOnOsm = true,
|
||||
OsmNodeId = 1234,
|
||||
OsmNodeType = "node"
|
||||
};
|
||||
Assert.Contains(svc.Validate(req),
|
||||
e => e.Path == nameof(BtcMapsSubmitRequest.UnlistFromOsm));
|
||||
var req = MakeValid();
|
||||
req.Country = "DEU";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Country));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_Unlist_RejectsCombinationWithSubmitToDirectory()
|
||||
public void Validate_RejectsNonAssignedTwoLetterCountry()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
UnlistFromOsm = true,
|
||||
SubmitToDirectory = true,
|
||||
Type = "merchants",
|
||||
OsmNodeId = 1234,
|
||||
OsmNodeType = "node"
|
||||
};
|
||||
Assert.Contains(svc.Validate(req),
|
||||
e => e.Path == nameof(BtcMapsSubmitRequest.UnlistFromOsm));
|
||||
// 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 ResolveDirectoryCountry_PrefersTopLevelCountry()
|
||||
public void Validate_AcceptsOnionHttpsUrl()
|
||||
{
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Country = "DE",
|
||||
Address = new BtcMapsSubmitAddress { Country = "FR" }
|
||||
};
|
||||
Assert.Equal("DE", BtcMapsService.ResolveDirectoryCountry(req));
|
||||
var req = MakeValid();
|
||||
req.OnionUrl = "https://abc123.onion";
|
||||
Assert.Empty(MakeService().Validate(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ResolveDirectoryCountry_FallsBackToAddressCountry()
|
||||
public void Validate_AcceptsOnionHttpUrl()
|
||||
{
|
||||
// Plugin centralises country in the address block only; the directory
|
||||
// entry should still carry the country code.
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Address = new BtcMapsSubmitAddress { Country = "FR" }
|
||||
};
|
||||
Assert.Equal("FR", BtcMapsService.ResolveDirectoryCountry(req));
|
||||
// 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 ResolveDirectoryCountry_FallsBackThroughWhitespace()
|
||||
public void Validate_RejectsNonOnionOnionUrl()
|
||||
{
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Country = " ",
|
||||
Address = new BtcMapsSubmitAddress { Country = " IT " }
|
||||
};
|
||||
Assert.Equal("IT", BtcMapsService.ResolveDirectoryCountry(req));
|
||||
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 ResolveDirectoryCountry_NullWhenNeitherProvided()
|
||||
public void Validate_AcceptsValidBtcMapSubmission()
|
||||
{
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Address = new BtcMapsSubmitAddress { City = "Munich" }
|
||||
};
|
||||
Assert.Null(BtcMapsService.ResolveDirectoryCountry(req));
|
||||
Assert.Empty(MakeService().Validate(MakeValidBtcMap()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AllowsRequestWithoutAddress()
|
||||
public void Validate_DoesNotRequireBtcMapFieldsByDefault()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
OsmNodeId = 12345,
|
||||
OsmNodeType = "node",
|
||||
TagOnOsm = true
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
// 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_AcceptsFullAddressWithIsoCountry()
|
||||
public void Validate_RejectsMissingLatWhenSubmitToBtcMap()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
OsmNodeId = 12345,
|
||||
OsmNodeType = "node",
|
||||
TagOnOsm = true,
|
||||
Address = new BtcMapsSubmitAddress
|
||||
{
|
||||
HouseNumber = "12",
|
||||
Street = "Main St",
|
||||
City = "Munich",
|
||||
Postcode = "80331",
|
||||
Country = "DE"
|
||||
}
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
var req = MakeValidBtcMap();
|
||||
req.Lat = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsPartialAddress()
|
||||
public void Validate_RejectsOutOfRangeLat()
|
||||
{
|
||||
// Only some addr:* keys provided. Server writes whichever the plugin
|
||||
// populated; nothing inferred. Empty / missing fields are not errors.
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
OsmNodeId = 12345,
|
||||
OsmNodeType = "node",
|
||||
TagOnOsm = true,
|
||||
Address = new BtcMapsSubmitAddress
|
||||
{
|
||||
City = "Munich",
|
||||
Country = "DE"
|
||||
}
|
||||
};
|
||||
Assert.Empty(svc.Validate(req));
|
||||
var req = MakeValidBtcMap();
|
||||
req.Lat = 91.0;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("DE", true)]
|
||||
[InlineData("US", true)]
|
||||
[InlineData("de", false)]
|
||||
[InlineData("DEU", false)]
|
||||
[InlineData("D", false)]
|
||||
[InlineData("GLOBAL", false)] // GLOBAL is valid for the directory's top-level Country, NOT for OSM addr:country.
|
||||
public void Validate_AddressCountry_MustBeIsoAlpha2(string country, bool expectValid)
|
||||
[Fact]
|
||||
public void Validate_RejectsNaNLat()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
OsmNodeId = 12345,
|
||||
OsmNodeType = "node",
|
||||
TagOnOsm = true,
|
||||
Address = new BtcMapsSubmitAddress { Country = country }
|
||||
};
|
||||
var errors = svc.Validate(req)
|
||||
.Where(e => e.Path.EndsWith(nameof(BtcMapsSubmitAddress.Country)))
|
||||
.ToList();
|
||||
if (expectValid)
|
||||
Assert.Empty(errors);
|
||||
else
|
||||
Assert.NotEmpty(errors);
|
||||
var req = MakeValidBtcMap();
|
||||
req.Lat = double.NaN;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0, false)]
|
||||
[InlineData(-1, false)]
|
||||
[InlineData(1, true)]
|
||||
public void Validate_Unlist_RequiresPositiveNodeId(long id, bool expectValid)
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingLonWhenSubmitToBtcMap()
|
||||
{
|
||||
var svc = MakeService();
|
||||
var req = new BtcMapsSubmitRequest
|
||||
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?>
|
||||
{
|
||||
Name = "Shop",
|
||||
Url = "https://shop.example",
|
||||
Description = "desc",
|
||||
UnlistFromOsm = true,
|
||||
OsmNodeId = id,
|
||||
OsmNodeType = "node"
|
||||
};
|
||||
var errors = svc.Validate(req)
|
||||
.Where(e => e.Path == nameof(BtcMapsSubmitRequest.OsmNodeId))
|
||||
.ToList();
|
||||
if (expectValid)
|
||||
Assert.Empty(errors);
|
||||
else
|
||||
Assert.NotEmpty(errors);
|
||||
["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("#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("#pluginSettingsHeader")).ToContainTextAsync("Update Plugin Settings");
|
||||
await Expect(t.Page.Locator("#collapseOwnerSettings")).Not.ToBeVisibleAsync();
|
||||
@ -78,7 +78,7 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
|
||||
await t.AssertNoError();
|
||||
|
||||
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("#collapsePluginSettings")).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.ClickAsync("button[type='submit']:text('Submit')");
|
||||
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("#collapseOwnerSettings")).Not.ToBeVisibleAsync();
|
||||
@ -145,7 +145,7 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
|
||||
await Expect(row.Locator(".badge.bg-warning:text('Pending')")).ToBeVisibleAsync();
|
||||
|
||||
// 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));
|
||||
|
||||
// Verify request details are displayed
|
||||
@ -172,6 +172,44 @@ public class PluginRequestListingUITest(ITestOutputHelper output) : PageTest
|
||||
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]
|
||||
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.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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,6 +45,8 @@ public class ServerTester : IAsyncDisposable
|
||||
public WebApplication WebApp => _WebApp ?? throw new InvalidOperationException("Webapp not initialized");
|
||||
|
||||
public bool ReuseDatabase { get; set; } = true;
|
||||
public bool CheatMode { get; set; }
|
||||
public bool EnableLocalArtifactDownloadProxy { get; set; }
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
@ -108,6 +110,8 @@ public class ServerTester : IAsyncDisposable
|
||||
"--urls=http://127.0.0.1:0",
|
||||
$"--postgres={connStr}",
|
||||
$"--storage_connection_string={StorageConnectionString}",
|
||||
$"--cheat_mode={CheatMode.ToString().ToLowerInvariant()}",
|
||||
$"--enable_local_artifact_download_proxy={EnableLocalArtifactDownloadProxy.ToString().ToLowerInvariant()}",
|
||||
]
|
||||
});
|
||||
|
||||
|
||||
@ -167,4 +167,55 @@ public class UnitTest1 : UnitTestBase
|
||||
res = await client.GetPublishedVersions("2.1.0.0", false, searchPluginName: "rockstar");
|
||||
Assert.DoesNotContain(res, p => p.ProjectSlug == "rockstar-stylist");
|
||||
}
|
||||
[Fact]
|
||||
public async Task DownloadEndpoint_UsesInternalLoopbackRedirectWhenLocalArtifactProxyEnabled()
|
||||
{
|
||||
await using var tester = Create();
|
||||
tester.ReuseDatabase = false;
|
||||
tester.EnableLocalArtifactDownloadProxy = true;
|
||||
await tester.Start();
|
||||
|
||||
var ownerId = await tester.CreateFakeUserAsync();
|
||||
await tester.CreateAndBuildPluginAsync(ownerId);
|
||||
|
||||
using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri(tester.WebApp.Urls.First(), UriKind.Absolute);
|
||||
|
||||
using var response = await client.GetAsync("api/v1/plugins/rockstar-stylist/versions/1.0.2.0/download");
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.Found, response.StatusCode);
|
||||
Assert.NotNull(response.Headers.Location);
|
||||
Assert.Equal(
|
||||
"/api/v1/plugins/rockstar-stylist/versions/1.0.2.0/download-loopback",
|
||||
response.Headers.Location!.OriginalString);
|
||||
|
||||
using var proxiedResponse = await client.GetAsync(response.Headers.Location);
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.OK, proxiedResponse.StatusCode);
|
||||
Assert.Equal("application/zip", proxiedResponse.Content.Headers.ContentType?.MediaType);
|
||||
Assert.True((await proxiedResponse.Content.ReadAsByteArrayAsync()).Length > 0);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadEndpoint_DoesNotUseInternalLoopbackRedirectWhenLocalArtifactProxyDisabled()
|
||||
{
|
||||
await using var tester = Create();
|
||||
tester.ReuseDatabase = false;
|
||||
await tester.Start();
|
||||
|
||||
var ownerId = await tester.CreateFakeUserAsync();
|
||||
await tester.CreateAndBuildPluginAsync(ownerId);
|
||||
|
||||
using var client = new HttpClient(new HttpClientHandler { AllowAutoRedirect = false });
|
||||
client.BaseAddress = new Uri(tester.WebApp.Urls.First(), UriKind.Absolute);
|
||||
|
||||
using var response = await client.GetAsync("api/v1/plugins/rockstar-stylist/versions/1.0.2.0/download");
|
||||
|
||||
Assert.Equal(System.Net.HttpStatusCode.Found, response.StatusCode);
|
||||
Assert.NotNull(response.Headers.Location);
|
||||
Assert.DoesNotContain("download-loopback", response.Headers.Location!.OriginalString, StringComparison.Ordinal);
|
||||
Assert.True(response.Headers.Location.IsAbsoluteUri);
|
||||
Assert.True(response.Headers.Location.IsLoopback);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
namespace PluginBuilder.APIModels;
|
||||
|
||||
// Optional structured address block on BtcMapsSubmitRequest. Populated by the
|
||||
// plugin when the merchant provides postal-address fields; consumed by
|
||||
// BtcMapsService to write OSM `addr:*` tags. Each field is optional - the
|
||||
// service only writes the OSM tags whose corresponding value is populated.
|
||||
//
|
||||
// Field ordering follows the OSM `addr:*` convention. HouseNumber + Street are
|
||||
// kept separate (per OSM) and the plugin is responsible for splitting the raw
|
||||
// merchant-entered street string into the two components before sending.
|
||||
public sealed class BtcMapsSubmitAddress
|
||||
{
|
||||
public string? HouseNumber { get; set; }
|
||||
public string? Street { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Postcode { get; set; }
|
||||
|
||||
// ISO 3166-1 alpha-2. Validated alongside the top-level Country (which is
|
||||
// the directory-submission field) when present; the two are independent.
|
||||
public string? Country { get; set; }
|
||||
}
|
||||
@ -12,39 +12,35 @@ public sealed class BtcMapsSubmitRequest
|
||||
public string? Twitter { get; set; }
|
||||
public string? Github { get; set; }
|
||||
public string? OnionUrl { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
|
||||
public long? OsmNodeId { get; set; }
|
||||
public string? OsmNodeType { 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; }
|
||||
|
||||
// Required when TagOnOsm=true and OsmNodeId is null (create-new path).
|
||||
// Plugin should pass the merchant's coordinates from the BTCPay store
|
||||
// address or merchant-supplied input.
|
||||
public double? Latitude { get; set; }
|
||||
public double? Longitude { get; set; }
|
||||
// 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; }
|
||||
|
||||
// Optional. Maps to the OSM amenity= tag. Common values: shop, cafe,
|
||||
// restaurant, bar, pub, fast_food. Defaults to "shop" when omitted.
|
||||
public string? OsmCategory { get; set; }
|
||||
// Email - first-class field on btcmap rest/v4/places.md. Plain key, no prefix.
|
||||
public string? Email { get; set; }
|
||||
|
||||
public bool SubmitToDirectory { get; set; }
|
||||
public bool TagOnOsm { 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; }
|
||||
|
||||
// Defaults to true: a BTCPay store accepts on-chain Bitcoin by definition,
|
||||
// so currency:XBT=yes is always set. Lightning is per-store configuration,
|
||||
// so the plugin must pass the actual store state.
|
||||
public bool AcceptsLightning { get; set; } = true;
|
||||
|
||||
// Opt-in un-listing: remove the bitcoin-related tags from an existing OSM
|
||||
// element. Requires OsmNodeId + OsmNodeType. Mutually exclusive with TagOnOsm
|
||||
// and SubmitToDirectory (v1 scope is OSM-only; directory unlist involves a
|
||||
// separate merchant-row/PR/rebuild flow and is out of scope for this endpoint).
|
||||
// If the target element no longer carries any of the bitcoin-related tags the
|
||||
// service removes, the endpoint returns 409 Conflict.
|
||||
public bool UnlistFromOsm { get; set; }
|
||||
|
||||
// Optional structured address. Consumed by the OSM tag writer (addr:*).
|
||||
// Each field nullable; only populated keys are written to the node. Plugin
|
||||
// is responsible for splitting raw street strings into HouseNumber + Street
|
||||
// at the merchant-form boundary.
|
||||
public BtcMapsSubmitAddress? Address { get; set; }
|
||||
// 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; }
|
||||
}
|
||||
|
||||
@ -3,7 +3,7 @@ namespace PluginBuilder.APIModels;
|
||||
public sealed class BtcMapsSubmitResponse
|
||||
{
|
||||
public BtcMapsDirectoryResult? Directory { get; set; }
|
||||
public BtcMapsOsmResult? Osm { get; set; }
|
||||
public BtcMapsBtcMapResult? BtcMap { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BtcMapsDirectoryResult
|
||||
@ -14,21 +14,9 @@ public sealed class BtcMapsDirectoryResult
|
||||
public string? Skipped { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BtcMapsOsmResult
|
||||
public sealed class BtcMapsBtcMapResult
|
||||
{
|
||||
public long? ChangesetId { get; set; }
|
||||
public long? NodeId { get; set; }
|
||||
public string? NodeType { get; set; }
|
||||
public int? NewVersion { get; set; }
|
||||
public string? Skipped { get; set; }
|
||||
|
||||
// True when the node was created on this request (OsmNodeId was null in
|
||||
// the request and the service POSTed /api/0.6/node). Plugin should
|
||||
// persist NodeId back to the merchant record so future submissions take
|
||||
// the existing-update path.
|
||||
public bool Created { get; set; }
|
||||
|
||||
// Populated on an un-list request (UnlistFromOsm=true) with the keys the
|
||||
// service actually removed from the element. Null on a tag-on request.
|
||||
public string[]? RemovedTags { get; set; }
|
||||
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();
|
||||
}
|
||||
|
||||
public bool IsUnlisted { get; set; }
|
||||
public PluginRatingSummary RatingSummary { get; set; } = new();
|
||||
|
||||
public string GetSourceUrl(GitHostingProviderFactory providerFactory)
|
||||
|
||||
@ -19,6 +19,16 @@
|
||||
<span>Builds</span>
|
||||
</a>
|
||||
</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">
|
||||
<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">
|
||||
|
||||
@ -2,6 +2,7 @@ using Dapper;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using PluginBuilder.Components.PluginVersion;
|
||||
using PluginBuilder.DataModels;
|
||||
using PluginBuilder.Services;
|
||||
using PluginBuilder.Util;
|
||||
using PluginBuilder.Util.Extensions;
|
||||
@ -26,22 +27,28 @@ public class MainNav : ViewComponent
|
||||
|
||||
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)>(
|
||||
"SELECT ver, pre_release FROM users_plugins up " +
|
||||
"JOIN versions v USING (plugin_slug) " +
|
||||
"WHERE up.user_id=@userId AND up.plugin_slug=@pluginSlug " +
|
||||
"ORDER BY v.ver DESC LIMIT 10", new { pluginSlug = 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)
|
||||
vm.Versions.Add(new PluginVersionViewModel
|
||||
{
|
||||
PluginSlug = pluginSlug?.ToString(),
|
||||
PluginSlug = slug,
|
||||
Version = new PluginBuilder.PluginVersion(r.ver).ToString(),
|
||||
PreRelease = r.pre_release,
|
||||
Published = 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
|
||||
|
||||
@ -12,4 +12,5 @@ public class MainNavViewModel
|
||||
public List<PluginVersionViewModel> Versions { get; set; } = new();
|
||||
|
||||
public int PendingListingRequestsCount { get; set; }
|
||||
public bool RequestListing { get; set; }
|
||||
}
|
||||
|
||||
@ -4,5 +4,6 @@ public enum PluginNavPages
|
||||
{
|
||||
Dashboard,
|
||||
Settings,
|
||||
Owners
|
||||
Owners,
|
||||
RequestListing
|
||||
}
|
||||
|
||||
@ -164,6 +164,7 @@ public class AccountController(
|
||||
}
|
||||
|
||||
[HttpPost("nostr/verify-nip07")]
|
||||
[IgnoreAntiforgeryToken]
|
||||
public async Task<IActionResult> NostrVerifyNip07([FromBody] VerifyNip07Request req)
|
||||
{
|
||||
var user = await userManager.GetUserAsync(User) ?? throw new Exception("User not found");
|
||||
|
||||
@ -1286,7 +1286,6 @@ public class AdminController(
|
||||
TempData[TempDataConstant.WarningMessage] = "Failed to reject the listing request";
|
||||
return RedirectToAction(nameof(ListingRequestDetail), new { requestId });
|
||||
}
|
||||
|
||||
var existingSettings = await conn.GetSettings(pluginSlug);
|
||||
var pluginOwners = await conn.GetPluginOwners(pluginSlug);
|
||||
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";
|
||||
return RedirectToAction(nameof(ListingRequests));
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
@ -26,7 +26,9 @@ public class ApiController(
|
||||
BuildService buildService,
|
||||
VersionLifecycleService versionLifecycleService,
|
||||
UserManager<IdentityUser> userManager,
|
||||
UserVerifiedLogic userVerifiedLogic)
|
||||
UserVerifiedLogic userVerifiedLogic,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ServerEnvironment serverEnvironment)
|
||||
: ControllerBase
|
||||
{
|
||||
private sealed class BuildRow
|
||||
@ -241,17 +243,57 @@ public class ApiController(
|
||||
[ModelBinder(typeof(PluginVersionModelBinder))]
|
||||
PluginVersion version)
|
||||
{
|
||||
var url = await GetArtifactUrl(pluginSlug, version);
|
||||
if (url is null)
|
||||
return NotFound();
|
||||
|
||||
await using var conn = await connectionFactory.Open();
|
||||
var url = await conn.ExecuteScalarAsync<string?>(
|
||||
await conn.InsertEvent("Download", new JObject { ["pluginSlug"] = pluginSlug.ToString(), ["version"] = version.ToString() });
|
||||
if (serverEnvironment.EnableLocalArtifactDownloadProxy && Uri.TryCreate(url, UriKind.Absolute, out var artifactUri) && artifactUri.IsLoopback)
|
||||
{
|
||||
return RedirectToAction(
|
||||
nameof(DownloadLoopbackArtifact),
|
||||
new { pluginSlug = pluginSlug.ToString(), version = version.ToString() });
|
||||
}
|
||||
|
||||
return Redirect(url);
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[ApiExplorerSettings(IgnoreApi = true)]
|
||||
[HttpGet("plugins/{pluginSlug}/versions/{version}/download-loopback")]
|
||||
[EnableRateLimiting(Policies.PublicApiRateLimit)]
|
||||
public async Task<IActionResult> DownloadLoopbackArtifact(
|
||||
[ModelBinder(typeof(PluginSlugModelBinder))]
|
||||
PluginSlug pluginSlug,
|
||||
[ModelBinder(typeof(PluginVersionModelBinder))]
|
||||
PluginVersion version)
|
||||
{
|
||||
if (!serverEnvironment.EnableLocalArtifactDownloadProxy)
|
||||
return NotFound();
|
||||
|
||||
var url = await GetArtifactUrl(pluginSlug, version);
|
||||
if (url is null || !Uri.TryCreate(url, UriKind.Absolute, out var artifactUri) || !artifactUri.IsLoopback)
|
||||
return NotFound();
|
||||
|
||||
using var response = await httpClientFactory.CreateClient().GetAsync(artifactUri, HttpContext.RequestAborted);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
return StatusCode((int)response.StatusCode);
|
||||
|
||||
var package = await response.Content.ReadAsByteArrayAsync(HttpContext.RequestAborted);
|
||||
var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/zip";
|
||||
var fileName = Path.GetFileName(artifactUri.LocalPath);
|
||||
return File(package, contentType, fileName);
|
||||
}
|
||||
|
||||
private async Task<string?> GetArtifactUrl(PluginSlug pluginSlug, PluginVersion version)
|
||||
{
|
||||
await using var conn = await connectionFactory.Open();
|
||||
return await conn.ExecuteScalarAsync<string?>(
|
||||
"SELECT b.build_info->>'url' FROM versions v " +
|
||||
"JOIN builds b ON b.plugin_slug = v.plugin_slug AND b.id = v.build_id " +
|
||||
"WHERE v.plugin_slug=@plugin_slug AND v.ver=@version",
|
||||
new { plugin_slug = pluginSlug.ToString(), version = version.VersionParts });
|
||||
if (url is null)
|
||||
return NotFound();
|
||||
|
||||
await conn.InsertEvent("Download", new JObject { ["pluginSlug"] = pluginSlug.ToString(), ["version"] = version.ToString() });
|
||||
return Redirect(url);
|
||||
}
|
||||
|
||||
[HttpPost("plugins/{pluginSlug}/builds")]
|
||||
@ -282,6 +324,26 @@ public class ApiController(
|
||||
if (!ModelState.IsValid)
|
||||
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 buildUrl = Url.ActionLink(nameof(PluginController.Build), "Plugin",
|
||||
new { pluginSlug = pluginSlug.ToString(), buildId });
|
||||
|
||||
@ -26,85 +26,84 @@ public sealed class BtcMapsController(
|
||||
if (request is null)
|
||||
return BadRequest(new { errors = new[] { new ValidationError("body", "Request body is required.") } });
|
||||
|
||||
if (!request.SubmitToDirectory && !request.TagOnOsm && !request.UnlistFromOsm)
|
||||
return BadRequest(new { errors = new[] { new ValidationError("action", "Set submitToDirectory, tagOnOsm, and/or unlistFromOsm to true.") } });
|
||||
|
||||
var errors = btcMapsService.Validate(request);
|
||||
if (errors.Count > 0)
|
||||
return BadRequest(new { errors });
|
||||
|
||||
// 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");
|
||||
var response = new BtcMapsSubmitResponse();
|
||||
BtcMapsDirectoryResult? directory = null;
|
||||
BtcMapsBtcMapResult? btcMap = null;
|
||||
|
||||
if (request.SubmitToDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
response.Directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
|
||||
directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
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
|
||||
});
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "directory-upstream-failed", correlationId });
|
||||
}
|
||||
}
|
||||
|
||||
if (request.UnlistFromOsm)
|
||||
if (request.SubmitToBtcMap)
|
||||
{
|
||||
try
|
||||
{
|
||||
response.Osm = await btcMapsService.UnlistFromOsmAsync(request, cancellationToken);
|
||||
btcMap = await btcMapsService.SubmitToBtcMapAsync(request, cancellationToken);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
catch (BtcMapsService.BtcMapTokenMissingException ex)
|
||||
{
|
||||
logger.LogError(ex, "BTCMaps OSM un-list failed (correlationId={CorrelationId}) for {Name} node {NodeType}/{NodeId}",
|
||||
correlationId, request.Name, request.OsmNodeType, request.OsmNodeId);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new
|
||||
{
|
||||
error = "osm-upstream-failed",
|
||||
correlationId,
|
||||
partial = response
|
||||
});
|
||||
logger.LogError(ex, "BTCMaps import submission rejected: token not configured (correlationId={CorrelationId})", correlationId);
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "btcmap-not-configured", correlationId });
|
||||
}
|
||||
|
||||
// Conflict surface: when the element already carries none of the
|
||||
// bitcoin-related tags the service removes, the service reports
|
||||
// "already-unlisted" via Skipped. Return 409 so the plugin can
|
||||
// distinguish idempotent no-op from "actually removed just now".
|
||||
if (response.Osm?.Skipped == "already-unlisted")
|
||||
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Conflict(new
|
||||
{
|
||||
error = "already-unlisted",
|
||||
correlationId,
|
||||
partial = response
|
||||
});
|
||||
logger.LogInformation(ex, "BTCMaps import submission cancelled by caller (correlationId={CorrelationId})", correlationId);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
else if (request.TagOnOsm)
|
||||
{
|
||||
try
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
response.Osm = await btcMapsService.TagOnOsmAsync(request, cancellationToken);
|
||||
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) when (ex is not OperationCanceledException)
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "BTCMaps OSM tagging failed (correlationId={CorrelationId}) for {Name} node {NodeType}/{NodeId}",
|
||||
correlationId, request.Name, request.OsmNodeType, request.OsmNodeId);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new
|
||||
{
|
||||
error = "osm-upstream-failed",
|
||||
correlationId,
|
||||
partial = response
|
||||
});
|
||||
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(response);
|
||||
return Ok(new BtcMapsSubmitResponse { Directory = directory, BtcMap = btcMap });
|
||||
}
|
||||
}
|
||||
|
||||
@ -230,6 +230,7 @@ public class HomeController(
|
||||
lv.plugin_slug,
|
||||
lv.ver,
|
||||
p.settings,
|
||||
p.visibility,
|
||||
b.id,
|
||||
b.manifest_info,
|
||||
b.build_info,
|
||||
@ -278,7 +279,7 @@ public class HomeController(
|
||||
}
|
||||
|
||||
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,
|
||||
new
|
||||
@ -304,6 +305,7 @@ public class HomeController(
|
||||
BuildInfo = JObject.Parse(r.build_info),
|
||||
ManifestInfo = manifestInfo,
|
||||
PluginLogo = settings?.Logo,
|
||||
IsUnlisted = r.visibility == PluginVisibilityEnum.Unlisted,
|
||||
RatingSummary = new PluginRatingSummary
|
||||
{
|
||||
Average = r.avg_rating,
|
||||
|
||||
@ -318,6 +318,35 @@ public class PluginController(
|
||||
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")]
|
||||
public async Task<IActionResult> RequestListing(
|
||||
[ModelBinder(typeof(PluginSlugModelBinder))]
|
||||
@ -335,6 +364,7 @@ public class PluginController(
|
||||
if (plugin.Visibility == PluginVisibilityEnum.Hidden)
|
||||
return NotFound();
|
||||
|
||||
var allRequests = await conn.GetAllListingRequestsForPlugin(pluginSlug);
|
||||
var pluginOwners = await conn.GetPluginOwners(pluginSlug);
|
||||
var pluginSettings = SafeJson.Deserialize<PluginSettings>(plugin.Settings);
|
||||
var pendingRequest = await conn.GetPendingListingRequestForPlugin(pluginSlug);
|
||||
@ -342,6 +372,7 @@ public class PluginController(
|
||||
|
||||
model.ReleaseNote = pluginSettings?.Description;
|
||||
model.HasPreviousRejection = rejectedRequest != null;
|
||||
model.HasRequests = allRequests.Any();
|
||||
|
||||
if (pendingRequest != null)
|
||||
{
|
||||
@ -737,7 +768,6 @@ public class PluginController(
|
||||
}
|
||||
|
||||
var pluginSettings = await conn.GetPluginDetails(pluginSlug);
|
||||
vm.RequestListing = pluginSettings?.Visibility == PluginVisibilityEnum.Unlisted;
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
|
||||
@ -4,4 +4,6 @@ public static class HttpClientNames
|
||||
{
|
||||
public const string GitHub = nameof(GitHub);
|
||||
public const string GitLab = nameof(GitLab);
|
||||
public const string BtcMapsDirectory = nameof(BtcMapsDirectory);
|
||||
public const string BtcMap = nameof(BtcMap);
|
||||
}
|
||||
|
||||
@ -15,6 +15,23 @@ public class PluginListingRequest
|
||||
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
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,7 @@ using PluginBuilder.Authentication;
|
||||
using PluginBuilder.Configuration;
|
||||
using PluginBuilder.Controllers.Logic;
|
||||
using PluginBuilder.DataModels;
|
||||
using PluginBuilder.Filters;
|
||||
using PluginBuilder.HostedServices;
|
||||
using PluginBuilder.Hubs;
|
||||
using PluginBuilder.Services;
|
||||
@ -143,7 +144,10 @@ public class Program
|
||||
|
||||
public void AddServices(IConfiguration configuration, IServiceCollection services, IHostEnvironment env)
|
||||
{
|
||||
services.AddControllersWithViews()
|
||||
services.AddControllersWithViews(options =>
|
||||
{
|
||||
options.Filters.Add(new UIControllerAntiforgeryTokenAttribute());
|
||||
})
|
||||
.AddRazorRuntimeCompilation()
|
||||
.AddRazorOptions(options =>
|
||||
{
|
||||
@ -207,6 +211,28 @@ public class Program
|
||||
client.DefaultRequestHeaders.Authorization =
|
||||
new AuthenticationHeaderValue("Bearer", token);
|
||||
});
|
||||
services.AddHttpClient(HttpClientNames.BtcMapsDirectory, client =>
|
||||
{
|
||||
// Per-call timeout caps a single GitHub round-trip at 15s. The directory
|
||||
// submission makes ~5-7 GitHub calls sequentially; with the default 100s
|
||||
// timeout a hung remote could pin the request for ~10min and tie up a
|
||||
// rate-limit slot. 15s per call keeps the worst case bounded.
|
||||
client.BaseAddress = new Uri("https://api.github.com/");
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "PluginBuilder-BtcMaps/1.0");
|
||||
client.DefaultRequestHeaders.Add("X-GitHub-Api-Version", "2022-11-28");
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/vnd.github+json"));
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
services.AddHttpClient(HttpClientNames.BtcMap, client =>
|
||||
{
|
||||
// BTC Map import RPC is a single JSON-RPC 2.0 dispatch endpoint.
|
||||
// Per-call timeout caps a single round-trip at 15s, matching the
|
||||
// BtcMapsDirectory budget so a hung remote can't pin the request
|
||||
// longer than the per-IP rate-limit window.
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "PluginBuilder-BtcMap/1.0");
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
client.Timeout = TimeSpan.FromSeconds(15);
|
||||
});
|
||||
services.AddHttpClient(HttpClientNames.GitLab, client =>
|
||||
{
|
||||
client.BaseAddress = new Uri("https://gitlab.com/api/v4/");
|
||||
@ -223,7 +249,6 @@ public class Program
|
||||
services.AddSingleton<EmailService>();
|
||||
services.AddSingleton<FirstBuildEvent>();
|
||||
services.AddSingleton<NostrService>();
|
||||
services.AddSingleton<BtcMapsService>();
|
||||
|
||||
// shared controller logic
|
||||
services.AddSingleton<AdminSettingsCache>();
|
||||
@ -238,6 +263,7 @@ public class Program
|
||||
});
|
||||
services.AddScoped<PluginOwnershipService>();
|
||||
services.AddScoped<VersionLifecycleService>();
|
||||
services.AddSingleton<BtcMapsService>();
|
||||
|
||||
services.AddRateLimiter(options =>
|
||||
{
|
||||
@ -265,10 +291,16 @@ public class Program
|
||||
});
|
||||
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 = 5,
|
||||
PermitLimit = 3,
|
||||
Window = TimeSpan.FromHours(24),
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 0
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
"PB_POSTGRES": "User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=61932;Database=btcpayplugin",
|
||||
"PB_STORAGE_CONNECTION_STRING": "BlobEndpoint=http://127.0.0.1:32827/satoshi;AccountName=satoshi;AccountKey=Rxb41pUHRe+ibX5XS311tjXpjvu7mVi2xYJvtmq1j2jlUpN+fY/gkzyBMjqwzgj42geXGdYSbPEcu5i5wjSjPw==",
|
||||
"PB_CHEAT_MODE": "true",
|
||||
"PB_ENABLE_LOCAL_ARTIFACT_DOWNLOAD_PROXY": "true",
|
||||
"PB_DEBUGLOG": "debug.log"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -42,7 +42,6 @@ Thank you,
|
||||
BTCPay Server Plugin Builder Team
|
||||
";
|
||||
|
||||
|
||||
public Task<List<string>> SendEmail(string toCsvList, string subject, string messageText)
|
||||
{
|
||||
List<InternetAddress> toList = toCsvList.Split([","], StringSplitOptions.RemoveEmptyEntries)
|
||||
@ -172,7 +171,6 @@ BTCPay Server Plugin Builder";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public async Task<EmailSettingsViewModel?> GetEmailSettingsFromDb()
|
||||
{
|
||||
await using var conn = await connectionFactory.Open();
|
||||
|
||||
@ -5,7 +5,9 @@ public class ServerEnvironment
|
||||
public ServerEnvironment(IConfiguration configuration)
|
||||
{
|
||||
CheatMode = configuration.GetValue<bool?>("CHEAT_MODE") ?? false;
|
||||
EnableLocalArtifactDownloadProxy = configuration.GetValue<bool?>("ENABLE_LOCAL_ARTIFACT_DOWNLOAD_PROXY") ?? false;
|
||||
}
|
||||
|
||||
public bool CheatMode { get; set; }
|
||||
public bool EnableLocalArtifactDownloadProxy { get; set; }
|
||||
}
|
||||
|
||||
@ -860,6 +860,29 @@ public static class NpgsqlConnectionExtensions
|
||||
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)
|
||||
{
|
||||
const string sql = """
|
||||
|
||||
@ -5,7 +5,6 @@ namespace PluginBuilder.ViewModels;
|
||||
|
||||
public class BuildListViewModel
|
||||
{
|
||||
public bool RequestListing { get; set; }
|
||||
public List<BuildViewModel> Builds { get; set; } = [];
|
||||
|
||||
public class BuildViewModel
|
||||
|
||||
@ -25,7 +25,7 @@ public class RequestListingViewModel
|
||||
[Required]
|
||||
[Display(Name = "User Reviews")]
|
||||
public string UserReviews { get; set; } = string.Empty;
|
||||
|
||||
public bool HasRequests { get; set; }
|
||||
public bool PendingListing { get; set; }
|
||||
public bool HasPreviousRejection { get; set; }
|
||||
public bool CanSendEmailReminder { get; set; }
|
||||
|
||||
@ -2,232 +2,163 @@
|
||||
@model ListingRequestDetailViewModel
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData.SetActivePage(AdminNavPages.ListingRequests);
|
||||
ViewData.SetActivePage(PluginNavPages.RequestListing, "Request Listing");
|
||||
ViewData["Title"] = $"Listing Request - {Model.PluginTitle ?? Model.PluginSlug}";
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h2 class="mb-0">@ViewData["Title"]</h2>
|
||||
<div class="d-flex gap-2">
|
||||
<a asp-action="ListingRequests" class="btn btn-secondary">Back to List</a>
|
||||
@if (Model.Status == PluginListingRequestStatus.Pending)
|
||||
{
|
||||
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#approveModal">
|
||||
Approve
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#rejectModal">
|
||||
Reject
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="sticky-header">
|
||||
<h2 class="my-1">@ViewData["Title"]</h2>
|
||||
@if (Model.Status == PluginListingRequestStatus.Pending)
|
||||
{
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<button type="button" class="btn btn-success" data-bs-toggle="modal" data-bs-target="#approveModal">Approve</button>
|
||||
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#rejectModal">Reject</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Request Status</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 fw-semibold">Status:</div>
|
||||
<div class="col-sm-8">
|
||||
@switch (Model.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;
|
||||
}
|
||||
</div>
|
||||
<div class="row mt-0">
|
||||
<div class="col-lg-6">
|
||||
<div class="d-flex align-items-center gap-3 mb-5">
|
||||
@if (!string.IsNullOrEmpty(Model.Logo))
|
||||
{
|
||||
<img src="@Model.Logo" alt="Logo" style="width:64px;height:64px;object-fit:contain;border-radius:12px;flex-shrink:0;" />
|
||||
}
|
||||
<div>
|
||||
<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">
|
||||
<a asp-controller="Home" asp-action="GetPluginDetails" asp-route-pluginSlug="@Model.PluginSlug" target="_blank" class="small">View Public Page</a>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 fw-semibold">Submitted:</div>
|
||||
<div class="col-sm-8">@Model.SubmittedAt.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss UTC")</div>
|
||||
</div>
|
||||
@if (Model.ReviewedAt.HasValue)
|
||||
@if (!string.IsNullOrEmpty(Model.PluginDescription))
|
||||
{
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 fw-semibold">Reviewed:</div>
|
||||
<div class="col-sm-8">@Model.ReviewedAt.Value.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss UTC")</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 fw-semibold">Reviewed By:</div>
|
||||
<div class="col-sm-8">@Model.ReviewedByEmail</div>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.RejectionReason))
|
||||
{
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 fw-semibold">Rejection Reason:</div>
|
||||
<div class="col-sm-8">
|
||||
<div class="alert alert-danger mb-0">@Model.RejectionReason</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (Model.AnnouncementDate.HasValue)
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-sm-4 fw-semibold">Announcement Date:</div>
|
||||
<div class="col-sm-8">@Model.AnnouncementDate.Value.UtcDateTime.ToString("yyyy-MM-dd HH:mm UTC")</div>
|
||||
</div>
|
||||
<p class="mt-2 mb-0">@Model.PluginDescription</p>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Plugin Information</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 fw-semibold">Plugin Slug:</div>
|
||||
<div class="col-sm-8">
|
||||
<code>@Model.PluginSlug</code>
|
||||
<a asp-controller="Home" asp-action="GetPluginDetails" asp-route-pluginSlug="@Model.PluginSlug"
|
||||
target="_blank" class="ms-2">View Public Page</a>
|
||||
</div>
|
||||
</div>
|
||||
@if (!string.IsNullOrEmpty(Model.PluginTitle))
|
||||
{
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 fw-semibold">Title:</div>
|
||||
<div class="col-sm-8">@Model.PluginTitle</div>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.PluginDescription))
|
||||
{
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 fw-semibold">Description:</div>
|
||||
<div class="col-sm-8">@Model.PluginDescription</div>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.Logo))
|
||||
{
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 fw-semibold">Logo:</div>
|
||||
<div class="col-sm-8">
|
||||
<img src="@Model.Logo" alt="Plugin Logo" style="max-width: 200px; max-height: 100px;" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.GitRepository) || !string.IsNullOrEmpty(Model.Documentation))
|
||||
{
|
||||
<div class="mb-5">
|
||||
@if (!string.IsNullOrEmpty(Model.GitRepository))
|
||||
{
|
||||
<div class="row mb-3">
|
||||
<div class="col-sm-4 fw-semibold">Repository:</div>
|
||||
<div class="col-sm-8">
|
||||
<a href="@Model.GitRepository" target="_blank" rel="noopener">@Model.GitRepository</a>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-muted fw-semibold small text-uppercase mb-1" style="letter-spacing:.05em;">Repository</div>
|
||||
<a href="@Model.GitRepository" target="_blank" rel="noopener" class="small">@Model.GitRepository</a>
|
||||
</div>
|
||||
}
|
||||
@if (!string.IsNullOrEmpty(Model.Documentation))
|
||||
{
|
||||
<div class="row">
|
||||
<div class="col-sm-4 fw-semibold">Documentation:</div>
|
||||
<div class="col-sm-8">
|
||||
<a href="@Model.Documentation" target="_blank" rel="noopener">@Model.Documentation</a>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted fw-semibold small text-uppercase mb-1" style="letter-spacing:.05em;">Documentation</div>
|
||||
<a href="@Model.Documentation" target="_blank" rel="noopener" class="small">@Model.Documentation</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="mb-5">
|
||||
<div class="mb-3">
|
||||
<div class="text-muted fw-semibold small text-uppercase mb-1" style="letter-spacing:.05em;">Submitted</div>
|
||||
<span>@Model.SubmittedAt.UtcDateTime.ToString("MMM dd, yyyy · HH:mm UTC")</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<div class="text-muted fw-semibold small text-uppercase mb-1" style="letter-spacing:.05em;">Status</div>
|
||||
<p class="mb-0">@Model.Status</p>
|
||||
</div>
|
||||
@if (Model.ReviewedAt.HasValue)
|
||||
{
|
||||
<div class="mb-3">
|
||||
<div class="text-muted fw-semibold small text-uppercase mb-1" style="letter-spacing:.05em;">Reviewed By</div>
|
||||
<span>@Model.ReviewedByEmail · @Model.ReviewedAt.Value.UtcDateTime.ToString("MMM dd, yyyy")</span>
|
||||
</div>
|
||||
}
|
||||
@if (Model.AnnouncementDate.HasValue)
|
||||
{
|
||||
<div>
|
||||
<div class="text-muted fw-semibold small text-uppercase mb-1" style="letter-spacing:.05em;">Announcement</div>
|
||||
<span>@Model.AnnouncementDate.Value.UtcDateTime.ToString("MMM dd, yyyy · HH:mm UTC")</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Listing Request Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label class="fw-semibold">Release Note:</label>
|
||||
<p class="mt-2">@Model.ReleaseNote</p>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="fw-semibold">Telegram Verification:</label>
|
||||
<p class="mt-2">
|
||||
<a href="@Model.TelegramVerificationMessage" target="_blank" rel="noopener">
|
||||
@Model.TelegramVerificationMessage
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="fw-semibold">User Reviews:</label>
|
||||
<p class="mt-2">@Model.UserReviews</p>
|
||||
</div>
|
||||
<h5 class="fw-bold mb-4">Listing Request Details</h5>
|
||||
@if (!string.IsNullOrEmpty(Model.RejectionReason))
|
||||
{
|
||||
<div class="mb-4">
|
||||
<div class="text-muted fw-semibold small text-uppercase mb-2" style="letter-spacing:.05em;">Rejection Reason</div>
|
||||
<p class="mb-0 text-danger" style="white-space: pre-wrap;">@Model.RejectionReason</p>
|
||||
</div>
|
||||
}
|
||||
<div class="mb-4">
|
||||
<div class="text-muted fw-semibold small text-uppercase mb-2" style="letter-spacing:.05em;">Release Note</div>
|
||||
<p class="mb-0">@Model.ReleaseNote</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<div class="text-muted fw-semibold small text-uppercase mb-2" style="letter-spacing:.05em;">Telegram Verification</div>
|
||||
<a href="@Model.TelegramVerificationMessage" target="_blank" rel="noopener" class="text-break">
|
||||
@Model.TelegramVerificationMessage
|
||||
</a>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-muted fw-semibold small text-uppercase mb-2" style="letter-spacing:.05em;">User Reviews</div>
|
||||
<p class="mb-0">@Model.UserReviews</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">Plugin Owners</h5>
|
||||
<div class="col-lg-3 offset-lg-1">
|
||||
<h5 class="fw-bold mb-4">Plugin Owners</h5>
|
||||
@foreach (var owner in Model.Owners)
|
||||
{
|
||||
<div class="mb-4">
|
||||
<div class="d-flex align-items-center gap-2 mb-2">
|
||||
<span class="fw-semibold">@owner.Email</span>
|
||||
@if (owner.IsPrimary)
|
||||
{
|
||||
<span class="badge bg-primary">Primary</span>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex flex-column gap-1 small">
|
||||
<span class="@(owner.EmailVerified ? "text-success" : "text-muted")">
|
||||
<vc:icon symbol="@(owner.EmailVerified ? "checkmark" : "cross")" />
|
||||
Email @(owner.EmailVerified ? "Verified" : "Not Verified")
|
||||
</span>
|
||||
<span class="@(!string.IsNullOrWhiteSpace(owner.GithubProfile) ? "text-success" : "text-muted")">
|
||||
<vc:icon symbol="@(!string.IsNullOrWhiteSpace(owner.GithubProfile) ? "checkmark" : "cross")" />
|
||||
@if (!string.IsNullOrWhiteSpace(owner.GithubProfile))
|
||||
{
|
||||
<a href="@owner.GithubProfile" target="_blank" rel="noopener" class="text-success text-decoration-none">GitHub Verified</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>GitHub Not Verified</span>
|
||||
}
|
||||
</span>
|
||||
<span class="@(!string.IsNullOrWhiteSpace(owner.NostrProfile) ? "text-success" : "text-muted")">
|
||||
<vc:icon symbol="@(!string.IsNullOrWhiteSpace(owner.NostrProfile) ? "checkmark" : "cross")" />
|
||||
@if (!string.IsNullOrWhiteSpace(owner.NostrProfile))
|
||||
{
|
||||
<a href="@owner.NostrProfile" target="_blank" rel="noopener" class="text-success text-decoration-none">Nostr Verified</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>Nostr Not Verified</span>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
@foreach (var owner in Model.Owners)
|
||||
{
|
||||
<div class="mb-3 pb-3 @(owner != Model.Owners.Last() ? "border-bottom" : "")">
|
||||
<div class="d-flex align-items-center mb-2">
|
||||
<strong>@owner.Email</strong>
|
||||
@if (owner.IsPrimary)
|
||||
{
|
||||
<span class="badge bg-primary ms-2">Primary</span>
|
||||
}
|
||||
</div>
|
||||
<div class="small">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
@if (owner.EmailVerified)
|
||||
{
|
||||
<vc:icon symbol="checkmark" css-class="text-success me-1" />
|
||||
<a href="mailto:@owner.Email" class="text-decoration-none">
|
||||
Email Verified
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<vc:icon symbol="cross" css-class="text-danger me-1" />
|
||||
<span class="text-muted">Email Not Verified</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
@if (owner.GithubProfile != null)
|
||||
{
|
||||
<vc:icon symbol="checkmark" css-class="text-success me-1" />
|
||||
<a href="@owner.GithubProfile" target="_blank" rel="noopener" class="text-decoration-none">
|
||||
GitHub Verified
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<vc:icon symbol="cross" css-class="text-danger me-1" />
|
||||
<span class="text-muted">GitHub Not Verified</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center">
|
||||
@if (owner.NostrProfile != null)
|
||||
{
|
||||
<vc:icon symbol="checkmark" css-class="text-success me-1" />
|
||||
<a href="@owner.NostrProfile" target="_blank" rel="noopener" class="text-decoration-none">
|
||||
Nostr Verified
|
||||
</a>
|
||||
}
|
||||
else
|
||||
{
|
||||
<vc:icon symbol="cross" css-class="text-danger me-1" />
|
||||
<span class="text-muted">Nostr Not Verified</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -242,7 +173,7 @@
|
||||
@Html.AntiForgeryToken()
|
||||
<div class="modal-body">
|
||||
<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 class="modal-footer border-0">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||
|
||||
@ -99,7 +99,7 @@ else
|
||||
}
|
||||
</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
|
||||
</a>
|
||||
</td>
|
||||
|
||||
@ -102,6 +102,12 @@
|
||||
<div class="col-md-6 mb-4">
|
||||
<div class="card h-100 plugin-card" data-type="community">
|
||||
<div class="card-body">
|
||||
@if (plugin.IsUnlisted)
|
||||
{
|
||||
<div class="position-absolute top-0 end-0 mt-3 me-3 d-flex flex-wrap justify-content-end gap-2">
|
||||
<span class="badge fw-normal text-muted bg-medium rounded-pill px-2 py-1" title="This plugin is only shown in search results">Unlisted</span>
|
||||
</div>
|
||||
}
|
||||
<div class="row">
|
||||
<div style="display: flex; align-items: flex-start; margin-bottom: 20px;">
|
||||
<div style="margin-right: 15px;">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -44,10 +44,6 @@
|
||||
Public Page
|
||||
</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
|
||||
new build</a>
|
||||
</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
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData.SetActivePage(PluginNavPages.Dashboard, "Plugin Listing Request");
|
||||
ViewData.SetActivePage(PluginNavPages.RequestListing, "Plugin Listing Request");
|
||||
var step3Completed = Model.PendingListing;
|
||||
var step1Completed = Model.Step != RequestListingViewModel.State.UpdatePluginSettings;
|
||||
var step2Completed = Model.Step != RequestListingViewModel.State.UpdateOwnerAccountSettings && Model.Step != RequestListingViewModel.State.UpdatePluginSettings;
|
||||
@ -11,27 +11,29 @@
|
||||
}
|
||||
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h2 class="mb-0">
|
||||
@ViewData["Title"]
|
||||
</h2>
|
||||
@if (!Model.PendingListing)
|
||||
{
|
||||
<div class="d-flex gap-3 mt-3 mt-sm-0">
|
||||
<h2 class="mb-0">@ViewData["Title"]</h2>
|
||||
<div class="d-flex align-items-center gap-3 mt-3 mt-sm-0">
|
||||
@if (Model.HasRequests)
|
||||
{
|
||||
<a asp-action="ListingHistory" asp-route-pluginSlug="@Model.PluginSlug" class="btn btn-outline-secondary btn-sm">
|
||||
View History
|
||||
</a>
|
||||
}
|
||||
@if (!Model.PendingListing)
|
||||
{
|
||||
<button type="submit" form="request-listing-form" class="btn btn-success" @(Model.Step == RequestListingViewModel.State.Done ? "" : "disabled")>
|
||||
@(Model.HasPreviousRejection ? "Re-submit" : "Submit")
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
@if (Model.PendingListing && Model.CanSendEmailReminder)
|
||||
{
|
||||
<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>
|
||||
</form>
|
||||
}
|
||||
}
|
||||
@if (Model.PendingListing && Model.CanSendEmailReminder)
|
||||
{
|
||||
<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>
|
||||
</form>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<div class="accordion" id="requestListingAccordion">
|
||||
<div class="accordion-item">
|
||||
<h3 class="accordion-header" id="pluginSettingsHeader">
|
||||
|
||||
@ -52,6 +52,9 @@
|
||||
<symbol id="caret-right" viewBox="0 0 24 24">
|
||||
<path d="M9.5 17L14.5 12L9.5 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</symbol>
|
||||
<symbol id="caret-left" viewBox="0 0 24 24">
|
||||
<path d="M7.92303 12.0039C7.92303 12.3233 8.03989 12.5882 8.28919 12.8375L14.2881 18.704C14.4906 18.9065 14.7322 19 15.0204 19C15.6047 19 16.08 18.5403 16.08 17.956C16.08 17.6678 15.9553 17.4029 15.745 17.1925L10.4161 12.0117L15.745 6.81525C15.9553 6.6049 16.08 6.3478 16.08 6.05175C16.08 5.46745 15.6047 5 15.0204 5C14.7322 5 14.4906 5.10128 14.2881 5.30384L8.28919 11.1703C8.0321 11.4196 7.92303 11.6845 7.92303 12.0039Z" fill="currentColor"/>
|
||||
</symbol>
|
||||
<symbol id="caret-down" viewBox="0 0 24 24">
|
||||
<path d="M7 9.5L12 14.5L17 9.5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</symbol>
|
||||
|
||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
@ -15,6 +15,7 @@ All parameters are configured via environment variables.
|
||||
* `PB_POSTGRES`: Connection to a postgres database (example: `User ID=postgres;Include Error Detail=true;Host=127.0.0.1;Port=61932;Database=btcpayplugin`)
|
||||
* `PB_STORAGE_CONNECTION_STRING`: Connection string to azure storage to store build results (example: `BlobEndpoint=http://127.0.0.1:32827/satoshi;AccountName=satoshi;AccountKey=Rxb41pUHRe+ibX5XS311tjXpjvu7mVi2xYJvtmq1j2jlUpN+fY/gkzyBMjqwzgj42geXGdYSbPEcu5i5wjSjPw==`)
|
||||
* `PB_CHEAT_MODE`: If set to `true`, it's considered that the server is running in a development environment and will allow to bypass some security checks (right now only registering admin account).
|
||||
* `PB_ENABLE_LOCAL_ARTIFACT_DOWNLOAD_PROXY`: If set to `true`, loopback artifact URLs can be proxied through the API download endpoint for local development.
|
||||
* `ASPNETCORE_URLS`: The url the web server will be listening (example: `http://127.0.0.1:8080`)
|
||||
* `PB_DATADIR`: Where some persistent data get saved (example: `/datadir`)
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user