Compare commits
112 Commits
fix/rc-btc
...
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 | ||
|
|
645f28c7d8 | ||
|
|
fa9e2673d9 | ||
|
|
02df725394 | ||
|
|
9c51728140 | ||
|
|
3faa08f350 | ||
|
|
d98a71fc82 | ||
|
|
d9460f8818 | ||
|
|
7c67548270 | ||
|
|
9a0c8d6350 | ||
|
|
36b0882561 | ||
|
|
047cf851a7 | ||
|
|
1bed813873 | ||
|
|
9a5c8614d5 | ||
|
|
382831cd38 | ||
|
|
0366e9ce8e | ||
|
|
660f86bc18 | ||
|
|
f0ddd89293 | ||
|
|
d762e9f0ff | ||
|
|
b7158cda4f | ||
|
|
55315d1f1b | ||
|
|
4829306bb5 | ||
|
|
1643bb8aec | ||
|
|
ed621aae24 | ||
|
|
ad18746ac1 | ||
|
|
91ae5f726c | ||
|
|
0281bc437c | ||
|
|
ea02f78e40 | ||
|
|
b881066eb9 | ||
|
|
ebf197210b | ||
|
|
cdda01f559 | ||
|
|
639d8d3243 | ||
|
|
1e1e2210ea | ||
|
|
fbeea1c46f | ||
|
|
a2ce9d5094 | ||
|
|
581a9ed1ee | ||
|
|
4983d18c66 | ||
|
|
cf9e8f05fc | ||
|
|
315c80fe05 | ||
|
|
28f0ac1569 | ||
|
|
78c0629823 | ||
|
|
d6246d2e24 | ||
|
|
c1e7670995 | ||
|
|
5d1fe17941 | ||
|
|
fa3fc4151a | ||
|
|
af146b9d36 | ||
|
|
05f89ab06c | ||
|
|
53b3d654e7 | ||
|
|
5791b03961 | ||
|
|
001e43004e | ||
|
|
ed1454e408 | ||
|
|
fd39f0ddf7 | ||
|
|
4edb993eae | ||
|
|
3794307312 | ||
|
|
f6c07cd6fd | ||
|
|
9b63e29fb4 | ||
|
|
e9ff5c2661 | ||
|
|
2938422707 | ||
|
|
ba82867753 | ||
|
|
4a341e86a2 | ||
|
|
3269864f0c | ||
|
|
03dcd9de45 | ||
|
|
be667dceef | ||
|
|
177c744c08 | ||
|
|
96d44fd6b0 | ||
|
|
c31b1f4d8b |
132
PluginBuilder.Tests/ApiTests/CreateBuildValidationApiTests.cs
Normal file
132
PluginBuilder.Tests/ApiTests/CreateBuildValidationApiTests.cs
Normal file
@ -0,0 +1,132 @@
|
||||
using System.Net;
|
||||
using System.Text;
|
||||
using Dapper;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using PluginBuilder.APIModels;
|
||||
using PluginBuilder.Services;
|
||||
using PluginBuilder.Util.Extensions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace PluginBuilder.Tests.ApiTests;
|
||||
|
||||
public class CreateBuildValidationApiTests(ITestOutputHelper logs) : UnitTestBase(logs)
|
||||
{
|
||||
private const string Password = "123456";
|
||||
private const string MediaType = "application/json";
|
||||
|
||||
private static readonly JsonSerializerSettings SerializerSettings = new() { ContractResolver = new CamelCasePropertyNamesContractResolver() };
|
||||
|
||||
[Fact]
|
||||
public async Task Should_Return422_If_InvalidPluginDirectory_WithoutCsproj()
|
||||
{
|
||||
// Arrange
|
||||
await using var tester = Create();
|
||||
tester.ReuseDatabase = false;
|
||||
await tester.Start();
|
||||
|
||||
var email = $"test-{Guid.NewGuid():N}@example.com";
|
||||
var ownerId = await tester.CreateFakeUserAsync(email, Password);
|
||||
var pluginSlug = "test-no-csproj-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
await using var conn = await tester.GetService<DBConnectionFactory>().Open();
|
||||
await conn.NewPlugin(pluginSlug, ownerId);
|
||||
|
||||
var client = tester.CreateHttpClient().SetBasicAuth(email, Password);
|
||||
|
||||
var request = new CreateBuildRequest
|
||||
{
|
||||
GitRepository = ServerTester.RepoUrl,
|
||||
GitRef = ServerTester.GitRef,
|
||||
PluginDirectory = "Invalid/Path/Without/Csproj",
|
||||
BuildConfig = "Release"
|
||||
};
|
||||
|
||||
// Act
|
||||
var content = new StringContent(
|
||||
JsonConvert.SerializeObject(request, SerializerSettings),
|
||||
Encoding.UTF8,
|
||||
MediaType);
|
||||
|
||||
var response = await client.PostAsync(
|
||||
$"/api/v1/plugins/{pluginSlug}/builds",
|
||||
content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("Manifest validation failed:", result);
|
||||
|
||||
Assert.False(await conn.ExecuteScalarAsync<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM builds WHERE plugin_slug = @pluginSlug)",
|
||||
new { pluginSlug }));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Should_Return422_If_SameRepo_UsedForAnotherPluginSlug()
|
||||
{
|
||||
// Arrange
|
||||
await using var tester = Create();
|
||||
tester.ReuseDatabase = false;
|
||||
await tester.Start();
|
||||
|
||||
var email1 = $"owner1-{Guid.NewGuid():N}@example.com";
|
||||
var email2 = $"owner2-{Guid.NewGuid():N}@example.com";
|
||||
|
||||
var ownerId1 = await tester.CreateFakeUserAsync(email1, Password);
|
||||
var ownerId2 = await tester.CreateFakeUserAsync(email2, Password);
|
||||
var pluginSlug1 = "first-plugin-" + Guid.NewGuid().ToString("N")[..8];
|
||||
|
||||
await using var conn = await tester.GetService<DBConnectionFactory>().Open();
|
||||
await conn.NewPlugin(pluginSlug1, ownerId1);
|
||||
|
||||
var buildService = tester.GetService<BuildService>();
|
||||
var actualIdentifier = await buildService.FetchIdentifierFromCsprojAsync(
|
||||
ServerTester.RepoUrl,
|
||||
ServerTester.GitRef,
|
||||
ServerTester.PluginDir);
|
||||
|
||||
await conn.ExecuteAsync(
|
||||
"UPDATE plugins SET identifier = @identifier WHERE slug = @slug",
|
||||
new
|
||||
{
|
||||
identifier = actualIdentifier,
|
||||
slug = pluginSlug1
|
||||
});
|
||||
|
||||
var client = tester.CreateHttpClient().SetBasicAuth(email2, Password);
|
||||
|
||||
var pluginSlug2 = "second-plugin-" + Guid.NewGuid().ToString("N")[..8];
|
||||
await conn.NewPlugin(pluginSlug2, ownerId2);
|
||||
|
||||
var request = new CreateBuildRequest
|
||||
{
|
||||
GitRepository = ServerTester.RepoUrl,
|
||||
GitRef = ServerTester.GitRef,
|
||||
PluginDirectory = ServerTester.PluginDir,
|
||||
BuildConfig = "Release"
|
||||
};
|
||||
|
||||
// Act
|
||||
var content = new StringContent(
|
||||
JsonConvert.SerializeObject(request, SerializerSettings),
|
||||
Encoding.UTF8,
|
||||
MediaType);
|
||||
|
||||
var response = await client.PostAsync(
|
||||
$"/api/v1/plugins/{pluginSlug2}/builds",
|
||||
content);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.UnprocessableEntity, response.StatusCode);
|
||||
|
||||
var result = await response.Content.ReadAsStringAsync();
|
||||
Assert.Contains("does not belong to plugin slug", result);
|
||||
|
||||
Assert.False(await conn.ExecuteScalarAsync<bool>(
|
||||
"SELECT EXISTS(SELECT 1 FROM builds WHERE plugin_slug = @pluginSlug)",
|
||||
new { pluginSlug = pluginSlug2 }));
|
||||
}
|
||||
}
|
||||
388
PluginBuilder.Tests/BtcMapsServiceTests.cs
Normal file
388
PluginBuilder.Tests/BtcMapsServiceTests.cs
Normal file
@ -0,0 +1,388 @@
|
||||
using System.Net.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using PluginBuilder.APIModels;
|
||||
using PluginBuilder.Services;
|
||||
using Xunit;
|
||||
|
||||
namespace PluginBuilder.Tests;
|
||||
|
||||
public class BtcMapsServiceTests
|
||||
{
|
||||
private sealed class StubHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new HttpClient();
|
||||
}
|
||||
|
||||
private static BtcMapsService MakeService() =>
|
||||
new BtcMapsService(
|
||||
configuration: new ConfigurationBuilder().Build(),
|
||||
httpClientFactory: new StubHttpClientFactory(),
|
||||
logger: NullLogger<BtcMapsService>.Instance);
|
||||
|
||||
private static BtcMapsSubmitRequest MakeValid() => new()
|
||||
{
|
||||
Name = "Good Shop",
|
||||
Url = "https://goodshop.example",
|
||||
Description = "A very good shop.",
|
||||
Type = "merchants"
|
||||
};
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsMinimalDirectoryRequest()
|
||||
{
|
||||
Assert.Empty(MakeService().Validate(MakeValid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingName()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.Name = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsOverlongName()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.Name = new string('x', 201);
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Name));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsNonHttpsUrl()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.Url = "http://plain.example";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Url));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingDescription()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.Description = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsOverlongDescription()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.Description = new string('x', 1001);
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Description));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingType()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.Type = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Type));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsInvalidType()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.Type = "shops";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Type));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsInvalidMerchantSubType()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.SubType = "not-a-real-subtype";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.SubType));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsValidMerchantSubType()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.SubType = "books";
|
||||
Assert.Empty(MakeService().Validate(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsIsoAlpha2Country()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.Country = "DE";
|
||||
Assert.Empty(MakeService().Validate(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsGlobalCountry()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.Country = "GLOBAL";
|
||||
Assert.Empty(MakeService().Validate(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsLowerCaseCountry()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.Country = "de";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Country));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsThreeLetterCountry()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.Country = "DEU";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Country));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsNonAssignedTwoLetterCountry()
|
||||
{
|
||||
// ZZ is reserved / not assigned in ISO 3166-1, so the validator must
|
||||
// reject it even though it passes the length + casing check.
|
||||
var req = MakeValid();
|
||||
req.Country = "ZZ";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Country));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsOnionHttpsUrl()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.OnionUrl = "https://abc123.onion";
|
||||
Assert.Empty(MakeService().Validate(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsOnionHttpUrl()
|
||||
{
|
||||
// Onion v3 addresses are commonly served over http (Tor provides the transport
|
||||
// encryption); the validator allows http on a .onion host explicitly.
|
||||
var req = MakeValid();
|
||||
req.OnionUrl = "http://abc123.onion";
|
||||
Assert.Empty(MakeService().Validate(req));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsNonOnionOnionUrl()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.OnionUrl = "https://example.com";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.OnionUrl));
|
||||
}
|
||||
|
||||
// BTC Map import-RPC fields are mandatory only when SubmitToBtcMap=true. The
|
||||
// default-false path preserves the directory-only callers untouched.
|
||||
|
||||
private static BtcMapsSubmitRequest MakeValidBtcMap()
|
||||
{
|
||||
var req = MakeValid();
|
||||
req.SubmitToBtcMap = true;
|
||||
req.Lat = 51.5074;
|
||||
req.Lon = -0.1278;
|
||||
req.Category = "cafe";
|
||||
req.ExternalId = "store.example.com:abc123";
|
||||
return req;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_AcceptsValidBtcMapSubmission()
|
||||
{
|
||||
Assert.Empty(MakeService().Validate(MakeValidBtcMap()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_DoesNotRequireBtcMapFieldsByDefault()
|
||||
{
|
||||
// Directory-only callers (the pre-existing shape) must not break: a
|
||||
// request with SubmitToBtcMap unset (default false) and no Lat / Lon /
|
||||
// Category / ExternalId is still valid.
|
||||
Assert.Empty(MakeService().Validate(MakeValid()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingLatWhenSubmitToBtcMap()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Lat = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsOutOfRangeLat()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Lat = 91.0;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsNaNLat()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Lat = double.NaN;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lat));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingLonWhenSubmitToBtcMap()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Lon = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lon));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsOutOfRangeLon()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Lon = -180.5;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Lon));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingCategoryWhenSubmitToBtcMap()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Category = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsUppercaseCategoryWhenSubmitToBtcMap()
|
||||
{
|
||||
// BTC Map docs: "Use a short, single-word (if possible), lowercase identifier."
|
||||
var req = MakeValidBtcMap();
|
||||
req.Category = "Cafe";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsCategoryWithInvalidCharacters()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.Category = "cafe!";
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.Category));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsMissingExternalIdWhenSubmitToBtcMap()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.ExternalId = null;
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.ExternalId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Validate_RejectsOverlongExternalId()
|
||||
{
|
||||
var req = MakeValidBtcMap();
|
||||
req.ExternalId = new string('x', 201);
|
||||
Assert.Contains(MakeService().Validate(req), e => e.Path == nameof(BtcMapsSubmitRequest.ExternalId));
|
||||
}
|
||||
|
||||
private static BtcMapsService MakeServiceWithConfig(IDictionary<string, string?> config) =>
|
||||
new BtcMapsService(
|
||||
configuration: new ConfigurationBuilder().AddInMemoryCollection(config).Build(),
|
||||
httpClientFactory: new StubHttpClientFactory(),
|
||||
logger: NullLogger<BtcMapsService>.Instance);
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task SubmitToBtcMapAsync_RejectsHttpEndpoint()
|
||||
{
|
||||
// Bearer token must never cross the wire over plaintext http://. Misconfigured
|
||||
// endpoint surfaces as InvalidOperationException before HttpClient.SendAsync,
|
||||
// so the token is never built into a request header.
|
||||
var service = MakeServiceWithConfig(new Dictionary<string, string?>
|
||||
{
|
||||
["BTCMAPS:BtcMapImportToken"] = "test-token",
|
||||
["BTCMAPS:BtcMapImportEndpoint"] = "http://api.btcmap.org/rpc"
|
||||
});
|
||||
var ex = await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.SubmitToBtcMapAsync(MakeValidBtcMap()));
|
||||
Assert.Contains("https", ex.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task SubmitToBtcMapAsync_RejectsNonAbsoluteEndpoint()
|
||||
{
|
||||
var service = MakeServiceWithConfig(new Dictionary<string, string?>
|
||||
{
|
||||
["BTCMAPS:BtcMapImportToken"] = "test-token",
|
||||
["BTCMAPS:BtcMapImportEndpoint"] = "/rpc"
|
||||
});
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => service.SubmitToBtcMapAsync(MakeValidBtcMap()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async System.Threading.Tasks.Task SubmitToBtcMapAsync_ThrowsTokenMissingWhenUnset()
|
||||
{
|
||||
// Token unset is the ops-deployment-pending shape; controller maps this to
|
||||
// 503 btcmap-not-configured. Test the underlying exception type so the
|
||||
// controller exception ladder stays wired.
|
||||
var service = MakeServiceWithConfig(new Dictionary<string, string?>());
|
||||
await Assert.ThrowsAsync<BtcMapsService.BtcMapTokenMissingException>(
|
||||
() => service.SubmitToBtcMapAsync(MakeValidBtcMap()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeUrl_LowercasesSchemeAndHostOnly()
|
||||
{
|
||||
// Scheme + host are case-insensitive (DNS + RFC); path + query are not, so
|
||||
// they must be preserved verbatim. Trailing slash is stripped only when the
|
||||
// path is non-root.
|
||||
Assert.Equal("https://example.com/", BtcMapsService.NormalizeUrl("HTTPS://Example.com/"));
|
||||
Assert.Equal("https://example.com/", BtcMapsService.NormalizeUrl(" https://example.com "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeUrl_PreservesPathCase()
|
||||
{
|
||||
Assert.Equal("https://example.com/Foo/Bar",
|
||||
BtcMapsService.NormalizeUrl("HTTPS://Example.com/Foo/Bar/"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NormalizeUrl_PreservesQueryCase()
|
||||
{
|
||||
Assert.Equal("https://example.com/path?ID=ABC",
|
||||
BtcMapsService.NormalizeUrl("https://EXAMPLE.com/path?ID=ABC"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBranchName_DeterministicForSameUrl()
|
||||
{
|
||||
var a = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/foo");
|
||||
var b = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/foo");
|
||||
Assert.Equal(a, b);
|
||||
Assert.StartsWith("btcmaps/good-shop-", a);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBranchName_DiffersForDifferentUrls()
|
||||
{
|
||||
var a = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/foo");
|
||||
var b = BtcMapsService.BuildBranchName("Good Shop", "https://example.com/bar");
|
||||
Assert.NotEqual(a, b);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slugify_ProducesUrlSafeSegment()
|
||||
{
|
||||
Assert.Equal("good-shop", BtcMapsService.Slugify("Good Shop!"));
|
||||
Assert.Equal("merchant", BtcMapsService.Slugify("!!!"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Slugify_CapsLengthAtFortyChars()
|
||||
{
|
||||
var input = new string('a', 80);
|
||||
var slug = BtcMapsService.Slugify(input);
|
||||
Assert.True(slug.Length <= 40);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,157 @@
|
||||
using System.Reflection;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Mvc.Abstractions;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
using Microsoft.AspNetCore.Mvc.Filters;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using PluginBuilder.Controllers;
|
||||
using PluginBuilder.Filters;
|
||||
using Xunit;
|
||||
|
||||
namespace PluginBuilder.Tests.FilterTests;
|
||||
|
||||
public class UIControllerAntiforgeryTokenAttributeTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task OnAuthorizationAsync_WithPostUiRequest_ValidationFailure_SetsResultAndErrorDetails()
|
||||
{
|
||||
var filter = new UIControllerAntiforgeryTokenAttribute();
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IAntiforgery, ThrowingAntiforgery>()
|
||||
.BuildServiceProvider();
|
||||
|
||||
var context = CreateContext(filter, typeof(DummyUiController), HttpMethods.Post, services);
|
||||
|
||||
await filter.OnAuthorizationAsync(context);
|
||||
|
||||
Assert.IsType<AntiforgeryValidationFailedResult>(context.Result);
|
||||
Assert.Equal("CSRF token validation failed.", context.HttpContext.Items[UIErrorController.ErrorDetailsKey]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnAuthorizationAsync_WithPostApiRequest_DoesNotValidate()
|
||||
{
|
||||
var filter = new UIControllerAntiforgeryTokenAttribute();
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IAntiforgery, ThrowingAntiforgery>()
|
||||
.BuildServiceProvider();
|
||||
|
||||
var context = CreateContext(filter, typeof(DummyApiController), HttpMethods.Post, services);
|
||||
|
||||
await filter.OnAuthorizationAsync(context);
|
||||
|
||||
Assert.Null(context.Result);
|
||||
Assert.False(context.HttpContext.Items.ContainsKey(UIErrorController.ErrorDetailsKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnAuthorizationAsync_WithIgnoreAntiforgeryPolicy_DoesNotValidate()
|
||||
{
|
||||
var filter = new UIControllerAntiforgeryTokenAttribute();
|
||||
var services = new ServiceCollection()
|
||||
.AddSingleton<IAntiforgery, ThrowingAntiforgery>()
|
||||
.BuildServiceProvider();
|
||||
|
||||
var context = CreateContext(
|
||||
filter,
|
||||
typeof(DummyUiController),
|
||||
HttpMethods.Post,
|
||||
services,
|
||||
new IgnoreAntiforgeryTokenAttribute());
|
||||
|
||||
await filter.OnAuthorizationAsync(context);
|
||||
|
||||
Assert.Null(context.Result);
|
||||
Assert.False(context.HttpContext.Items.ContainsKey(UIErrorController.ErrorDetailsKey));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OnResultExecutionAsync_WithAntiforgeryFailureResult_AddsErrorDetails()
|
||||
{
|
||||
var filter = new UIControllerAntiforgeryTokenAttribute();
|
||||
var httpContext = new DefaultHttpContext();
|
||||
var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor());
|
||||
List<IFilterMetadata> filters = new() { filter };
|
||||
var result = new AntiforgeryValidationFailedResult();
|
||||
var controller = new object();
|
||||
var context = new ResultExecutingContext(actionContext, filters, result, controller);
|
||||
|
||||
await filter.OnResultExecutionAsync(context, () =>
|
||||
{
|
||||
var executedContext = new ResultExecutedContext(actionContext, filters, result, controller);
|
||||
return Task.FromResult(executedContext);
|
||||
});
|
||||
|
||||
Assert.Equal("CSRF token validation failed.", context.HttpContext.Items[UIErrorController.ErrorDetailsKey]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void NostrVerifyNip07_HasIgnoreAntiforgeryTokenAttribute()
|
||||
{
|
||||
var method = typeof(AccountController).GetMethod(nameof(AccountController.NostrVerifyNip07));
|
||||
|
||||
Assert.NotNull(method);
|
||||
Assert.NotNull(method!.GetCustomAttribute<IgnoreAntiforgeryTokenAttribute>());
|
||||
}
|
||||
|
||||
private static AuthorizationFilterContext CreateContext(
|
||||
UIControllerAntiforgeryTokenAttribute filter,
|
||||
Type controllerType,
|
||||
string method,
|
||||
IServiceProvider services,
|
||||
params IFilterMetadata[] extraFilters)
|
||||
{
|
||||
var httpContext = new DefaultHttpContext
|
||||
{
|
||||
RequestServices = services
|
||||
};
|
||||
httpContext.Request.Method = method;
|
||||
|
||||
var descriptor = new ControllerActionDescriptor
|
||||
{
|
||||
ActionName = "Action",
|
||||
ControllerName = controllerType.Name.Replace("Controller", string.Empty, StringComparison.Ordinal),
|
||||
ControllerTypeInfo = controllerType.GetTypeInfo()
|
||||
};
|
||||
|
||||
var actionContext = new ActionContext(httpContext, new RouteData(), descriptor);
|
||||
List<IFilterMetadata> filters = new();
|
||||
filters.Add(filter);
|
||||
filters.AddRange(extraFilters);
|
||||
|
||||
// Match MVC's ordered execution so IAntiforgeryPolicy precedence is realistic in tests.
|
||||
var orderedFilters = filters
|
||||
.Select((metadata, index) => new
|
||||
{
|
||||
Metadata = metadata,
|
||||
Order = (metadata as IOrderedFilter)?.Order ?? 0,
|
||||
Index = index
|
||||
})
|
||||
.OrderBy(x => x.Order)
|
||||
.ThenBy(x => x.Index)
|
||||
.Select(x => x.Metadata)
|
||||
.ToList();
|
||||
|
||||
return new AuthorizationFilterContext(actionContext, orderedFilters);
|
||||
}
|
||||
|
||||
private sealed class DummyUiController : Controller;
|
||||
|
||||
private sealed class DummyApiController : ControllerBase;
|
||||
|
||||
private sealed class ThrowingAntiforgery : IAntiforgery
|
||||
{
|
||||
public AntiforgeryTokenSet GetAndStoreTokens(HttpContext httpContext) => throw new NotSupportedException();
|
||||
|
||||
public AntiforgeryTokenSet GetTokens(HttpContext httpContext) => throw new NotSupportedException();
|
||||
|
||||
public Task<bool> IsRequestValidAsync(HttpContext httpContext) => throw new NotSupportedException();
|
||||
|
||||
public void SetCookieTokenAndHeader(HttpContext httpContext) => throw new NotSupportedException();
|
||||
|
||||
public Task ValidateRequestAsync(HttpContext httpContext) => throw new AntiforgeryValidationException("Invalid CSRF token.");
|
||||
}
|
||||
}
|
||||
754
PluginBuilder.Tests/GitHostingProviderTests.cs
Normal file
754
PluginBuilder.Tests/GitHostingProviderTests.cs
Normal file
@ -0,0 +1,754 @@
|
||||
using System.Net;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using PluginBuilder.APIModels;
|
||||
using PluginBuilder.DataModels;
|
||||
using PluginBuilder.Services;
|
||||
using PluginBuilder.Util;
|
||||
using Xunit;
|
||||
|
||||
namespace PluginBuilder.Tests;
|
||||
|
||||
public class GitHostingProviderTests
|
||||
{
|
||||
// ── GitHub CanHandle ──────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://github.com/owner/repo", true)]
|
||||
[InlineData("https://www.github.com/owner/repo", true)]
|
||||
[InlineData("https://github.com/owner/repo.git", true)]
|
||||
[InlineData("https://GITHUB.COM/owner/repo", true)]
|
||||
[InlineData("https://gitlab.com/owner/repo", false)]
|
||||
[InlineData("https://bitbucket.org/owner/repo", false)]
|
||||
[InlineData("not a url", false)]
|
||||
public void GitHub_CanHandle(string url, bool expected)
|
||||
{
|
||||
var provider = CreateGitHubProvider();
|
||||
Assert.Equal(expected, provider.CanHandle(url));
|
||||
}
|
||||
|
||||
// ── GitLab CanHandle ──────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://gitlab.com/owner/repo", true)]
|
||||
[InlineData("https://www.gitlab.com/owner/repo", true)]
|
||||
[InlineData("https://gitlab.com/group/subgroup/repo", true)]
|
||||
[InlineData("https://GITLAB.COM/owner/repo", true)]
|
||||
[InlineData("https://github.com/owner/repo", false)]
|
||||
[InlineData("not a url", false)]
|
||||
public void GitLab_CanHandle(string url, bool expected)
|
||||
{
|
||||
var provider = CreateGitLabProvider();
|
||||
Assert.Equal(expected, provider.CanHandle(url));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://gitlab.selfhosted.com/owner/repo", true)]
|
||||
[InlineData("https://github.com/owner/repo", false)]
|
||||
public void GitLab_CanHandle_AdditionalHosts(string url, bool expected)
|
||||
{
|
||||
var provider = CreateGitLabProvider(additionalHosts: new[] { "gitlab.selfhosted.com" });
|
||||
Assert.Equal(expected, provider.CanHandle(url));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
// Config "host:port" matches URL with same port
|
||||
[InlineData("gitlab.example.com:8443", "https://gitlab.example.com:8443/owner/repo", true)]
|
||||
// Config "host:port" does NOT match URL without port
|
||||
[InlineData("gitlab.example.com:8443", "https://gitlab.example.com/owner/repo", false)]
|
||||
// Config "host" matches URL with any port (host fallback)
|
||||
[InlineData("gitlab.example.com", "https://gitlab.example.com:8443/owner/repo", true)]
|
||||
[InlineData("gitlab.example.com", "https://gitlab.example.com/owner/repo", true)]
|
||||
public void GitLab_CanHandle_AdditionalHosts_PortAware(string configuredHost, string url, bool expected)
|
||||
{
|
||||
var provider = CreateGitLabProvider(additionalHosts: new[] { configuredHost });
|
||||
Assert.Equal(expected, provider.CanHandle(url));
|
||||
}
|
||||
|
||||
// ── GitHub ParseRepository ────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://github.com/Kukks/btcpayserver", "Kukks", "btcpayserver")]
|
||||
[InlineData("https://github.com/Kukks/btcpayserver.git", "Kukks", "btcpayserver")]
|
||||
[InlineData("https://www.github.com/Kukks/btcpayserver/", "Kukks", "btcpayserver")]
|
||||
public void GitHub_ParseRepository_Valid(string url, string expectedOwner, string expectedRepo)
|
||||
{
|
||||
var provider = CreateGitHubProvider();
|
||||
var result = provider.ParseRepository(url);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(expectedOwner, result.Value.Owner);
|
||||
Assert.Equal(expectedRepo, result.Value.RepoName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("https://github.com/")]
|
||||
[InlineData("https://gitlab.com/owner/repo")]
|
||||
public void GitHub_ParseRepository_Invalid(string url)
|
||||
{
|
||||
var provider = CreateGitHubProvider();
|
||||
var result = provider.ParseRepository(url);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// ── GitLab ParseRepository ────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://gitlab.com/owner/repo", "owner", "repo")]
|
||||
[InlineData("https://gitlab.com/owner/repo.git", "owner", "repo")]
|
||||
[InlineData("https://gitlab.com/group/subgroup/repo", "group/subgroup", "repo")]
|
||||
[InlineData("https://gitlab.com/a/b/c/repo", "a/b/c", "repo")]
|
||||
public void GitLab_ParseRepository_Valid(string url, string expectedOwner, string expectedRepo)
|
||||
{
|
||||
var provider = CreateGitLabProvider();
|
||||
var result = provider.ParseRepository(url);
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(expectedOwner, result.Value.Owner);
|
||||
Assert.Equal(expectedRepo, result.Value.RepoName);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData("https://gitlab.com/")]
|
||||
[InlineData("https://gitlab.com/onlyone")]
|
||||
public void GitLab_ParseRepository_Invalid(string url)
|
||||
{
|
||||
var provider = CreateGitLabProvider();
|
||||
var result = provider.ParseRepository(url);
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
// ── GitHub GetSourceUrl ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GitHub_GetSourceUrl_WithCommitAndPluginDir()
|
||||
{
|
||||
var provider = CreateGitHubProvider();
|
||||
var url = provider.GetSourceUrl(
|
||||
"https://github.com/NicolasDorier/btcpayserver",
|
||||
"abc123",
|
||||
"Plugins/MyPlugin");
|
||||
Assert.Equal("https://github.com/NicolasDorier/btcpayserver/tree/abc123/Plugins/MyPlugin", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitHub_GetSourceUrl_WithCommitNoPluginDir()
|
||||
{
|
||||
var provider = CreateGitHubProvider();
|
||||
var url = provider.GetSourceUrl(
|
||||
"https://github.com/NicolasDorier/btcpayserver",
|
||||
"abc123",
|
||||
null);
|
||||
Assert.Equal("https://github.com/NicolasDorier/btcpayserver/tree/abc123", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitHub_GetSourceUrl_NullCommit()
|
||||
{
|
||||
var provider = CreateGitHubProvider();
|
||||
var url = provider.GetSourceUrl(
|
||||
"https://github.com/NicolasDorier/btcpayserver",
|
||||
null,
|
||||
"Plugins/MyPlugin");
|
||||
Assert.Null(url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitHub_GetSourceUrl_GitAtUrl()
|
||||
{
|
||||
var provider = CreateGitHubProvider();
|
||||
var url = provider.GetSourceUrl(
|
||||
"git@github.com:Kukks/btcpayserver.git",
|
||||
"abc123",
|
||||
"Plugins/AOPP");
|
||||
Assert.Equal("https://github.com/Kukks/btcpayserver/tree/abc123/Plugins/AOPP", url);
|
||||
}
|
||||
|
||||
// ── GitLab GetSourceUrl ───────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GitLab_GetSourceUrl_WithCommitAndPluginDir()
|
||||
{
|
||||
var provider = CreateGitLabProvider();
|
||||
var url = provider.GetSourceUrl(
|
||||
"https://gitlab.com/mygroup/myrepo",
|
||||
"def456",
|
||||
"src/MyPlugin");
|
||||
Assert.Equal("https://gitlab.com/mygroup/myrepo/-/tree/def456/src/MyPlugin", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitLab_GetSourceUrl_NestedGroup()
|
||||
{
|
||||
var provider = CreateGitLabProvider();
|
||||
var url = provider.GetSourceUrl(
|
||||
"https://gitlab.com/group/subgroup/repo",
|
||||
"def456",
|
||||
null);
|
||||
Assert.Equal("https://gitlab.com/group/subgroup/repo/-/tree/def456", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitLab_GetSourceUrl_NullCommit()
|
||||
{
|
||||
var provider = CreateGitLabProvider();
|
||||
var url = provider.GetSourceUrl(
|
||||
"https://gitlab.com/owner/repo",
|
||||
null,
|
||||
null);
|
||||
Assert.Null(url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitLab_GetSourceUrl_DotGitSuffix()
|
||||
{
|
||||
var provider = CreateGitLabProvider();
|
||||
var url = provider.GetSourceUrl(
|
||||
"https://gitlab.com/owner/repo.git",
|
||||
"abc",
|
||||
null);
|
||||
Assert.Equal("https://gitlab.com/owner/repo/-/tree/abc", url);
|
||||
}
|
||||
|
||||
// ── Factory ───────────────────────────────────────────────────────
|
||||
|
||||
[Theory]
|
||||
[InlineData("https://github.com/owner/repo", typeof(GitHubHostingProvider))]
|
||||
[InlineData("https://gitlab.com/owner/repo", typeof(GitLabHostingProvider))]
|
||||
public void Factory_ReturnsCorrectProvider(string url, Type expectedType)
|
||||
{
|
||||
var factory = CreateFactory();
|
||||
var provider = factory.GetProvider(url);
|
||||
Assert.NotNull(provider);
|
||||
Assert.IsType(expectedType, provider);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("https://bitbucket.org/owner/repo")]
|
||||
public void Factory_ReturnsNull_ForUnsupported(string? url)
|
||||
{
|
||||
var factory = CreateFactory();
|
||||
var provider = factory.GetProvider(url);
|
||||
Assert.Null(provider);
|
||||
}
|
||||
|
||||
// ── PublishedPlugin provider-agnostic methods ─────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PublishedPlugin_GetSourceUrl_GitHub()
|
||||
{
|
||||
var factory = CreateFactory();
|
||||
var plugin = new PublishedPlugin
|
||||
{
|
||||
BuildInfo = JObject.FromObject(new
|
||||
{
|
||||
gitRepository = "https://github.com/owner/repo",
|
||||
gitCommit = "abc123",
|
||||
pluginDir = "Plugins/MyPlugin"
|
||||
})
|
||||
};
|
||||
var url = plugin.GetSourceUrl(factory);
|
||||
Assert.Equal("https://github.com/owner/repo/tree/abc123/Plugins/MyPlugin", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishedPlugin_GetSourceUrl_GitLab()
|
||||
{
|
||||
var factory = CreateFactory();
|
||||
var plugin = new PublishedPlugin
|
||||
{
|
||||
BuildInfo = JObject.FromObject(new
|
||||
{
|
||||
gitRepository = "https://gitlab.com/group/repo",
|
||||
gitCommit = "def456",
|
||||
pluginDir = "src/Plugin"
|
||||
})
|
||||
};
|
||||
var url = plugin.GetSourceUrl(factory);
|
||||
Assert.Equal("https://gitlab.com/group/repo/-/tree/def456/src/Plugin", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishedPlugin_GetOwnerName_GitHub()
|
||||
{
|
||||
var factory = CreateFactory();
|
||||
var plugin = new PublishedPlugin
|
||||
{
|
||||
BuildInfo = JObject.FromObject(new
|
||||
{
|
||||
gitRepository = "https://github.com/NicolasDorier/btcpayserver"
|
||||
})
|
||||
};
|
||||
Assert.Equal("NicolasDorier", plugin.GetOwnerName(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishedPlugin_GetOwnerName_GitLab_NestedGroup()
|
||||
{
|
||||
var factory = CreateFactory();
|
||||
var plugin = new PublishedPlugin
|
||||
{
|
||||
BuildInfo = JObject.FromObject(new
|
||||
{
|
||||
gitRepository = "https://gitlab.com/group/subgroup/repo"
|
||||
})
|
||||
};
|
||||
Assert.Equal("group/subgroup", plugin.GetOwnerName(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishedPlugin_GetOwnerProfileUrl_GitHub()
|
||||
{
|
||||
var factory = CreateFactory();
|
||||
var plugin = new PublishedPlugin
|
||||
{
|
||||
BuildInfo = JObject.FromObject(new
|
||||
{
|
||||
gitRepository = "https://github.com/Kukks/btcpayserver"
|
||||
})
|
||||
};
|
||||
Assert.Equal("https://github.com/Kukks", plugin.GetOwnerProfileUrl(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishedPlugin_GetOwnerProfileUrl_GitLab()
|
||||
{
|
||||
var factory = CreateFactory();
|
||||
var plugin = new PublishedPlugin
|
||||
{
|
||||
BuildInfo = JObject.FromObject(new
|
||||
{
|
||||
gitRepository = "https://gitlab.com/mygroup/myrepo"
|
||||
})
|
||||
};
|
||||
Assert.Equal("https://gitlab.com/mygroup", plugin.GetOwnerProfileUrl(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PublishedPlugin_GetSourceUrl_NullBuildInfo()
|
||||
{
|
||||
var factory = CreateFactory();
|
||||
var plugin = new PublishedPlugin { BuildInfo = null };
|
||||
Assert.Null(plugin.GetSourceUrl(factory));
|
||||
}
|
||||
|
||||
// ── PluginController.GetUrl backward compatibility ────────────────
|
||||
|
||||
[Fact]
|
||||
public void PluginController_GetUrl_GitHub_WithFactory()
|
||||
{
|
||||
var factory = CreateFactory();
|
||||
var buildInfo = new BuildInfo
|
||||
{
|
||||
GitRepository = "https://github.com/Kukks/btcpayserver",
|
||||
GitCommit = "abc123",
|
||||
PluginDir = "Plugins/AOPP"
|
||||
};
|
||||
var url = Controllers.PluginController.GetUrl(buildInfo, factory);
|
||||
Assert.Equal("https://github.com/Kukks/btcpayserver/tree/abc123/Plugins/AOPP", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginController_GetUrl_GitLab_WithFactory()
|
||||
{
|
||||
var factory = CreateFactory();
|
||||
var buildInfo = new BuildInfo
|
||||
{
|
||||
GitRepository = "https://gitlab.com/group/repo",
|
||||
GitCommit = "def456",
|
||||
PluginDir = "src/Plugin"
|
||||
};
|
||||
var url = Controllers.PluginController.GetUrl(buildInfo, factory);
|
||||
Assert.Equal("https://gitlab.com/group/repo/-/tree/def456/src/Plugin", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginController_GetUrl_GitAtUrl_WithoutFactory()
|
||||
{
|
||||
// Backward compat: no factory, git@ URL should still work
|
||||
var buildInfo = new BuildInfo
|
||||
{
|
||||
GitRepository = "git@github.com:Kukks/btcpayserver.git",
|
||||
GitCommit = "abc123",
|
||||
PluginDir = "Plugins/AOPP"
|
||||
};
|
||||
var url = Controllers.PluginController.GetUrl(buildInfo);
|
||||
Assert.Equal("https://github.com/Kukks/btcpayserver/tree/abc123/Plugins/AOPP", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginController_GetUrl_NullBuildInfo()
|
||||
{
|
||||
var factory = CreateFactory();
|
||||
Assert.Null(Controllers.PluginController.GetUrl(null, factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void PluginController_GetUrl_UnsupportedProvider_WithoutFactory()
|
||||
{
|
||||
var buildInfo = new BuildInfo
|
||||
{
|
||||
GitRepository = "https://bitbucket.org/owner/repo",
|
||||
GitCommit = "abc123"
|
||||
};
|
||||
Assert.Null(Controllers.PluginController.GetUrl(buildInfo));
|
||||
}
|
||||
|
||||
// ── Self-hosted / port preservation ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void PublishedPlugin_GetOwnerProfileUrl_PreservesPort()
|
||||
{
|
||||
var httpFactory = new TestHttpClientFactory();
|
||||
var gitlabProvider = new GitLabHostingProvider(httpFactory, new[] { "gitlab.example.com" });
|
||||
var factory = new GitHostingProviderFactory(new IGitHostingProvider[] { gitlabProvider });
|
||||
var plugin = new PublishedPlugin
|
||||
{
|
||||
BuildInfo = JObject.FromObject(new
|
||||
{
|
||||
gitRepository = "https://gitlab.example.com:8443/group/repo"
|
||||
})
|
||||
};
|
||||
Assert.Equal("https://gitlab.example.com:8443/group", plugin.GetOwnerProfileUrl(factory));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitLab_GetSourceUrl_PreservesPort()
|
||||
{
|
||||
var provider = CreateGitLabProvider(additionalHosts: new[] { "gitlab.example.com" });
|
||||
var url = provider.GetSourceUrl(
|
||||
"https://gitlab.example.com:8443/group/repo",
|
||||
"abc123",
|
||||
"src/Plugin");
|
||||
Assert.Equal("https://gitlab.example.com:8443/group/repo/-/tree/abc123/src/Plugin", url);
|
||||
}
|
||||
|
||||
// ── GitHub ExtractOwnerRepo .git handling ─────────────────────────
|
||||
|
||||
[Fact]
|
||||
public void GitHub_GetSourceUrl_RepoNameContainsDotGit()
|
||||
{
|
||||
var provider = CreateGitHubProvider();
|
||||
var url = provider.GetSourceUrl(
|
||||
"https://github.com/owner/my.gitrepo",
|
||||
"abc123",
|
||||
null);
|
||||
Assert.Equal("https://github.com/owner/my.gitrepo/tree/abc123", url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GitHub_ParseRepository_RepoNameContainsDotGit()
|
||||
{
|
||||
var provider = CreateGitHubProvider();
|
||||
var result = provider.ParseRepository("https://github.com/owner/my.gitrepo");
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal("owner", result.Value.Owner);
|
||||
Assert.Equal("my.gitrepo", result.Value.RepoName);
|
||||
}
|
||||
|
||||
// ── GitLab avatar resolution ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GitLab_GetContributors_ResolvesAvatarFromEmail()
|
||||
{
|
||||
var commitsJson = JArray.FromObject(new[]
|
||||
{
|
||||
new { author_name = "Alice", author_email = "alice@example.com" },
|
||||
new { author_name = "Alice", author_email = "alice@example.com" },
|
||||
new { author_name = "Bob", author_email = "bob@example.com" }
|
||||
}).ToString();
|
||||
|
||||
var handler = new FakeHttpHandler(new Dictionary<string, (HttpStatusCode, string)>
|
||||
{
|
||||
["projects/owner%2Frepo/repository/commits?per_page=100&page=1"] =
|
||||
(HttpStatusCode.OK, commitsJson),
|
||||
["avatar?email=alice%40example.com&size=48"] =
|
||||
(HttpStatusCode.OK, """{"avatar_url":"https://gitlab.com/uploads/-/alice.png"}"""),
|
||||
["avatar?email=bob%40example.com&size=48"] =
|
||||
(HttpStatusCode.OK, """{"avatar_url":"https://gitlab.com/uploads/-/bob.png"}""")
|
||||
});
|
||||
|
||||
var provider = CreateGitLabProvider(handler: handler);
|
||||
var contributors = await provider.GetContributorsAsync("https://gitlab.com/owner/repo", "");
|
||||
|
||||
Assert.Equal(2, contributors.Count);
|
||||
|
||||
var alice = contributors.First(c => c.Login == "Alice");
|
||||
Assert.Equal("https://gitlab.com/uploads/-/alice.png", alice.AvatarUrl);
|
||||
Assert.Equal(2, alice.Contributions);
|
||||
|
||||
var bob = contributors.First(c => c.Login == "Bob");
|
||||
Assert.Equal("https://gitlab.com/uploads/-/bob.png", bob.AvatarUrl);
|
||||
Assert.Equal(1, bob.Contributions);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GitLab_GetContributors_AvatarEndpointFails_StillReturnsContributors()
|
||||
{
|
||||
var commitsJson = JArray.FromObject(new[]
|
||||
{
|
||||
new { author_name = "Alice", author_email = "alice@example.com" }
|
||||
}).ToString();
|
||||
|
||||
var handler = new FakeHttpHandler(new Dictionary<string, (HttpStatusCode, string)>
|
||||
{
|
||||
["projects/owner%2Frepo/repository/commits?per_page=100&page=1"] =
|
||||
(HttpStatusCode.OK, commitsJson),
|
||||
// avatar endpoint returns 500
|
||||
["avatar?email=alice%40example.com&size=48"] =
|
||||
(HttpStatusCode.InternalServerError, "")
|
||||
});
|
||||
|
||||
var provider = CreateGitLabProvider(handler: handler);
|
||||
var contributors = await provider.GetContributorsAsync("https://gitlab.com/owner/repo", "");
|
||||
|
||||
var alice = Assert.Single(contributors);
|
||||
Assert.Equal("Alice", alice.Login);
|
||||
Assert.Null(alice.AvatarUrl); // gracefully null
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GitLab_GetContributors_NoEmail_SkipsAvatarResolution()
|
||||
{
|
||||
var commitsJson = JArray.FromObject(new[]
|
||||
{
|
||||
new { author_name = "NoEmail", author_email = (string?)null }
|
||||
}).ToString();
|
||||
|
||||
var handler = new FakeHttpHandler(new Dictionary<string, (HttpStatusCode, string)>
|
||||
{
|
||||
["projects/owner%2Frepo/repository/commits?per_page=100&page=1"] =
|
||||
(HttpStatusCode.OK, commitsJson)
|
||||
// no avatar endpoint registered — would throw if called
|
||||
});
|
||||
|
||||
var provider = CreateGitLabProvider(handler: handler);
|
||||
var contributors = await provider.GetContributorsAsync("https://gitlab.com/owner/repo", "");
|
||||
|
||||
var c = Assert.Single(contributors);
|
||||
Assert.Equal("NoEmail", c.Login);
|
||||
Assert.Null(c.AvatarUrl);
|
||||
}
|
||||
|
||||
// ── GitLab Users API resolution ───────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task GitLab_GetContributors_ResolvesUserViaPublicEmail()
|
||||
{
|
||||
var commitsJson = JArray.FromObject(new[]
|
||||
{
|
||||
new { author_name = "Alice Commit", author_email = "alice@example.com" }
|
||||
}).ToString();
|
||||
|
||||
var userJson = JArray.FromObject(new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
username = "alice_gitlab",
|
||||
web_url = "https://gitlab.com/alice_gitlab",
|
||||
avatar_url = "https://gitlab.com/uploads/-/alice.png"
|
||||
}
|
||||
}).ToString();
|
||||
|
||||
var handler = new FakeHttpHandler(new Dictionary<string, (HttpStatusCode, string)>
|
||||
{
|
||||
["projects/owner%2Frepo/repository/commits?per_page=100&page=1"] =
|
||||
(HttpStatusCode.OK, commitsJson),
|
||||
["users?public_email=alice%40example.com"] =
|
||||
(HttpStatusCode.OK, userJson)
|
||||
});
|
||||
|
||||
var provider = CreateGitLabProvider(handler: handler);
|
||||
var contributors = await provider.GetContributorsAsync("https://gitlab.com/owner/repo", "");
|
||||
|
||||
var alice = Assert.Single(contributors);
|
||||
Assert.Equal("alice_gitlab", alice.Login); // uses canonical username
|
||||
Assert.Equal("https://gitlab.com/alice_gitlab", alice.HtmlUrl);
|
||||
Assert.Equal("https://gitlab.com/uploads/-/alice.png", alice.AvatarUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GitLab_GetContributors_FallsBackToSearchWhenPublicEmailEmpty()
|
||||
{
|
||||
var commitsJson = JArray.FromObject(new[]
|
||||
{
|
||||
new { author_name = "Bob Commit", author_email = "bob@example.com" }
|
||||
}).ToString();
|
||||
|
||||
var userJson = JArray.FromObject(new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
username = "bob_gitlab",
|
||||
web_url = "https://gitlab.com/bob_gitlab",
|
||||
avatar_url = "https://gitlab.com/uploads/-/bob.png"
|
||||
}
|
||||
}).ToString();
|
||||
|
||||
var handler = new FakeHttpHandler(new Dictionary<string, (HttpStatusCode, string)>
|
||||
{
|
||||
["projects/owner%2Frepo/repository/commits?per_page=100&page=1"] =
|
||||
(HttpStatusCode.OK, commitsJson),
|
||||
// public_email returns empty (user hasn't made email public)
|
||||
["users?public_email=bob%40example.com"] =
|
||||
(HttpStatusCode.OK, "[]"),
|
||||
// search returns a single match
|
||||
["users?search=bob%40example.com"] =
|
||||
(HttpStatusCode.OK, userJson)
|
||||
});
|
||||
|
||||
var provider = CreateGitLabProvider(handler: handler);
|
||||
var contributors = await provider.GetContributorsAsync("https://gitlab.com/owner/repo", "");
|
||||
|
||||
var bob = Assert.Single(contributors);
|
||||
Assert.Equal("bob_gitlab", bob.Login);
|
||||
Assert.Equal("https://gitlab.com/bob_gitlab", bob.HtmlUrl);
|
||||
Assert.Equal("https://gitlab.com/uploads/-/bob.png", bob.AvatarUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GitLab_GetContributors_SearchAmbiguous_FallsBackToAvatarOnly()
|
||||
{
|
||||
var commitsJson = JArray.FromObject(new[]
|
||||
{
|
||||
new { author_name = "Carol Commit", author_email = "carol@example.com" }
|
||||
}).ToString();
|
||||
|
||||
// Two matches — ambiguous, should be rejected
|
||||
var ambiguousJson = JArray.FromObject(new[]
|
||||
{
|
||||
new { username = "carol1", web_url = "https://gitlab.com/carol1", avatar_url = "https://gitlab.com/carol1.png" },
|
||||
new { username = "carol2", web_url = "https://gitlab.com/carol2", avatar_url = "https://gitlab.com/carol2.png" }
|
||||
}).ToString();
|
||||
|
||||
var handler = new FakeHttpHandler(new Dictionary<string, (HttpStatusCode, string)>
|
||||
{
|
||||
["projects/owner%2Frepo/repository/commits?per_page=100&page=1"] =
|
||||
(HttpStatusCode.OK, commitsJson),
|
||||
["users?public_email=carol%40example.com"] =
|
||||
(HttpStatusCode.OK, "[]"),
|
||||
["users?search=carol%40example.com"] =
|
||||
(HttpStatusCode.OK, ambiguousJson),
|
||||
["avatar?email=carol%40example.com&size=48"] =
|
||||
(HttpStatusCode.OK, """{"avatar_url":"https://gitlab.com/uploads/-/carol_gravatar.png"}""")
|
||||
});
|
||||
|
||||
var provider = CreateGitLabProvider(handler: handler);
|
||||
var contributors = await provider.GetContributorsAsync("https://gitlab.com/owner/repo", "");
|
||||
|
||||
var carol = Assert.Single(contributors);
|
||||
// Name stays as commit author since we couldn't resolve unambiguously
|
||||
Assert.Equal("Carol Commit", carol.Login);
|
||||
// No profile URL since we couldn't resolve
|
||||
Assert.Null(carol.HtmlUrl);
|
||||
// But avatar came through via the /avatar fallback
|
||||
Assert.Equal("https://gitlab.com/uploads/-/carol_gravatar.png", carol.AvatarUrl);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GitLab_GetContributors_NoUserMatch_AllFallbacksFail_LeavesNull()
|
||||
{
|
||||
var commitsJson = JArray.FromObject(new[]
|
||||
{
|
||||
new { author_name = "Ghost", author_email = "ghost@example.com" }
|
||||
}).ToString();
|
||||
|
||||
var handler = new FakeHttpHandler(new Dictionary<string, (HttpStatusCode, string)>
|
||||
{
|
||||
["projects/owner%2Frepo/repository/commits?per_page=100&page=1"] =
|
||||
(HttpStatusCode.OK, commitsJson),
|
||||
["users?public_email=ghost%40example.com"] = (HttpStatusCode.OK, "[]"),
|
||||
["users?search=ghost%40example.com"] = (HttpStatusCode.OK, "[]"),
|
||||
// avatar endpoint returns 500
|
||||
["avatar?email=ghost%40example.com&size=48"] = (HttpStatusCode.InternalServerError, "")
|
||||
});
|
||||
|
||||
var provider = CreateGitLabProvider(handler: handler);
|
||||
var contributors = await provider.GetContributorsAsync("https://gitlab.com/owner/repo", "");
|
||||
|
||||
var ghost = Assert.Single(contributors);
|
||||
Assert.Equal("Ghost", ghost.Login); // original commit author name
|
||||
Assert.Null(ghost.HtmlUrl);
|
||||
Assert.Null(ghost.AvatarUrl);
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────
|
||||
|
||||
private static GitHubHostingProvider CreateGitHubProvider()
|
||||
{
|
||||
var factory = new TestHttpClientFactory();
|
||||
return new GitHubHostingProvider(factory);
|
||||
}
|
||||
|
||||
private static GitLabHostingProvider CreateGitLabProvider(
|
||||
IEnumerable<string>? additionalHosts = null,
|
||||
FakeHttpHandler? handler = null)
|
||||
{
|
||||
var factory = handler != null
|
||||
? new FakeHttpClientFactory(handler)
|
||||
: (IHttpClientFactory)new TestHttpClientFactory();
|
||||
return new GitLabHostingProvider(factory, additionalHosts);
|
||||
}
|
||||
|
||||
private static GitHostingProviderFactory CreateFactory()
|
||||
{
|
||||
var httpFactory = new TestHttpClientFactory();
|
||||
var providers = new IGitHostingProvider[]
|
||||
{
|
||||
new GitHubHostingProvider(httpFactory),
|
||||
new GitLabHostingProvider(httpFactory)
|
||||
};
|
||||
return new GitHostingProviderFactory(providers);
|
||||
}
|
||||
|
||||
private class TestHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
public HttpClient CreateClient(string name) => new();
|
||||
}
|
||||
|
||||
private class FakeHttpClientFactory : IHttpClientFactory
|
||||
{
|
||||
private readonly FakeHttpHandler _handler;
|
||||
public FakeHttpClientFactory(FakeHttpHandler handler) => _handler = handler;
|
||||
|
||||
public HttpClient CreateClient(string name)
|
||||
{
|
||||
return new HttpClient(_handler)
|
||||
{
|
||||
BaseAddress = new Uri("https://gitlab.com/api/v4/")
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeHttpHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Dictionary<string, (HttpStatusCode Status, string Body)> _responses;
|
||||
|
||||
public FakeHttpHandler(Dictionary<string, (HttpStatusCode, string)> responses)
|
||||
{
|
||||
_responses = responses;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
// Match on the path+query relative to the base address
|
||||
var key = request.RequestUri!.PathAndQuery.TrimStart('/');
|
||||
// Strip the base path prefix (e.g., "/api/v4/")
|
||||
const string prefix = "api/v4/";
|
||||
if (key.StartsWith(prefix))
|
||||
key = key[prefix.Length..];
|
||||
|
||||
if (_responses.TryGetValue(key, out var resp))
|
||||
{
|
||||
return Task.FromResult(new HttpResponseMessage(resp.Status)
|
||||
{
|
||||
Content = new StringContent(resp.Body)
|
||||
});
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound));
|
||||
}
|
||||
}
|
||||
}
|
||||
136
PluginBuilder.Tests/PluginTests/CreatePluginUITests.cs
Normal file
136
PluginBuilder.Tests/PluginTests/CreatePluginUITests.cs
Normal file
@ -0,0 +1,136 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Dapper;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using Microsoft.Playwright.Xunit;
|
||||
using Npgsql;
|
||||
using PluginBuilder.Controllers.Logic;
|
||||
using PluginBuilder.DataModels;
|
||||
using PluginBuilder.Services;
|
||||
using PluginBuilder.Util.Extensions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace PluginBuilder.Tests.PluginTests;
|
||||
|
||||
[Collection("Playwright Tests")]
|
||||
public class CreatePluginUITests(ITestOutputHelper output) : PageTest
|
||||
{
|
||||
private readonly XUnitLogger _log = new("CreatePluginUITests", output);
|
||||
|
||||
[Fact]
|
||||
public async Task InvalidLogoValidationDoesNotReserveSlug()
|
||||
{
|
||||
await using var t = new PlaywrightTester(_log);
|
||||
t.Server.ReuseDatabase = false;
|
||||
await t.StartAsync();
|
||||
await using var conn = await t.Server.GetService<DBConnectionFactory>().Open();
|
||||
|
||||
await PrepareVerifiedPublisherAsync(t, conn);
|
||||
|
||||
var pluginSlug = "failed-create-" + PlaywrightTester.GetRandomUInt256()[..8];
|
||||
var oversizedImage = Path.Combine(Path.GetTempPath(), $"oversized-{Guid.NewGuid():N}.png");
|
||||
CreateOversizedPng(oversizedImage);
|
||||
|
||||
try
|
||||
{
|
||||
await t.GoToUrl("/plugins/create");
|
||||
await t.Page.Locator("#PluginSlug").FillAsync(pluginSlug);
|
||||
await t.Page.Locator("#PluginTitle").FillAsync("Failed create test");
|
||||
await t.Page.Locator("#Description").FillAsync("Slug should stay available after failed validation.");
|
||||
await t.Page.Locator("#Logo").SetInputFilesAsync(oversizedImage);
|
||||
await t.Page.Locator("#Create").ClickAsync();
|
||||
|
||||
await Expect(t.Page.Locator("span[data-valmsg-for='Logo']")).ToContainTextAsync("Image upload validation failed");
|
||||
|
||||
var pluginCount = await conn.QuerySingleAsync<int>(
|
||||
"SELECT COUNT(*) FROM plugins WHERE slug = @Slug",
|
||||
new { Slug = pluginSlug });
|
||||
Assert.Equal(0, pluginCount);
|
||||
|
||||
await t.Page.Locator("#Logo").SetInputFilesAsync(Array.Empty<string>());
|
||||
await t.Page.Locator("#Create").ClickAsync();
|
||||
await Expect(t.Page).ToHaveURLAsync(new Regex($"/plugins/{Regex.Escape(pluginSlug)}$", RegexOptions.IgnoreCase));
|
||||
await t.AssertNoError();
|
||||
|
||||
pluginCount = await conn.QuerySingleAsync<int>(
|
||||
"SELECT COUNT(*) FROM plugins WHERE slug = @Slug",
|
||||
new { Slug = pluginSlug });
|
||||
Assert.Equal(1, pluginCount);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(oversizedImage))
|
||||
File.Delete(oversizedImage);
|
||||
}
|
||||
}
|
||||
|
||||
private static void CreateOversizedPng(string path)
|
||||
{
|
||||
var bytes = new byte[1_100_000];
|
||||
bytes[0] = 0x89;
|
||||
bytes[1] = 0x50;
|
||||
bytes[2] = 0x4E;
|
||||
bytes[3] = 0x47;
|
||||
bytes[4] = 0x0D;
|
||||
bytes[5] = 0x0A;
|
||||
bytes[6] = 0x1A;
|
||||
bytes[7] = 0x0A;
|
||||
File.WriteAllBytes(path, bytes);
|
||||
}
|
||||
|
||||
private ServerTester CreateServerWithTestStorage(bool throwOnUpload = false)
|
||||
{
|
||||
var server = new ServerTester("PlaywrightTest", _log);
|
||||
server.ConfigureServices = services =>
|
||||
{
|
||||
services.RemoveAll<AzureStorageClient>();
|
||||
services.AddSingleton<TestAzureStorageClient>(sp => new TestAzureStorageClient(
|
||||
sp.GetRequiredService<ProcessRunner>(),
|
||||
sp.GetRequiredService<IConfiguration>())
|
||||
{
|
||||
ThrowOnUpload = throwOnUpload
|
||||
});
|
||||
services.AddSingleton<AzureStorageClient>(sp => sp.GetRequiredService<TestAzureStorageClient>());
|
||||
};
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
private async Task PrepareVerifiedPublisherAsync(PlaywrightTester tester, NpgsqlConnection conn)
|
||||
{
|
||||
await conn.SettingsSetAsync(SettingsKeys.VerifiedGithub, "true");
|
||||
var verifiedCache = tester.Server.GetService<AdminSettingsCache>();
|
||||
await verifiedCache.RefreshAllAdminSettings(conn);
|
||||
|
||||
await tester.GoToUrl("/register");
|
||||
var user = await tester.RegisterNewUser();
|
||||
await Expect(tester.Page!).ToHaveURLAsync(new Regex(".*/dashboard$", RegexOptions.IgnoreCase));
|
||||
await tester.VerifyUserAccounts(user);
|
||||
}
|
||||
|
||||
private sealed class TestAzureStorageClient(ProcessRunner processRunner, IConfiguration configuration)
|
||||
: AzureStorageClient(processRunner, configuration)
|
||||
{
|
||||
public bool ThrowOnUpload { get; init; }
|
||||
public List<string> UploadedBlobNames { get; } = [];
|
||||
public List<string> DeletedBlobNames { get; } = [];
|
||||
|
||||
public override Task<string> UploadImageFile(IFormFile file, string blobName)
|
||||
{
|
||||
if (ThrowOnUpload)
|
||||
throw new AzureStorageClientException("Synthetic upload failure");
|
||||
|
||||
UploadedBlobNames.Add(blobName);
|
||||
return Task.FromResult($"https://example.com/{blobName}");
|
||||
}
|
||||
|
||||
public override Task DeleteImageFileIfExists(string blobName)
|
||||
{
|
||||
DeletedBlobNames.Add(blobName);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
}
|
||||
158
PluginBuilder.Tests/PluginTests/ImagesUITests.cs
Normal file
158
PluginBuilder.Tests/PluginTests/ImagesUITests.cs
Normal file
@ -0,0 +1,158 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading.Tasks;
|
||||
using Dapper;
|
||||
using Microsoft.Playwright.Xunit;
|
||||
using PluginBuilder.Controllers.Logic;
|
||||
using PluginBuilder.DataModels;
|
||||
using PluginBuilder.Services;
|
||||
using PluginBuilder.Util.Extensions;
|
||||
using Xunit;
|
||||
using Xunit.Abstractions;
|
||||
|
||||
namespace PluginBuilder.Tests.PluginTests;
|
||||
|
||||
[Collection("Playwright Tests")]
|
||||
public class ImagesUITests(ITestOutputHelper output) : PageTest
|
||||
{
|
||||
private readonly XUnitLogger _log = new("ImagesUITests", output);
|
||||
|
||||
[Fact]
|
||||
public async Task SettingsCanAddRemoveAndKeepNewImagesFirst()
|
||||
{
|
||||
await using var t = new PlaywrightTester(_log);
|
||||
t.Server.ReuseDatabase = false;
|
||||
await t.StartAsync();
|
||||
await using var conn = await t.Server.GetService<DBConnectionFactory>().Open();
|
||||
|
||||
await conn.SettingsSetAsync(SettingsKeys.VerifiedGithub, "true");
|
||||
var verfCache = t.Server.GetService<AdminSettingsCache>();
|
||||
await verfCache.RefreshAllAdminSettings(conn);
|
||||
|
||||
await t.GoToUrl("/register");
|
||||
var user = await t.RegisterNewUser();
|
||||
await t.VerifyUserAccounts(user);
|
||||
|
||||
var pluginSlug = "images-settings-" + PlaywrightTester.GetRandomUInt256()[..8];
|
||||
var userId = await conn.QuerySingleAsync<string>("SELECT \"Id\" FROM \"AspNetUsers\" WHERE \"Email\" = @Email", new { Email = user });
|
||||
await t.Server.CreateAndBuildPluginAsync(userId, pluginSlug);
|
||||
|
||||
var old1 = "https://example.com/old-1.png";
|
||||
var old2 = "https://example.com/old-2.png";
|
||||
await conn.SetPluginSettings(pluginSlug, new PluginSettings
|
||||
{
|
||||
PluginTitle = pluginSlug,
|
||||
Description = "Images settings test",
|
||||
GitRepository = ServerTester.RepoUrl,
|
||||
Images = [old1, old2]
|
||||
});
|
||||
|
||||
await t.GoToUrl($"/plugins/{pluginSlug}/settings");
|
||||
await Expect(t.Page!.Locator("#images-order-list [data-image-item]")).ToHaveCountAsync(2);
|
||||
|
||||
var files = CreateTempImages(t, 2, "settings-images");
|
||||
try
|
||||
{
|
||||
await t.Page.Locator("#images-input").SetInputFilesAsync(files);
|
||||
await Expect(t.Page.Locator("#images-order-list [data-image-item]")).ToHaveCountAsync(4);
|
||||
|
||||
var firstNewCard = t.Page.Locator("#images-order-list [data-image-item][data-new-id]").First;
|
||||
var newId = await firstNewCard.GetAttributeAsync("data-new-id");
|
||||
Assert.False(string.IsNullOrWhiteSpace(newId));
|
||||
|
||||
var old1Card = t.Page.Locator($"#images-order-list [data-existing-input][value='{old1}']").Locator("xpath=ancestor::*[@data-image-item][1]");
|
||||
await old1Card.Locator("button[name='removeImageUrl']").ClickAsync();
|
||||
await t.AssertNoError();
|
||||
|
||||
var savedImages = await conn.QuerySingleAsync<string[]>(
|
||||
"SELECT COALESCE(ARRAY(SELECT jsonb_array_elements_text(settings->'images')), ARRAY[]::text[]) FROM plugins WHERE slug = @Slug",
|
||||
new { Slug = pluginSlug });
|
||||
|
||||
Assert.Equal(3, savedImages.Length);
|
||||
Assert.DoesNotContain(old1, savedImages);
|
||||
Assert.Equal(old2, savedImages[^1]);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteFiles(files);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PluginDetailsMediaCarouselNavigatesBetweenVideoAndImages()
|
||||
{
|
||||
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 ownerId = await t.Server.CreateFakeUserAsync(confirmEmail: true, githubVerified: true);
|
||||
var pluginSlug = "images-carousel-" + PlaywrightTester.GetRandomUInt256()[..8];
|
||||
var fullBuildId = await t.Server.CreateAndBuildPluginAsync(ownerId, pluginSlug);
|
||||
|
||||
var manifestInfoJson = await conn.QuerySingleAsync<string>(
|
||||
"SELECT manifest_info FROM builds WHERE plugin_slug = @PluginSlug AND id = @BuildId",
|
||||
new { PluginSlug = pluginSlug, fullBuildId.BuildId });
|
||||
var manifest = PluginManifest.Parse(manifestInfoJson);
|
||||
await conn.SetVersionBuild(fullBuildId, manifest.Version, manifest.BTCPayMinVersion, manifest.BTCPayMaxVersion, false);
|
||||
|
||||
const string image1 = "https://example.com/carousel-1.png";
|
||||
const string image2 = "https://example.com/carousel-2.png";
|
||||
await conn.SetPluginSettings(pluginSlug, new PluginSettings
|
||||
{
|
||||
PluginTitle = pluginSlug,
|
||||
Description = "Carousel test",
|
||||
GitRepository = ServerTester.RepoUrl,
|
||||
VideoUrl = "https://www.youtube.com/watch?v=dQw4w9WgXcQ",
|
||||
Images = [image1, image2]
|
||||
}, PluginVisibilityEnum.Listed);
|
||||
|
||||
await t.GoToUrl($"/public/plugins/{pluginSlug}");
|
||||
await t.AssertNoError();
|
||||
|
||||
var thumbs = t.Page!.Locator("#plugin-media-carousel [data-media-thumb]");
|
||||
await Expect(thumbs).ToHaveCountAsync(3);
|
||||
await Expect(thumbs.First).ToHaveClassAsync(new Regex("is-active"));
|
||||
|
||||
await thumbs.Nth(2).ClickAsync();
|
||||
await Expect(thumbs.Nth(2)).ToHaveClassAsync(new Regex("is-active"));
|
||||
await Expect(t.Page.Locator("#plugin-media-carousel .plugin-media-slide.is-active img")).ToHaveAttributeAsync("src", image2);
|
||||
|
||||
await t.Page.Locator("#plugin-media-carousel [data-media-nav='prev']").ClickAsync();
|
||||
await Expect(t.Page.Locator("#plugin-media-carousel .plugin-media-slide.is-active img")).ToHaveAttributeAsync("src", image1);
|
||||
}
|
||||
|
||||
private static string[] CreateTempImages(PlaywrightTester tester, int count, string prefix)
|
||||
{
|
||||
var result = new List<string>(count);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
var path = Path.Combine(Path.GetTempPath(), $"{prefix}-{Guid.NewGuid():N}.png");
|
||||
tester.CreateTestImage(path);
|
||||
result.Add(path);
|
||||
}
|
||||
|
||||
return result.ToArray();
|
||||
}
|
||||
|
||||
private static void DeleteFiles(IEnumerable<string> files)
|
||||
{
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(file))
|
||||
File.Delete(file);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -57,12 +57,12 @@ public class OwnersUITests(ITestOutputHelper output) : PageTest
|
||||
var addForm = t.Page.Locator("form[method='post'] >> input[name='email']");
|
||||
|
||||
await addForm.FillAsync(userB);
|
||||
await t.Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Add" }).ClickAsync();
|
||||
await t.Page.Locator("#AddUser").ClickAsync();
|
||||
await Expect(t.Page.Locator(".alert-warning")).ToBeVisibleAsync();
|
||||
|
||||
await t.VerifyUserAccounts(userB);
|
||||
await addForm.FillAsync(userB);
|
||||
await t.Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Add" }).ClickAsync();
|
||||
await t.Page.Locator("#AddUser").ClickAsync();
|
||||
|
||||
var bRow = t.Page.Locator("table tbody tr").Filter(new LocatorFilterOptions { HasText = userB });
|
||||
await Expect(bRow).ToBeVisibleAsync();
|
||||
@ -75,7 +75,7 @@ public class OwnersUITests(ITestOutputHelper output) : PageTest
|
||||
await Expect(t.Page.Locator("table tbody tr").Filter(new LocatorFilterOptions { HasText = userB })).ToHaveCountAsync(0);
|
||||
|
||||
await addForm.FillAsync(userB);
|
||||
await t.Page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Add" }).ClickAsync();
|
||||
await t.Page.Locator("#AddUser").ClickAsync();
|
||||
bRow = t.Page.Locator("table tbody tr").Filter(new LocatorFilterOptions { HasText = userB });
|
||||
await Expect(bRow).ToBeVisibleAsync();
|
||||
var transferBtn = bRow.GetByRole(AriaRole.Button, new LocatorGetByRoleOptions { Name = "Transfer Primary" });
|
||||
|
||||
@ -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()
|
||||
{
|
||||
|
||||
@ -19,7 +19,7 @@ public class VideoUITests(ITestOutputHelper output) : PageTest
|
||||
private readonly XUnitLogger _log = new("VideoUrlUITests", output);
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_Can_Add_VideoUrl_And_It_Renders_On_PluginDetails()
|
||||
public async Task OwnerCanAddVideoUrlAndItRendersOnPluginDetails()
|
||||
{
|
||||
await using var t = new PlaywrightTester(_log);
|
||||
t.Server.ReuseDatabase = false;
|
||||
@ -85,7 +85,7 @@ public class VideoUITests(ITestOutputHelper output) : PageTest
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_Can_Update_VideoUrl_From_YouTube_To_Vimeo()
|
||||
public async Task OwnerCanUpdateVideoUrlFromYouTubeToVimeo()
|
||||
{
|
||||
await using var t = new PlaywrightTester(_log);
|
||||
t.Server.ReuseDatabase = false;
|
||||
@ -144,7 +144,7 @@ public class VideoUITests(ITestOutputHelper output) : PageTest
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_Can_Remove_VideoUrl()
|
||||
public async Task OwnerCanRemoveVideoUrl()
|
||||
{
|
||||
await using var t = new PlaywrightTester(_log);
|
||||
t.Server.ReuseDatabase = false;
|
||||
@ -200,7 +200,7 @@ public class VideoUITests(ITestOutputHelper output) : PageTest
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VideoUrl_Validation_Rejects_Invalid_URLs()
|
||||
public async Task VideoUrlValidationRejectsInvalidURLs()
|
||||
{
|
||||
await using var t = new PlaywrightTester(_log);
|
||||
t.Server.ReuseDatabase = false;
|
||||
@ -245,7 +245,7 @@ public class VideoUITests(ITestOutputHelper output) : PageTest
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_Can_Edit_VideoUrl_For_Any_Plugin()
|
||||
public async Task AdminCanEditVideoUrlForAnyPlugin()
|
||||
{
|
||||
await using var t = new PlaywrightTester(_log);
|
||||
t.Server.ReuseDatabase = false;
|
||||
@ -297,7 +297,7 @@ public class VideoUITests(ITestOutputHelper output) : PageTest
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VideoUrl_Supports_Various_YouTube_URL_Formats()
|
||||
public async Task VideoUrlSupportsVariousYouTubeURLFormats()
|
||||
{
|
||||
await using var t = new PlaywrightTester(_log);
|
||||
t.Server.ReuseDatabase = false;
|
||||
@ -352,7 +352,7 @@ public class VideoUITests(ITestOutputHelper output) : PageTest
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task VideoUrl_Does_Not_Display_When_Plugin_Has_No_Video()
|
||||
public async Task VideoUrlDoesNotDisplayWhenPluginHasNoVideo()
|
||||
{
|
||||
await using var t = new PlaywrightTester(_log);
|
||||
t.Server.ReuseDatabase = false;
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,6 +11,8 @@ namespace PluginBuilder.Tests.PublicTests;
|
||||
|
||||
public class RateLimitTests(ITestOutputHelper logs) : UnitTestBase(logs)
|
||||
{
|
||||
private const string PluginUpdatesEndpoint = "/api/v1/plugins/updates";
|
||||
|
||||
[Theory]
|
||||
[InlineData("/public/plugins", "GET")]
|
||||
[InlineData("/api/v1/plugins", "GET")]
|
||||
@ -18,7 +20,7 @@ public class RateLimitTests(ITestOutputHelper logs) : UnitTestBase(logs)
|
||||
[InlineData("/api/v1/plugins/test-identifier", "GET")]
|
||||
[InlineData("/api/v1/plugins/test-slug/versions/1.0.0", "GET")]
|
||||
[InlineData("/api/v1/plugins/test-slug/versions/1.0.0/download", "GET")]
|
||||
[InlineData("/api/v1/plugins/updates", "POST")]
|
||||
[InlineData(PluginUpdatesEndpoint, "POST")]
|
||||
[InlineData("/login", "POST")]
|
||||
[InlineData("/register", "POST")]
|
||||
[InlineData("/forgotpassword", "POST")]
|
||||
@ -45,13 +47,16 @@ public class RateLimitTests(ITestOutputHelper logs) : UnitTestBase(logs)
|
||||
await using var _ = tester;
|
||||
|
||||
for (var i = 0; i < 2; i++)
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/public/plugins")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest,
|
||||
(await SendRequest(client, PluginUpdatesEndpoint, "POST")).StatusCode);
|
||||
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests, (await client.GetAsync("/public/plugins")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.TooManyRequests,
|
||||
(await SendRequest(client, PluginUpdatesEndpoint, "POST")).StatusCode);
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(3));
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, (await client.GetAsync("/public/plugins")).StatusCode);
|
||||
Assert.Equal(HttpStatusCode.BadRequest,
|
||||
(await SendRequest(client, PluginUpdatesEndpoint, "POST")).StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
46
PluginBuilder/APIModels/BtcMapsSubmitRequest.cs
Normal file
46
PluginBuilder/APIModels/BtcMapsSubmitRequest.cs
Normal file
@ -0,0 +1,46 @@
|
||||
namespace PluginBuilder.APIModels;
|
||||
|
||||
public sealed class BtcMapsSubmitRequest
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Url { get; set; }
|
||||
public string? Description { get; set; }
|
||||
|
||||
public string? Type { get; set; }
|
||||
public string? SubType { get; set; }
|
||||
public string? Country { get; set; }
|
||||
public string? Twitter { get; set; }
|
||||
public string? Github { get; set; }
|
||||
public string? OnionUrl { get; set; }
|
||||
public string? Phone { get; set; }
|
||||
|
||||
// BTC Map import RPC fields. Required iff SubmitToBtcMap=true.
|
||||
// Plugin captures lat/lon and composes external_id as hostname:storeId
|
||||
// so this endpoint just passes through to the btcmap submit_place RPC.
|
||||
public double? Lat { get; set; }
|
||||
public double? Lon { get; set; }
|
||||
public string? Category { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
|
||||
// Address fields. Optional; forwarded to btcmap as osm:addr:* tags per the
|
||||
// BTC Map import-RPC doc's osm:<tag_name> custom-field convention.
|
||||
public string? HouseNumber { get; set; }
|
||||
public string? Street { get; set; }
|
||||
public string? City { get; set; }
|
||||
public string? Postcode { get; set; }
|
||||
|
||||
// Email - first-class field on btcmap rest/v4/places.md. Plain key, no prefix.
|
||||
public string? Email { get; set; }
|
||||
|
||||
// Payment-rail flags. Plugin sets these per the store's enabled rails;
|
||||
// each true value emits the corresponding `payment:onchain=yes` /
|
||||
// `payment:lightning=yes` marker in extra_fields. Null / false = omitted.
|
||||
public bool? AcceptsOnchain { get; set; }
|
||||
public bool? AcceptsLightning { get; set; }
|
||||
|
||||
// Routing flags. Default-true preserves the existing call-site semantics
|
||||
// for SubmitToDirectory; SubmitToBtcMap defaults false so callers must
|
||||
// opt in to the new path.
|
||||
public bool SubmitToDirectory { get; set; } = true;
|
||||
public bool SubmitToBtcMap { get; set; }
|
||||
}
|
||||
22
PluginBuilder/APIModels/BtcMapsSubmitResponse.cs
Normal file
22
PluginBuilder/APIModels/BtcMapsSubmitResponse.cs
Normal file
@ -0,0 +1,22 @@
|
||||
namespace PluginBuilder.APIModels;
|
||||
|
||||
public sealed class BtcMapsSubmitResponse
|
||||
{
|
||||
public BtcMapsDirectoryResult? Directory { get; set; }
|
||||
public BtcMapsBtcMapResult? BtcMap { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BtcMapsDirectoryResult
|
||||
{
|
||||
public string? PrUrl { get; set; }
|
||||
public int? PrNumber { get; set; }
|
||||
public string? Branch { get; set; }
|
||||
public string? Skipped { get; set; }
|
||||
}
|
||||
|
||||
public sealed class BtcMapsBtcMapResult
|
||||
{
|
||||
public long? Id { get; set; }
|
||||
public string? Origin { get; set; }
|
||||
public string? ExternalId { get; set; }
|
||||
}
|
||||
@ -2,6 +2,7 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using PluginBuilder.Services;
|
||||
|
||||
namespace PluginBuilder.APIModels;
|
||||
|
||||
@ -19,6 +20,7 @@ public class PublishedVersion
|
||||
public string PluginTitle { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string VideoUrl { get; set; }
|
||||
public List<string> Images { get; set; }
|
||||
public string Fingerprint { get; set; }
|
||||
}
|
||||
|
||||
@ -37,8 +39,43 @@ public class PublishedPlugin : PublishedVersion
|
||||
get => BuildInfo?["pluginDir"]?.ToString();
|
||||
}
|
||||
|
||||
public bool IsUnlisted { get; set; }
|
||||
public PluginRatingSummary RatingSummary { get; set; } = new();
|
||||
|
||||
public string GetSourceUrl(GitHostingProviderFactory providerFactory)
|
||||
{
|
||||
if (gitRepository is null)
|
||||
return null;
|
||||
var commit = BuildInfo?["gitCommit"]?.ToString();
|
||||
var dir = BuildInfo?["pluginDir"]?.ToString();
|
||||
var provider = providerFactory?.GetProvider(gitRepository);
|
||||
return provider?.GetSourceUrl(gitRepository, commit, dir);
|
||||
}
|
||||
|
||||
public string GetOwnerName(GitHostingProviderFactory providerFactory)
|
||||
{
|
||||
if (gitRepository is null)
|
||||
return null;
|
||||
var provider = providerFactory?.GetProvider(gitRepository);
|
||||
return provider?.ParseRepository(gitRepository)?.Owner;
|
||||
}
|
||||
|
||||
public string GetOwnerProfileUrl(GitHostingProviderFactory providerFactory)
|
||||
{
|
||||
if (gitRepository is null)
|
||||
return null;
|
||||
var provider = providerFactory?.GetProvider(gitRepository);
|
||||
var parsed = provider?.ParseRepository(gitRepository);
|
||||
if (parsed is null)
|
||||
return null;
|
||||
|
||||
// Build profile URL from the repository host, preserving port for self-hosted instances
|
||||
if (Uri.TryCreate(gitRepository, UriKind.Absolute, out var uri))
|
||||
return $"{uri.Scheme}://{uri.Authority}/{parsed.Value.Owner}";
|
||||
return null;
|
||||
}
|
||||
|
||||
// Kept for backward compatibility with existing code that uses this pattern
|
||||
public GithubRepository GetGithubRepository()
|
||||
{
|
||||
if (gitRepository is null)
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -161,7 +161,14 @@ public class AdminController(
|
||||
|
||||
|
||||
[HttpPost("plugins/edit/{pluginSlug}")]
|
||||
public async Task<IActionResult> PluginEdit(string pluginSlug, PluginEditViewModel model, [FromForm] bool removeLogoFile = false)
|
||||
public async Task<IActionResult> PluginEdit(
|
||||
string pluginSlug,
|
||||
PluginEditViewModel model,
|
||||
[FromForm] bool removeLogoFile = false,
|
||||
[FromForm] string? removeImageUrl = null,
|
||||
[FromForm] List<string>? imagesUrl = null,
|
||||
[FromForm] bool imagesUrlSubmitted = false,
|
||||
[FromForm] List<string>? imagesOrder = null)
|
||||
{
|
||||
await using var conn = await connectionFactory.Open();
|
||||
model.ActiveTab = PluginEditTabs.Settings;
|
||||
@ -218,9 +225,28 @@ public class AdminController(
|
||||
pluginSettings.Documentation = model.PluginSettings.Documentation;
|
||||
pluginSettings.PluginDirectory = model.PluginSettings.PluginDirectory;
|
||||
pluginSettings.VideoUrl = model.PluginSettings.VideoUrl;
|
||||
|
||||
var existingImages = pluginSettings.Images ?? [];
|
||||
var existingImagesSet = new HashSet<string>(existingImages, StringComparer.Ordinal);
|
||||
pluginSettings.Images = imagesUrlSubmitted
|
||||
? (imagesUrl ?? [])
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s) && !string.Equals(s, removeImageUrl, StringComparison.Ordinal))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Where(existingImagesSet.Contains)
|
||||
.ToList()
|
||||
: [..existingImages];
|
||||
model.PluginSettings.Images = [..pluginSettings.Images];
|
||||
|
||||
if (pluginSettings.Images.Count > 10)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Images), "A maximum of 10 images is allowed per plugin.");
|
||||
await PopulatePluginEditViewModel(conn, pluginSlug, model);
|
||||
return View(model);
|
||||
}
|
||||
|
||||
if (model.LogoFile != null)
|
||||
{
|
||||
if (!model.LogoFile.ValidateUploadedImage(out var errorMessage))
|
||||
if (!model.LogoFile.ValidateImageFile(out var errorMessage))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.LogoFile), $"Image upload validation failed: {errorMessage}");
|
||||
await PopulatePluginEditViewModel(conn, pluginSlug, model);
|
||||
@ -246,6 +272,65 @@ public class AdminController(
|
||||
pluginSettings.Logo = null;
|
||||
}
|
||||
|
||||
var uploadedImages = new List<string>();
|
||||
var imagesToUpload = (model.Images ?? []).Where(s => s.Length > 0).ToList();
|
||||
if (imagesToUpload.Count > 0)
|
||||
{
|
||||
if ((pluginSettings.Images?.Count ?? 0) + imagesToUpload.Count > 10)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Images), "A maximum of 10 images is allowed per plugin.");
|
||||
await PopulatePluginEditViewModel(conn, pluginSlug, model);
|
||||
return View(model);
|
||||
}
|
||||
foreach (var image in imagesToUpload)
|
||||
{
|
||||
if (!image.ValidateImageFile(out var errorMessage))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Images), $"Image upload validation failed: {errorMessage}");
|
||||
await PopulatePluginEditViewModel(conn, pluginSlug, model);
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
uploadedImages = (await Task.WhenAll(imagesToUpload.Select(async image =>
|
||||
{
|
||||
var blobName = $"{pluginSlug}-{Guid.NewGuid()}{Path.GetExtension(image.FileName)}";
|
||||
return await azureStorageClient.UploadImageFile(image, blobName);
|
||||
}))).ToList();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Images), "Could not complete settings upload. An error occurred while uploading images");
|
||||
await PopulatePluginEditViewModel(conn, pluginSlug, model);
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
|
||||
var existingQueue = new Queue<string>(pluginSettings.Images ?? []);
|
||||
var uploadedQueue = new Queue<string>(uploadedImages);
|
||||
var orderedImages = new List<string>((pluginSettings.Images?.Count ?? 0) + uploadedImages.Count);
|
||||
foreach (var marker in imagesOrder ?? [])
|
||||
{
|
||||
if (string.Equals(marker, "existing", StringComparison.OrdinalIgnoreCase) && existingQueue.Count > 0)
|
||||
orderedImages.Add(existingQueue.Dequeue());
|
||||
else if (string.Equals(marker, "new", StringComparison.OrdinalIgnoreCase) && uploadedQueue.Count > 0)
|
||||
orderedImages.Add(uploadedQueue.Dequeue());
|
||||
}
|
||||
orderedImages.AddRange(existingQueue);
|
||||
orderedImages.AddRange(uploadedQueue);
|
||||
|
||||
if (orderedImages.Count > 10)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Images), "A maximum of 10 images is allowed per plugin.");
|
||||
model.PluginSettings.Images = orderedImages;
|
||||
await PopulatePluginEditViewModel(conn, pluginSlug, model);
|
||||
return View(model);
|
||||
}
|
||||
|
||||
pluginSettings.Images = orderedImages;
|
||||
model.PluginSettings.Images = orderedImages;
|
||||
|
||||
var setPluginSettings = await conn.SetPluginSettings(pluginSlug, pluginSettings, model.Visibility);
|
||||
if (!setPluginSettings)
|
||||
return NotFound();
|
||||
@ -1201,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);
|
||||
@ -1211,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 });
|
||||
|
||||
109
PluginBuilder/Controllers/BtcMapsController.cs
Normal file
109
PluginBuilder/Controllers/BtcMapsController.cs
Normal file
@ -0,0 +1,109 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using PluginBuilder.APIModels;
|
||||
using PluginBuilder.Services;
|
||||
|
||||
namespace PluginBuilder.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[AllowAnonymous]
|
||||
[Route("~/apis/btcmaps/v1")]
|
||||
public sealed class BtcMapsController(
|
||||
BtcMapsService btcMapsService,
|
||||
ILogger<BtcMapsController> logger)
|
||||
: ControllerBase
|
||||
{
|
||||
[HttpGet("ping")]
|
||||
public IActionResult Ping() => Ok(new { ok = true, service = "btcmaps", version = "v1" });
|
||||
|
||||
[HttpPost("submit")]
|
||||
[EnableRateLimiting(Policies.BtcMapsSubmitRateLimit)]
|
||||
public async Task<IActionResult> Submit(
|
||||
[FromBody] BtcMapsSubmitRequest? request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (request is null)
|
||||
return BadRequest(new { errors = new[] { new ValidationError("body", "Request body is required.") } });
|
||||
|
||||
var errors = btcMapsService.Validate(request);
|
||||
if (errors.Count > 0)
|
||||
return BadRequest(new { errors });
|
||||
|
||||
// At least one downstream lane must run; "submit nothing" is almost
|
||||
// certainly a caller bug (forgot to set the flag) rather than a legit
|
||||
// intent, and silently 200-ing an empty response would hide it.
|
||||
if (!request.SubmitToDirectory && !request.SubmitToBtcMap)
|
||||
{
|
||||
return BadRequest(new { errors = new[] {
|
||||
new ValidationError("body", "At least one of SubmitToDirectory or SubmitToBtcMap must be true.")
|
||||
}});
|
||||
}
|
||||
|
||||
var correlationId = Guid.NewGuid().ToString("N");
|
||||
BtcMapsDirectoryResult? directory = null;
|
||||
BtcMapsBtcMapResult? btcMap = null;
|
||||
|
||||
if (request.SubmitToDirectory)
|
||||
{
|
||||
try
|
||||
{
|
||||
directory = await btcMapsService.SubmitToDirectoryAsync(request, cancellationToken);
|
||||
}
|
||||
catch (BtcMapsService.DirectoryTokenMissingException ex)
|
||||
{
|
||||
logger.LogError(ex, "BTCMaps directory submission rejected: token not configured (correlationId={CorrelationId})", correlationId);
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "directory-not-configured", correlationId });
|
||||
}
|
||||
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(ex, "BTCMaps directory submission cancelled by caller (correlationId={CorrelationId})", correlationId);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
logger.LogError(ex, "BTCMaps directory submission timed out upstream (correlationId={CorrelationId}) for {Name} ({Url})",
|
||||
correlationId, request.Name, request.Url);
|
||||
return StatusCode(StatusCodes.Status504GatewayTimeout, new { error = "directory-upstream-timeout", correlationId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "BTCMaps directory submission failed (correlationId={CorrelationId}) for {Name} ({Url})",
|
||||
correlationId, request.Name, request.Url);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "directory-upstream-failed", correlationId });
|
||||
}
|
||||
}
|
||||
|
||||
if (request.SubmitToBtcMap)
|
||||
{
|
||||
try
|
||||
{
|
||||
btcMap = await btcMapsService.SubmitToBtcMapAsync(request, cancellationToken);
|
||||
}
|
||||
catch (BtcMapsService.BtcMapTokenMissingException ex)
|
||||
{
|
||||
logger.LogError(ex, "BTCMaps import submission rejected: token not configured (correlationId={CorrelationId})", correlationId);
|
||||
return StatusCode(StatusCodes.Status503ServiceUnavailable, new { error = "btcmap-not-configured", correlationId });
|
||||
}
|
||||
catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
logger.LogInformation(ex, "BTCMaps import submission cancelled by caller (correlationId={CorrelationId})", correlationId);
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException ex)
|
||||
{
|
||||
logger.LogError(ex, "BTCMaps import submission timed out upstream (correlationId={CorrelationId}) for {Name} ({Url})",
|
||||
correlationId, request.Name, request.Url);
|
||||
return StatusCode(StatusCodes.Status504GatewayTimeout, new { error = "btcmap-upstream-timeout", correlationId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "BTCMaps import submission failed (correlationId={CorrelationId}) for {Name} ({Url})",
|
||||
correlationId, request.Name, request.Url);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, new { error = "btcmap-upstream-failed", correlationId });
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new BtcMapsSubmitResponse { Directory = directory, BtcMap = btcMap });
|
||||
}
|
||||
}
|
||||
@ -16,7 +16,6 @@ public class DashboardController(
|
||||
AzureStorageClient azureStorageClient,
|
||||
UserVerifiedLogic userVerifiedLogic) : Controller
|
||||
{
|
||||
// plugin methods
|
||||
|
||||
[HttpGet("/plugins/create")]
|
||||
public async Task<IActionResult> CreatePlugin()
|
||||
@ -51,13 +50,33 @@ public class DashboardController(
|
||||
return View(model);
|
||||
}
|
||||
|
||||
await using var conn = await connectionFactory.Open();
|
||||
if (!string.IsNullOrEmpty(model.VideoUrl))
|
||||
{
|
||||
if (!Uri.TryCreate(model.VideoUrl, UriKind.Absolute, out var videoUri) || videoUri.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.VideoUrl), "Video URL must be a valid HTTPS URL");
|
||||
return View(model);
|
||||
}
|
||||
if (!model.VideoUrl.IsSupportedVideoUrl())
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.VideoUrl), "Video URL must be from a supported platform (YouTube, Vimeo)");
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
|
||||
if (model.Logo != null && !model.Logo.ValidateImageFile(out var logoError))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Logo), $"Image upload validation failed: {logoError}");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
if (!await userVerifiedLogic.IsUserEmailVerifiedForPublish(User))
|
||||
{
|
||||
TempData[TempDataConstant.WarningMessage] = "You need to verify your email address in order to create and publish plugins";
|
||||
return RedirectToAction("AccountDetails", "Account");
|
||||
}
|
||||
|
||||
await using var conn = await connectionFactory.Open();
|
||||
var userId = userManager.GetUserId(User)!;
|
||||
if (!await userVerifiedLogic.IsUserGithubVerified(User, conn))
|
||||
{
|
||||
@ -67,60 +86,57 @@ public class DashboardController(
|
||||
|
||||
if (await conn.IsPluginTitleInUse(model.PluginTitle))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.PluginTitle),
|
||||
"This plugin title is already in use. Please choose a different title.");
|
||||
ModelState.AddModelError(nameof(model.PluginTitle), "This plugin title is already in use. Please choose a different title.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(model.VideoUrl))
|
||||
{
|
||||
if (!Uri.TryCreate(model.VideoUrl, UriKind.Absolute, out var videoUri) || videoUri.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.VideoUrl), "Video URL must be a valid HTTPS URL.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
if (!model.VideoUrl.IsSupportedVideoUrl())
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.VideoUrl), "Video URL must be from a supported platform (YouTube, Vimeo).");
|
||||
return View(model);
|
||||
}
|
||||
}
|
||||
|
||||
if (!await conn.NewPlugin(pluginSlug, userId))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.PluginSlug), "This slug already exists");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
string? logoUrl = null;
|
||||
var logoUploadFailed = false;
|
||||
if (model.Logo != null)
|
||||
{
|
||||
string errorMessage;
|
||||
if (!model.Logo.ValidateUploadedImage(out errorMessage))
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Logo), $"Image upload validation failed: {errorMessage}");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var uniqueBlobName = $"{pluginSlug}-{Guid.NewGuid()}{Path.GetExtension(model.Logo.FileName)}";
|
||||
model.LogoUrl = await azureStorageClient.UploadImageFile(model.Logo, uniqueBlobName);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
ModelState.AddModelError(nameof(model.Logo), "Could not complete plugin creation. An error occurred while uploading logo image");
|
||||
return View(model);
|
||||
logoUrl = await azureStorageClient.UploadImageFile(model.Logo, uniqueBlobName);
|
||||
}
|
||||
catch (Exception) { logoUploadFailed = true; }
|
||||
}
|
||||
|
||||
await conn.SetPluginSettings(pluginSlug, new PluginSettings
|
||||
var baseSettings = new PluginSettings
|
||||
{
|
||||
Logo = model.LogoUrl,
|
||||
PluginTitle = model.PluginTitle,
|
||||
Description = model.Description,
|
||||
VideoUrl = model.VideoUrl
|
||||
});
|
||||
VideoUrl = model.VideoUrl,
|
||||
Logo = logoUrl,
|
||||
Images = []
|
||||
};
|
||||
|
||||
if (!await conn.SetPluginSettings(pluginSlug, baseSettings))
|
||||
{
|
||||
await conn.DeletePlugin(pluginSlug);
|
||||
if (logoUrl is not null)
|
||||
{
|
||||
var uploadedBlobName = Path.GetFileName(new Uri(logoUrl).AbsolutePath);
|
||||
try
|
||||
{
|
||||
await azureStorageClient.DeleteImageFileIfExists(uploadedBlobName);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
ModelState.AddModelError(string.Empty, "Could not complete plugin creation.");
|
||||
return View(model);
|
||||
}
|
||||
|
||||
TempData[TempDataConstant.SuccessMessage] = "Plugin created successfully.";
|
||||
if (logoUploadFailed)
|
||||
TempData[TempDataConstant.WarningMessage] = "Plugin was created, but the logo could not be uploaded. You can retry from plugin settings.";
|
||||
|
||||
return RedirectToAction(nameof(PluginController.Dashboard), "Plugin", new { pluginSlug = pluginSlug.ToString() });
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,13 +26,13 @@ namespace PluginBuilder.Controllers;
|
||||
public class HomeController(
|
||||
DBConnectionFactory connectionFactory,
|
||||
UserManager<IdentityUser> userManager,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
SignInManager<IdentityUser> signInManager,
|
||||
EmailService emailService,
|
||||
UserVerifiedLogic userVerifiedLogic,
|
||||
PluginBuilderOptions options,
|
||||
ServerEnvironment env,
|
||||
NostrService nostrService,
|
||||
GitHostingProviderFactory gitHostingProviderFactory,
|
||||
ILogger<HomeController> logger,
|
||||
HealthCheckService healthCheckService)
|
||||
: Controller
|
||||
@ -83,7 +83,7 @@ public class HomeController(
|
||||
b.GitRef = buildInfo?.GitRef;
|
||||
b.Version = PluginVersionViewModel.CreateOrNull(manifest?.Version?.ToString(), row.published, row.pre_release, row.state, row.slug);
|
||||
b.Date = (DateTimeOffset.UtcNow - row.created_at).ToTimeAgo();
|
||||
b.RepositoryLink = PluginController.GetUrl(buildInfo);
|
||||
b.RepositoryLink = PluginController.GetUrl(buildInfo, gitHostingProviderFactory);
|
||||
b.DownloadLink = buildInfo?.Url;
|
||||
b.Error = buildInfo?.Error;
|
||||
b.PluginSlug = row.slug;
|
||||
@ -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,
|
||||
@ -461,6 +463,7 @@ public class HomeController(
|
||||
PluginLogo = settings?.Logo,
|
||||
Documentation = settings?.Documentation,
|
||||
VideoUrl = settings?.VideoUrl,
|
||||
Images = settings?.Images,
|
||||
Version = (string)pluginDetails.ver_str,
|
||||
BTCPayMinVersion = (string?)pluginDetails.btcpay_min_ver is { Length: > 0 } min ? min.Trim() : null,
|
||||
BTCPayMaxVersion = (string?)pluginDetails.btcpay_max_ver is { Length: > 0 } max ? max.Trim() : null,
|
||||
@ -481,13 +484,16 @@ public class HomeController(
|
||||
if (!string.IsNullOrWhiteSpace(ownerNpub))
|
||||
ownerNostrUrl = string.Format(ExternalProfileUrls.PrimalProfileFormat, Uri.EscapeDataString(ownerNpub));
|
||||
|
||||
var githubClient = httpClientFactory.CreateClient(HttpClientNames.GitHub);
|
||||
var pluginContributors = GithubService.LoadSnapshot(options.PluginDataDir, pluginSlug);
|
||||
if (pluginContributors.Count == 0)
|
||||
{
|
||||
pluginContributors = await GithubService.GetContributorsAsync(githubClient, plugin.gitRepository, plugin.pluginDir);
|
||||
if (pluginContributors.Count > 0)
|
||||
await GithubService.SaveSnapshot(options.PluginDataDir, pluginSlug, pluginContributors);
|
||||
var provider = gitHostingProviderFactory.GetProvider(plugin.gitRepository);
|
||||
if (provider != null)
|
||||
{
|
||||
pluginContributors = await provider.GetContributorsAsync(plugin.gitRepository, plugin.pluginDir);
|
||||
if (pluginContributors.Count > 0)
|
||||
await GithubService.SaveSnapshot(options.PluginDataDir, pluginSlug, pluginContributors);
|
||||
}
|
||||
}
|
||||
|
||||
var vm = new PluginDetailsViewModel
|
||||
|
||||
@ -31,6 +31,7 @@ public class PluginController(
|
||||
IOutputCacheStore outputCacheStore,
|
||||
PluginOwnershipService ownershipService,
|
||||
VersionLifecycleService versionLifecycleService,
|
||||
GitHostingProviderFactory gitHostingProviderFactory,
|
||||
ILogger<PluginController> logger)
|
||||
: Controller
|
||||
{
|
||||
@ -56,7 +57,12 @@ public class PluginController(
|
||||
public async Task<IActionResult> Settings(
|
||||
[ModelBinder(typeof(PluginSlugModelBinder))]
|
||||
PluginSlug pluginSlug,
|
||||
PluginSettingViewModel settingViewModel, [FromForm] bool removeLogoFile = false)
|
||||
PluginSettingViewModel settingViewModel,
|
||||
[FromForm] bool removeLogoFile = false,
|
||||
[FromForm] string? removeImageUrl = null,
|
||||
[FromForm] List<string>? imagesUrl = null,
|
||||
[FromForm] bool imagesUrlSubmitted = false,
|
||||
[FromForm] List<string>? imagesOrder = null)
|
||||
{
|
||||
if (settingViewModel is null)
|
||||
return NotFound();
|
||||
@ -98,6 +104,23 @@ public class PluginController(
|
||||
var pluginOwner = await conn.RetrievePluginPrimaryOwner(pluginSlug);
|
||||
settingViewModel.LogoUrl = existingSetting?.Logo;
|
||||
settingViewModel.IsPluginPrimaryOwner = pluginOwner == userId;
|
||||
|
||||
var existingImages = existingSetting?.Images ?? [];
|
||||
var existingImagesSet = new HashSet<string>(existingImages, StringComparer.Ordinal);
|
||||
settingViewModel.ImagesUrl = imagesUrlSubmitted
|
||||
? (imagesUrl ?? [])
|
||||
.Where(s => !string.IsNullOrWhiteSpace(s) && !string.Equals(s, removeImageUrl, StringComparison.Ordinal))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Where(existingImagesSet.Contains)
|
||||
.ToList()
|
||||
: [..existingImages];
|
||||
|
||||
if (settingViewModel.ImagesUrl.Count > 10)
|
||||
{
|
||||
ModelState.AddModelError(nameof(settingViewModel.Images), "A maximum of 10 images is allowed per plugin.");
|
||||
return View(settingViewModel);
|
||||
}
|
||||
|
||||
if (settingViewModel.IsPluginPrimaryOwner && (string.IsNullOrEmpty(settingViewModel.Description) || string.IsNullOrEmpty(settingViewModel.PluginTitle)))
|
||||
{
|
||||
ModelState.AddModelError(nameof(settingViewModel.PluginTitle), "Plugin title and description are required");
|
||||
@ -119,7 +142,7 @@ public class PluginController(
|
||||
|
||||
if (settingViewModel.Logo != null)
|
||||
{
|
||||
if (!settingViewModel.Logo.ValidateUploadedImage(out var errorMessage))
|
||||
if (!settingViewModel.Logo.ValidateImageFile(out var errorMessage))
|
||||
{
|
||||
ModelState.AddModelError(nameof(settingViewModel.Logo), $"Image upload validation failed: {errorMessage}");
|
||||
return View(settingViewModel);
|
||||
@ -143,6 +166,61 @@ public class PluginController(
|
||||
settingViewModel.LogoUrl = null;
|
||||
}
|
||||
|
||||
var uploadedImages = new List<string>();
|
||||
var imagesToUpload = (settingViewModel.Images ?? []).OfType<IFormFile>().Where(s => s.Length > 0).ToList();
|
||||
if (imagesToUpload.Count > 0)
|
||||
{
|
||||
if (settingViewModel.ImagesUrl.Count + imagesToUpload.Count > 10)
|
||||
{
|
||||
ModelState.AddModelError(nameof(settingViewModel.Images), "A maximum of 10 images is allowed per plugin.");
|
||||
return View(settingViewModel);
|
||||
}
|
||||
foreach (var image in imagesToUpload)
|
||||
{
|
||||
if (!image.ValidateImageFile(out var errorMessage))
|
||||
{
|
||||
ModelState.AddModelError(nameof(settingViewModel.Images), $"Image upload validation failed: {errorMessage}");
|
||||
return View(settingViewModel);
|
||||
}
|
||||
}
|
||||
try
|
||||
{
|
||||
uploadedImages = (await Task.WhenAll(imagesToUpload.Select(async image =>
|
||||
{
|
||||
var blobName = $"{pluginSlug}-{Guid.NewGuid()}{Path.GetExtension(image.FileName)}";
|
||||
return await azureStorageClient.UploadImageFile(image, blobName);
|
||||
}))).ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Failed to upload images for plugin {PluginSlug}", pluginSlug);
|
||||
ModelState.AddModelError(nameof(settingViewModel.Images), "Could not complete settings upload. An error occurred while uploading images");
|
||||
return View(settingViewModel);
|
||||
}
|
||||
}
|
||||
|
||||
var existingQueue = new Queue<string>(settingViewModel.ImagesUrl);
|
||||
var uploadedQueue = new Queue<string>(uploadedImages);
|
||||
var orderedImages = new List<string>(settingViewModel.ImagesUrl.Count + uploadedImages.Count);
|
||||
foreach (var marker in imagesOrder ?? [])
|
||||
{
|
||||
if (string.Equals(marker, "existing", StringComparison.OrdinalIgnoreCase) && existingQueue.Count > 0)
|
||||
orderedImages.Add(existingQueue.Dequeue());
|
||||
else if (string.Equals(marker, "new", StringComparison.OrdinalIgnoreCase) && uploadedQueue.Count > 0)
|
||||
orderedImages.Add(uploadedQueue.Dequeue());
|
||||
}
|
||||
orderedImages.AddRange(existingQueue);
|
||||
orderedImages.AddRange(uploadedQueue);
|
||||
|
||||
if (orderedImages.Count > 10)
|
||||
{
|
||||
ModelState.AddModelError(nameof(settingViewModel.Images), "A maximum of 10 images is allowed per plugin.");
|
||||
settingViewModel.ImagesUrl = orderedImages;
|
||||
return View(settingViewModel);
|
||||
}
|
||||
|
||||
settingViewModel.ImagesUrl = orderedImages;
|
||||
|
||||
if (!settingViewModel.IsPluginPrimaryOwner && existingSetting is not null)
|
||||
{
|
||||
settingViewModel.RequireGPGSignatureForRelease = existingSetting.RequireGPGSignatureForRelease;
|
||||
@ -211,7 +289,7 @@ public class PluginController(
|
||||
|
||||
try
|
||||
{
|
||||
var identifier = await buildService.FetchIdentifierFromGithubCsprojAsync(model.GitRepository, model.GitRef, model.PluginDirectory);
|
||||
var identifier = await buildService.FetchIdentifierFromCsprojAsync(model.GitRepository, model.GitRef, model.PluginDirectory);
|
||||
var owns = await conn.EnsureIdentifierOwnership(pluginSlug, identifier);
|
||||
if (!owns)
|
||||
{
|
||||
@ -240,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))]
|
||||
@ -257,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);
|
||||
@ -264,6 +372,7 @@ public class PluginController(
|
||||
|
||||
model.ReleaseNote = pluginSettings?.Description;
|
||||
model.HasPreviousRejection = rejectedRequest != null;
|
||||
model.HasRequests = allRequests.Any();
|
||||
|
||||
if (pendingRequest != null)
|
||||
{
|
||||
@ -505,7 +614,7 @@ public class PluginController(
|
||||
vm.Repository = buildInfo?.GitRepository;
|
||||
vm.GitRef = buildInfo?.GitRef;
|
||||
vm.Version = PluginVersionViewModel.CreateOrNull(manifest?.Version?.ToString(), row.published, row.pre_release, row.state, pluginSlug.ToString());
|
||||
vm.RepositoryLink = GetUrl(buildInfo);
|
||||
vm.RepositoryLink = GetUrl(buildInfo, gitHostingProviderFactory);
|
||||
vm.DownloadLink = buildInfo?.Url;
|
||||
//vm.Error = buildInfo?.Error;
|
||||
vm.RequireGPGSignatureForRelease = pluginSetting?.RequireGPGSignatureForRelease ?? false;
|
||||
@ -653,42 +762,44 @@ public class PluginController(
|
||||
b.GitRef = buildInfo?.GitRef;
|
||||
b.Version = PluginVersionViewModel.CreateOrNull(manifest?.Version?.ToString(), row.published, row.pre_release, row.state, pluginSlug.ToString());
|
||||
b.Date = (DateTimeOffset.UtcNow - row.created_at).ToTimeAgo();
|
||||
b.RepositoryLink = GetUrl(buildInfo);
|
||||
b.RepositoryLink = GetUrl(buildInfo, gitHostingProviderFactory);
|
||||
b.DownloadLink = buildInfo?.Url;
|
||||
b.Error = buildInfo?.Error;
|
||||
}
|
||||
|
||||
var pluginSettings = await conn.GetPluginDetails(pluginSlug);
|
||||
vm.RequestListing = pluginSettings?.Visibility == PluginVisibilityEnum.Unlisted;
|
||||
return View(vm);
|
||||
}
|
||||
|
||||
public static string? GetUrl(BuildInfo? buildInfo)
|
||||
public static string? GetUrl(BuildInfo? buildInfo, GitHostingProviderFactory? providerFactory = null)
|
||||
{
|
||||
if (buildInfo?.GitRepository is string repo && buildInfo?.GitCommit is string commit)
|
||||
if (buildInfo?.GitRepository is not string repo || buildInfo?.GitCommit is not string commit)
|
||||
return null;
|
||||
|
||||
if (providerFactory != null)
|
||||
{
|
||||
string? repoName = null;
|
||||
// git@github.com:Kukks/btcpayserver.git
|
||||
if (repo.StartsWith("git@github.com:", StringComparison.OrdinalIgnoreCase))
|
||||
repoName = repo.Substring("git@github.com:".Length);
|
||||
// https://github.com/Kukks/btcpayserver.git
|
||||
// https://github.com/Kukks/btcpayserver
|
||||
else if (repo.StartsWith("https://github.com/"))
|
||||
repoName = repo.Substring("https://github.com/".Length);
|
||||
if (repoName is not null)
|
||||
{
|
||||
// Kukks/btcpayserver
|
||||
if (repoName.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
|
||||
repoName = repoName.Substring(0, repoName.Length - 4);
|
||||
// https://github.com/Kukks/btcpayserver/tree/plugins/collection/Plugins/BTCPayServer.Plugins.AOPP
|
||||
var link = $"https://github.com/{repoName}/tree/{commit}";
|
||||
if (buildInfo?.PluginDir is string pluginDir)
|
||||
link += $"/{pluginDir}";
|
||||
return link;
|
||||
}
|
||||
var provider = providerFactory.GetProvider(repo);
|
||||
if (provider != null)
|
||||
return provider.GetSourceUrl(repo, commit, buildInfo.PluginDir);
|
||||
}
|
||||
|
||||
return null;
|
||||
// Fallback for backward compatibility (git@ URLs, etc.)
|
||||
string? repoName = null;
|
||||
if (repo.StartsWith("git@github.com:", StringComparison.OrdinalIgnoreCase))
|
||||
repoName = repo.Substring("git@github.com:".Length);
|
||||
else if (repo.StartsWith("https://github.com/"))
|
||||
repoName = repo.Substring("https://github.com/".Length);
|
||||
|
||||
if (repoName is null)
|
||||
return null;
|
||||
|
||||
if (repoName.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
|
||||
repoName = repoName.Substring(0, repoName.Length - 4);
|
||||
|
||||
var link = $"https://github.com/{repoName}/tree/{commit}";
|
||||
if (buildInfo?.PluginDir is string pluginDir)
|
||||
link += $"/{pluginDir}";
|
||||
return link;
|
||||
}
|
||||
|
||||
private static bool VersionEquals(PluginVersion? left, PluginVersion? right)
|
||||
|
||||
@ -3,4 +3,7 @@ namespace PluginBuilder.DataModels;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,7 @@ public static class BtcPayHostVersionParser
|
||||
if (buildSeparator >= 0)
|
||||
normalized = normalized[..buildSeparator];
|
||||
|
||||
// In future consider whitelisting only -rcN suffixes instead of stripping all prerelease labels
|
||||
var prereleaseSeparator = normalized.IndexOf('-');
|
||||
if (prereleaseSeparator >= 0)
|
||||
normalized = normalized[..prereleaseSeparator];
|
||||
|
||||
@ -28,11 +28,12 @@ public class PluginSettings
|
||||
public string PluginTitle { get; set; }
|
||||
public string Description { get; set; }
|
||||
public string Logo { get; set; }
|
||||
|
||||
|
||||
[MaxLength(200)]
|
||||
[Display(Name = "Video URL")]
|
||||
public string VideoUrl { get; set; }
|
||||
|
||||
|
||||
public List<string> Images { get; set; } = [];
|
||||
public bool RequireGPGSignatureForRelease { get; set; }
|
||||
public PluginRequestListingRecord RequestListing { get; set; }
|
||||
}
|
||||
|
||||
@ -4,4 +4,5 @@ public class Policies
|
||||
{
|
||||
public const string OwnPlugin = "OwnPlugin";
|
||||
public const string PublicApiRateLimit = "PublicApiRateLimit";
|
||||
public const string BtcMapsSubmitRateLimit = "BtcMapsSubmitRateLimit";
|
||||
}
|
||||
|
||||
@ -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,40 @@ 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/");
|
||||
client.DefaultRequestHeaders.Add("User-Agent", "PluginBuilder");
|
||||
|
||||
var token = configuration["GITLAB_TOKEN"];
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
client.DefaultRequestHeaders.Add("PRIVATE-TOKEN", token);
|
||||
});
|
||||
services.AddSingleton<IGitHostingProvider, GitHubHostingProvider>();
|
||||
services.AddSingleton<IGitHostingProvider, GitLabHostingProvider>();
|
||||
services.AddSingleton<GitHostingProviderFactory>();
|
||||
services.AddSingleton<ExternalAccountVerificationService>();
|
||||
services.AddSingleton<EmailService>();
|
||||
services.AddSingleton<FirstBuildEvent>();
|
||||
@ -225,6 +263,7 @@ public class Program
|
||||
});
|
||||
services.AddScoped<PluginOwnershipService>();
|
||||
services.AddScoped<VersionLifecycleService>();
|
||||
services.AddSingleton<BtcMapsService>();
|
||||
|
||||
services.AddRateLimiter(options =>
|
||||
{
|
||||
@ -250,6 +289,23 @@ public class Program
|
||||
QueueLimit = 0
|
||||
});
|
||||
});
|
||||
options.AddPolicy(Policies.BtcMapsSubmitRateLimit, httpContext =>
|
||||
{
|
||||
// Per-source-IP fixed window: 3 submissions per 24h. Caps automation
|
||||
// abuse of /apis/btcmaps/v1/submit without throttling honest single
|
||||
// submissions from a merchant. Tightened from 5/24h with the
|
||||
// multi-vendor BTC Map import-RPC lane (PR #226) since that path
|
||||
// forwards into a moderator review queue and rate-limit is the
|
||||
// primary spam control on the public endpoint.
|
||||
var clientIp = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
|
||||
return RateLimitPartition.GetFixedWindowLimiter(clientIp, _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 3,
|
||||
Window = TimeSpan.FromHours(24),
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 0
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
services.AddOutputCache(options =>
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,19 +78,34 @@ public class AzureStorageClient
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> UploadImageFile(IFormFile file, string blobName)
|
||||
public virtual async Task<string> UploadImageFile(IFormFile file, string blobName)
|
||||
{
|
||||
var container = blobClient.GetContainerReference(DefaultContainer);
|
||||
var blob = container.GetBlockBlobReference(blobName);
|
||||
blob.Properties.ContentType = file.ContentType;
|
||||
using (var stream = file.OpenReadStream())
|
||||
{
|
||||
await blob.UploadFromStreamAsync(stream);
|
||||
}
|
||||
using var stream = file.OpenReadStream();
|
||||
await blob.UploadFromStreamAsync(
|
||||
stream,
|
||||
accessCondition: null,
|
||||
options: null,
|
||||
operationContext: null,
|
||||
cancellationToken: CancellationToken.None);
|
||||
|
||||
return blob.Uri.ToString();
|
||||
}
|
||||
|
||||
public virtual async Task DeleteImageFileIfExists(string blobName)
|
||||
{
|
||||
var container = blobClient.GetContainerReference(DefaultContainer);
|
||||
var blob = container.GetBlockBlobReference(blobName);
|
||||
await blob.DeleteIfExistsAsync(
|
||||
deleteSnapshotsOption: DeleteSnapshotsOption.None,
|
||||
accessCondition: null,
|
||||
options: null,
|
||||
operationContext: null,
|
||||
cancellationToken: CancellationToken.None);
|
||||
}
|
||||
|
||||
public async Task<string> Upload(string volume, string fileInVolume, string blobName)
|
||||
{
|
||||
OutputCapture error = new();
|
||||
|
||||
668
PluginBuilder/Services/BtcMapsService.cs
Normal file
668
PluginBuilder/Services/BtcMapsService.cs
Normal file
@ -0,0 +1,668 @@
|
||||
using System.Globalization;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Encodings.Web;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using PluginBuilder.APIModels;
|
||||
using PluginBuilder.DataModels;
|
||||
|
||||
namespace PluginBuilder.Services;
|
||||
|
||||
public sealed class BtcMapsService
|
||||
{
|
||||
private const string DefaultDirectoryRepo = "btcpayserver/directory.btcpayserver.org";
|
||||
private const string DefaultDirectoryMerchantsPath = "src/data/merchants.json";
|
||||
|
||||
private static readonly HashSet<string> ValidTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"merchants", "apps", "hosted-btcpay", "non-profits"
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> ValidMerchantSubTypes = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"3d-printing", "adult", "appliances-furniture", "art", "books",
|
||||
"cryptocurrency-paraphernalia", "domains-hosting-vpns", "education",
|
||||
"electronics", "fashion", "food", "gambling", "gift-cards",
|
||||
"health-household", "holiday-travel", "jewelry", "payment-services",
|
||||
"pets", "services", "software-video-games", "sports", "tools"
|
||||
};
|
||||
|
||||
// ISO 3166-1 alpha-2 codes derived from CultureInfo at startup. Cached because
|
||||
// CultureInfo.GetCultures + RegionInfo enumeration is non-trivial and the set
|
||||
// is stable for the process lifetime.
|
||||
private static readonly HashSet<string> Iso3166Alpha2 = BuildIsoAlpha2Set();
|
||||
|
||||
private static HashSet<string> BuildIsoAlpha2Set()
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var culture in CultureInfo.GetCultures(CultureTypes.SpecificCultures))
|
||||
{
|
||||
try
|
||||
{
|
||||
var region = new RegionInfo(culture.Name);
|
||||
if (region.TwoLetterISORegionName.Length == 2 &&
|
||||
region.TwoLetterISORegionName.All(c => c is >= 'A' and <= 'Z'))
|
||||
{
|
||||
set.Add(region.TwoLetterISORegionName);
|
||||
}
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Some neutral cultures throw; skip.
|
||||
}
|
||||
}
|
||||
return set;
|
||||
}
|
||||
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<BtcMapsService> _logger;
|
||||
|
||||
public BtcMapsService(
|
||||
IConfiguration configuration,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
ILogger<BtcMapsService> logger)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public sealed class DirectoryTokenMissingException : Exception
|
||||
{
|
||||
public DirectoryTokenMissingException() : base("BTCMAPS:DirectoryGithubToken is not configured.") { }
|
||||
}
|
||||
|
||||
public sealed class BtcMapTokenMissingException : Exception
|
||||
{
|
||||
public BtcMapTokenMissingException() : base("BTCMAPS:BtcMapImportToken is not configured.") { }
|
||||
}
|
||||
|
||||
private const string DefaultBtcMapImportEndpoint = "https://api.btcmap.org/rpc";
|
||||
private const string BtcMapImportOrigin = "btcpayserver";
|
||||
private const string BtcMapImportMethod = "submit_place";
|
||||
|
||||
public IReadOnlyList<ValidationError> Validate(BtcMapsSubmitRequest request)
|
||||
{
|
||||
var errors = new List<ValidationError>();
|
||||
|
||||
// Name is the only field both lanes need - btcmap submit_place requires it
|
||||
// as part of the params payload, and the directory merchants.json keys
|
||||
// entries by name.
|
||||
var name = (request.Name ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(name) || name.Length > 200)
|
||||
errors.Add(new ValidationError(nameof(request.Name), "Required, 1-200 characters."));
|
||||
|
||||
// Directory-lane required fields. Skip when the caller opted out of the
|
||||
// directory submission so a btcmap-only call doesn't need to fill in
|
||||
// unrelated directory metadata.
|
||||
if (request.SubmitToDirectory)
|
||||
{
|
||||
var url = (request.Url ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(url))
|
||||
errors.Add(new ValidationError(nameof(request.Url), "Required when SubmitToDirectory=true."));
|
||||
else if (!Uri.TryCreate(url, UriKind.Absolute, out var parsed) || parsed.Scheme != Uri.UriSchemeHttps)
|
||||
errors.Add(new ValidationError(nameof(request.Url), "Must be a valid https:// URL."));
|
||||
|
||||
var description = (request.Description ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(description) || description.Length > 1000)
|
||||
errors.Add(new ValidationError(nameof(request.Description), "Required when SubmitToDirectory=true. 1-1000 characters."));
|
||||
|
||||
var type = (request.Type ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(type) || !ValidTypes.Contains(type))
|
||||
errors.Add(new ValidationError(nameof(request.Type),
|
||||
$"Required when SubmitToDirectory=true. One of: {string.Join(", ", ValidTypes)}."));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.SubType))
|
||||
{
|
||||
var subType = request.SubType.Trim();
|
||||
if (string.Equals(type, "merchants", StringComparison.OrdinalIgnoreCase) &&
|
||||
!ValidMerchantSubTypes.Contains(subType))
|
||||
{
|
||||
errors.Add(new ValidationError(nameof(request.SubType),
|
||||
"Invalid merchant subtype."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Country format check applies to either lane. GLOBAL is the directory's
|
||||
// pseudonym for online-only / multi-region merchants but it is NOT a valid
|
||||
// physical-place country code for the btcmap directory map (every place
|
||||
// there is geocoded). Reject GLOBAL when the btcmap lane is on; allow it
|
||||
// when only the directory lane is in play.
|
||||
if (!string.IsNullOrWhiteSpace(request.Country))
|
||||
{
|
||||
var country = request.Country.Trim();
|
||||
if (country == "GLOBAL")
|
||||
{
|
||||
if (request.SubmitToBtcMap)
|
||||
{
|
||||
errors.Add(new ValidationError(nameof(request.Country),
|
||||
"Country=GLOBAL is incompatible with SubmitToBtcMap=true (btcmap places are physical locations). Use an ISO 3166-1 alpha-2 code or omit Country."));
|
||||
}
|
||||
}
|
||||
else if (!Iso3166Alpha2.Contains(country))
|
||||
{
|
||||
errors.Add(new ValidationError(nameof(request.Country),
|
||||
"Must be ISO 3166-1 alpha-2 or GLOBAL."));
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.OnionUrl))
|
||||
{
|
||||
if (!Uri.TryCreate(request.OnionUrl.Trim(), UriKind.Absolute, out var onionUri) ||
|
||||
(onionUri.Scheme != Uri.UriSchemeHttp && onionUri.Scheme != Uri.UriSchemeHttps) ||
|
||||
!onionUri.Host.EndsWith(".onion", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
errors.Add(new ValidationError(nameof(request.OnionUrl),
|
||||
"Must be an http(s) .onion URL."));
|
||||
}
|
||||
}
|
||||
|
||||
// BTC Map import RPC fields become mandatory only when the caller
|
||||
// opts into that lane via SubmitToBtcMap=true. Directory-only callers
|
||||
// (the existing PR #224 shape) are unaffected.
|
||||
if (request.SubmitToBtcMap)
|
||||
{
|
||||
if (!request.Lat.HasValue || request.Lat.Value is < -90 or > 90 || double.IsNaN(request.Lat.Value))
|
||||
errors.Add(new ValidationError(nameof(request.Lat),
|
||||
"Required when SubmitToBtcMap=true. Must be in [-90, 90]."));
|
||||
|
||||
if (!request.Lon.HasValue || request.Lon.Value is < -180 or > 180 || double.IsNaN(request.Lon.Value))
|
||||
errors.Add(new ValidationError(nameof(request.Lon),
|
||||
"Required when SubmitToBtcMap=true. Must be in [-180, 180]."));
|
||||
|
||||
var category = (request.Category ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(category) || category.Length > 50 ||
|
||||
!category.All(c => c is (>= 'a' and <= 'z') or (>= '0' and <= '9') or '-' or '_'))
|
||||
{
|
||||
errors.Add(new ValidationError(nameof(request.Category),
|
||||
"Required when SubmitToBtcMap=true. Short lowercase identifier (a-z, 0-9, -, _; max 50 chars)."));
|
||||
}
|
||||
|
||||
var externalId = (request.ExternalId ?? string.Empty).Trim();
|
||||
if (string.IsNullOrEmpty(externalId) || externalId.Length > 200)
|
||||
errors.Add(new ValidationError(nameof(request.ExternalId),
|
||||
"Required when SubmitToBtcMap=true. 1-200 characters."));
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
public async Task<BtcMapsBtcMapResult> SubmitToBtcMapAsync(
|
||||
BtcMapsSubmitRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = _configuration["BTCMAPS:BtcMapImportToken"];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
throw new BtcMapTokenMissingException();
|
||||
|
||||
// Bearer tokens MUST NOT cross the wire over http://; an operator-
|
||||
// misconfigured endpoint would silently leak the scoped token to
|
||||
// anyone on the network path. Parse the configured value into an
|
||||
// absolute https URI before we even create the request, so a bad
|
||||
// BTCMAPS:BtcMapImportEndpoint fails loudly with the offending
|
||||
// value in the message instead of producing a quiet credential leak.
|
||||
var endpoint = (_configuration["BTCMAPS:BtcMapImportEndpoint"] ?? DefaultBtcMapImportEndpoint).Trim();
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var endpointUri) ||
|
||||
endpointUri.Scheme != Uri.UriSchemeHttps)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"BTCMAPS:BtcMapImportEndpoint must be an absolute https:// URL (got '{endpoint}').");
|
||||
}
|
||||
|
||||
var client = _httpClientFactory.CreateClient(HttpClientNames.BtcMap);
|
||||
|
||||
// BTC Map import-RPC takes a JSON-RPC 2.0 envelope at /rpc with method=submit_place.
|
||||
// Required params: origin, external_id, lat, lon, category, name. extra_fields uses
|
||||
// the documented first-class keys (phone, website, description) and the osm:<tag_name>
|
||||
// convention for granular OSM tags (osm:addr:*).
|
||||
var extraFields = new Dictionary<string, object?>();
|
||||
// First-class btcmap fields (plain keys per rest/v4/places.md).
|
||||
if (!string.IsNullOrWhiteSpace(request.Url)) extraFields["website"] = request.Url.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Description)) extraFields["description"] = request.Description.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Phone)) extraFields["phone"] = request.Phone.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Email)) extraFields["email"] = request.Email.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Twitter))
|
||||
{
|
||||
// btcmap's `twitter` field is documented as a URL. Normalize the @handle
|
||||
// shape the rest of the API accepts (with or without leading @) into
|
||||
// the URL form the directory map expects.
|
||||
var t = request.Twitter.Trim();
|
||||
var handle = t.StartsWith("@") ? t[1..] : t;
|
||||
extraFields["twitter"] = handle.StartsWith("http", StringComparison.OrdinalIgnoreCase)
|
||||
? handle
|
||||
: $"https://x.com/{handle}";
|
||||
}
|
||||
// OSM custom tags use osm:<tag_name> per the same doc.
|
||||
if (!string.IsNullOrWhiteSpace(request.HouseNumber)) extraFields["osm:addr:housenumber"] = request.HouseNumber.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Street)) extraFields["osm:addr:street"] = request.Street.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.City)) extraFields["osm:addr:city"] = request.City.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Postcode)) extraFields["osm:addr:postcode"] = request.Postcode.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(request.Country)) extraFields["osm:addr:country"] = request.Country.Trim();
|
||||
// Payment-rail flags. Plugin sets per the store's enabled rails - omit
|
||||
// when null or false so a Lightning-only store doesn't claim on-chain
|
||||
// support (or vice versa).
|
||||
if (request.AcceptsOnchain == true) extraFields["osm:payment:onchain"] = "yes";
|
||||
if (request.AcceptsLightning == true) extraFields["osm:payment:lightning"] = "yes";
|
||||
|
||||
var rpcParams = new Dictionary<string, object?>
|
||||
{
|
||||
["origin"] = BtcMapImportOrigin,
|
||||
["external_id"] = request.ExternalId!.Trim(),
|
||||
["lat"] = request.Lat!.Value,
|
||||
["lon"] = request.Lon!.Value,
|
||||
["category"] = request.Category!.Trim().ToLowerInvariant(),
|
||||
["name"] = request.Name!.Trim(),
|
||||
["extra_fields"] = extraFields
|
||||
};
|
||||
|
||||
var envelope = new Dictionary<string, object?>
|
||||
{
|
||||
["jsonrpc"] = "2.0",
|
||||
["method"] = BtcMapImportMethod,
|
||||
["params"] = rpcParams,
|
||||
["id"] = 1
|
||||
};
|
||||
|
||||
using var req = new HttpRequestMessage(HttpMethod.Post, endpointUri);
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
req.Content = new StringContent(JsonSerializer.Serialize(envelope), Encoding.UTF8, "application/json");
|
||||
|
||||
using var response = await client.SendAsync(req, cancellationToken);
|
||||
var body = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new HttpRequestException($"BtcMap RPC {(int)response.StatusCode} {endpointUri}: {body}");
|
||||
|
||||
using var doc = JsonDocument.Parse(body);
|
||||
var root = doc.RootElement;
|
||||
|
||||
// JSON-RPC 2.0 response shape: either {result: {...}} on success or {error: {...}}.
|
||||
// 2xx status with an error body is a legal JSON-RPC outcome we must surface.
|
||||
if (root.TryGetProperty("error", out var errorElement))
|
||||
{
|
||||
var errorJson = errorElement.GetRawText();
|
||||
throw new HttpRequestException($"BtcMap RPC error response: {errorJson}");
|
||||
}
|
||||
|
||||
if (!root.TryGetProperty("result", out var result))
|
||||
throw new HttpRequestException($"BtcMap RPC missing result: {body}");
|
||||
|
||||
return new BtcMapsBtcMapResult
|
||||
{
|
||||
Id = result.TryGetProperty("id", out var id) && id.ValueKind == JsonValueKind.Number ? id.GetInt64() : null,
|
||||
Origin = result.TryGetProperty("origin", out var origin) ? origin.GetString() : null,
|
||||
ExternalId = result.TryGetProperty("external_id", out var ext) ? ext.GetString() : null
|
||||
};
|
||||
}
|
||||
|
||||
public async Task<BtcMapsDirectoryResult> SubmitToDirectoryAsync(
|
||||
BtcMapsSubmitRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var token = _configuration["BTCMAPS:DirectoryGithubToken"];
|
||||
if (string.IsNullOrWhiteSpace(token))
|
||||
throw new DirectoryTokenMissingException();
|
||||
|
||||
var repo = _configuration["BTCMAPS:DirectoryRepo"] ?? DefaultDirectoryRepo;
|
||||
var merchantsPath = _configuration["BTCMAPS:DirectoryMerchantsPath"] ?? DefaultDirectoryMerchantsPath;
|
||||
|
||||
var client = _httpClientFactory.CreateClient(HttpClientNames.BtcMapsDirectory);
|
||||
// Auth is per-call: the named-client registration sets BaseAddress + User-Agent
|
||||
// + Accept + Timeout, but the BTCMAPS token is distinct from the global
|
||||
// PluginBuilder GitHub token and must not be baked into the singleton handler.
|
||||
using var authClient = new HttpRequestAuth(client, token);
|
||||
|
||||
var repoInfo = await GetJsonAsync(authClient, $"repos/{repo}", cancellationToken);
|
||||
var defaultBranch = repoInfo.GetProperty("default_branch").GetString()
|
||||
?? throw new InvalidOperationException("default_branch missing");
|
||||
|
||||
var fileInfo = await GetJsonAsync(
|
||||
authClient,
|
||||
$"repos/{repo}/contents/{merchantsPath}?ref={Uri.EscapeDataString(defaultBranch)}",
|
||||
cancellationToken);
|
||||
var contentB64 = fileInfo.GetProperty("content").GetString() ?? string.Empty;
|
||||
var fileSha = fileInfo.GetProperty("sha").GetString() ?? string.Empty;
|
||||
var currentJson = Encoding.UTF8.GetString(Convert.FromBase64String(contentB64.Replace("\n", string.Empty)));
|
||||
|
||||
var merchants = JsonSerializer.Deserialize<List<JsonElement>>(currentJson)
|
||||
?? throw new InvalidOperationException("merchants.json must be a JSON array");
|
||||
|
||||
var normalizedUrl = NormalizeUrl(request.Url!);
|
||||
foreach (var existing in merchants)
|
||||
{
|
||||
if (existing.TryGetProperty("url", out var existingUrl) &&
|
||||
existingUrl.ValueKind == JsonValueKind.String &&
|
||||
NormalizeUrl(existingUrl.GetString() ?? string.Empty) == normalizedUrl)
|
||||
{
|
||||
var existingName = existing.TryGetProperty("name", out var n) ? n.GetString() : "(unknown)";
|
||||
return new BtcMapsDirectoryResult { Skipped = $"duplicate-url:{existingName}" };
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic branch name derived from the normalized URL. Two concurrent
|
||||
// submissions of the same URL collide on the git/refs create instead of
|
||||
// racing through preflight and opening duplicate PRs.
|
||||
var branchName = BuildBranchName(request.Name!, normalizedUrl);
|
||||
var marker = BuildUrlMarker(normalizedUrl);
|
||||
|
||||
var branchRef = await GetJsonAsync(
|
||||
authClient,
|
||||
$"repos/{repo}/git/ref/heads/{Uri.EscapeDataString(defaultBranch)}",
|
||||
cancellationToken);
|
||||
var baseSha = branchRef.GetProperty("object").GetProperty("sha").GetString()
|
||||
?? throw new InvalidOperationException("base sha missing");
|
||||
|
||||
var newEntry = BuildMerchantEntry(request);
|
||||
var updated = merchants
|
||||
.Select(e => (JsonElement?)e)
|
||||
.Append(newEntry)
|
||||
.OrderBy(e => e!.Value.TryGetProperty("name", out var n) ? n.GetString() : string.Empty,
|
||||
StringComparer.OrdinalIgnoreCase)
|
||||
.Select(e => e!.Value)
|
||||
.ToList();
|
||||
|
||||
// Use UnsafeRelaxedJsonEscaping so non-ASCII codepoints and HTML-only "unsafe"
|
||||
// chars (`&`, `'`, `<`, `>`) are written raw in the file, matching the upstream
|
||||
// merchants.json convention. The default JavaScriptEncoder is HTML-safe and
|
||||
// would re-encode every entry containing `'` or non-ASCII as `\uXXXX`, which
|
||||
// shows up as a noisy full-file diff on every append.
|
||||
var updatedJson = JsonSerializer.Serialize(updated, new JsonSerializerOptions
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||
}) + "\n";
|
||||
|
||||
var refCreateResponse = await PostJsonAllowConflictAsync(
|
||||
authClient,
|
||||
$"repos/{repo}/git/refs",
|
||||
new { @ref = $"refs/heads/{branchName}", sha = baseSha },
|
||||
cancellationToken);
|
||||
if (refCreateResponse.IsConflict)
|
||||
{
|
||||
// Branch already exists. Look up the open PR keyed by the URL marker;
|
||||
// if one is open, return its details; otherwise this is a stuck-branch
|
||||
// from a prior failed run and we cannot safely reuse it.
|
||||
var openPrSearch = await GetJsonAsync(
|
||||
authClient,
|
||||
$"search/issues?q={Uri.EscapeDataString($"repo:{repo} is:pr is:open in:body \"{marker}\"")}",
|
||||
cancellationToken);
|
||||
if (openPrSearch.TryGetProperty("total_count", out var totalCount) && totalCount.GetInt32() > 0)
|
||||
{
|
||||
var firstItem = openPrSearch.GetProperty("items")[0];
|
||||
return new BtcMapsDirectoryResult
|
||||
{
|
||||
Skipped = "duplicate-open-pr",
|
||||
PrUrl = firstItem.TryGetProperty("html_url", out var h) ? h.GetString() : null,
|
||||
PrNumber = firstItem.TryGetProperty("number", out var n) ? n.GetInt32() : null
|
||||
};
|
||||
}
|
||||
return new BtcMapsDirectoryResult { Skipped = "branch-exists-no-open-pr", Branch = branchName };
|
||||
}
|
||||
|
||||
await PutJsonAsync(authClient, $"repos/{repo}/contents/{merchantsPath}",
|
||||
new
|
||||
{
|
||||
message = $"Add {request.Name}",
|
||||
content = Convert.ToBase64String(Encoding.UTF8.GetBytes(updatedJson)),
|
||||
sha = fileSha,
|
||||
branch = branchName
|
||||
}, cancellationToken);
|
||||
|
||||
var prBody = BuildPrBody(request, marker);
|
||||
var prResponse = await PostJsonAsync(authClient, $"repos/{repo}/pulls",
|
||||
new
|
||||
{
|
||||
title = $"Add {request.Name}",
|
||||
head = branchName,
|
||||
@base = defaultBranch,
|
||||
body = prBody
|
||||
}, cancellationToken);
|
||||
|
||||
return new BtcMapsDirectoryResult
|
||||
{
|
||||
PrUrl = prResponse.GetProperty("html_url").GetString(),
|
||||
PrNumber = prResponse.GetProperty("number").GetInt32(),
|
||||
Branch = branchName
|
||||
};
|
||||
}
|
||||
|
||||
private static JsonElement BuildMerchantEntry(BtcMapsSubmitRequest request)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
using (var w = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = false }))
|
||||
{
|
||||
w.WriteStartObject();
|
||||
w.WriteString("name", request.Name!.Trim());
|
||||
w.WriteString("url", request.Url!.Trim());
|
||||
w.WriteString("description", request.Description!.Trim());
|
||||
// type + subType are validated case-insensitively above
|
||||
// (ValidTypes / ValidMerchantSubTypes use OrdinalIgnoreCase) but
|
||||
// the upstream merchants.json convention is lowercase. Normalize
|
||||
// on write so a submission of "Merchants" / "Books" lands as
|
||||
// "merchants" / "books" in the file. Country uses a case-sensitive
|
||||
// Ordinal set so the validator already rejects non-uppercase.
|
||||
w.WriteString("type", request.Type!.Trim().ToLowerInvariant());
|
||||
if (!string.IsNullOrWhiteSpace(request.SubType))
|
||||
w.WriteString("subType", request.SubType.Trim().ToLowerInvariant());
|
||||
if (!string.IsNullOrWhiteSpace(request.Country))
|
||||
w.WriteString("country", request.Country.Trim());
|
||||
if (!string.IsNullOrWhiteSpace(request.Twitter))
|
||||
{
|
||||
var t = request.Twitter.Trim();
|
||||
w.WriteString("twitter", t.StartsWith("@") ? t : "@" + t);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Github))
|
||||
w.WriteString("github", request.Github.Trim());
|
||||
if (!string.IsNullOrWhiteSpace(request.OnionUrl))
|
||||
w.WriteString("onionUrl", request.OnionUrl.Trim());
|
||||
w.WriteEndObject();
|
||||
}
|
||||
ms.Position = 0;
|
||||
using var doc = JsonDocument.Parse(ms);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static string BuildPrBody(BtcMapsSubmitRequest request, string urlMarker)
|
||||
{
|
||||
// User-supplied fields are wrapped in inline code spans so a doctored merchant
|
||||
// name (e.g. `[click here](https://attacker.example)`) can't render as a clickable
|
||||
// link in the maintainer-facing PR description. The URL is its own line and is
|
||||
// displayed via a plain markdown link with a sanitized label so the maintainer
|
||||
// sees the bare URL, not a renamed target.
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("Automated submission from the BTCPay Server plugin-builder `/apis/btcmaps/v1/submit` endpoint.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"- **Name:** {EscapeInlineCode(request.Name)}");
|
||||
sb.AppendLine($"- **URL:** <{request.Url}>");
|
||||
sb.AppendLine($"- **Type:** {EscapeInlineCode(request.Type)}{(string.IsNullOrWhiteSpace(request.SubType) ? string.Empty : " / " + EscapeInlineCode(request.SubType))}");
|
||||
if (!string.IsNullOrWhiteSpace(request.Country)) sb.AppendLine($"- **Country:** {EscapeInlineCode(request.Country.Trim())}");
|
||||
if (!string.IsNullOrWhiteSpace(request.Twitter))
|
||||
{
|
||||
// The Twitter handle is rendered inside an inline code span so a hostile
|
||||
// value like `]( <evil-url> )` cannot escape into an active link. The
|
||||
// maintainer can copy the handle and visit manually.
|
||||
var raw = request.Twitter.Trim();
|
||||
var handle = raw.StartsWith("@") ? raw[1..] : raw;
|
||||
sb.AppendLine($"- **Twitter:** {EscapeInlineCode("@" + handle)}");
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(request.Github)) sb.AppendLine($"- **GitHub:** {EscapeInlineCode(request.Github)}");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("**Description:**");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine(request.Description?.Replace("```", "```") ?? string.Empty);
|
||||
sb.AppendLine("```");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("_Please review before merge - this PR was opened programmatically by a BTCMap-plugin merchant submission, not by a maintainer._");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"<!-- {urlMarker} -->");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// Wrap user input as an inline code span. Use enough backticks to escape any
|
||||
// backticks the input contains (CommonMark inline-code-fence rule).
|
||||
private static string EscapeInlineCode(string? value)
|
||||
{
|
||||
var s = value ?? string.Empty;
|
||||
if (s.Length == 0) return "``";
|
||||
var longestRun = 0;
|
||||
var current = 0;
|
||||
foreach (var c in s)
|
||||
{
|
||||
if (c == '`') { current++; if (current > longestRun) longestRun = current; }
|
||||
else current = 0;
|
||||
}
|
||||
var fence = new string('`', longestRun + 1);
|
||||
// Pad with spaces if the value starts or ends with a backtick (CommonMark rule).
|
||||
var needsPad = s.StartsWith("`") || s.EndsWith("`");
|
||||
return needsPad ? $"{fence} {s} {fence}" : $"{fence}{s}{fence}";
|
||||
}
|
||||
|
||||
private static string BuildUrlMarker(string normalizedUrl) =>
|
||||
$"btcmaps-submit:url={normalizedUrl}";
|
||||
|
||||
public static string NormalizeUrl(string url)
|
||||
{
|
||||
// Normalize for duplicate detection without lying about case-sensitive parts.
|
||||
// Scheme + host get lower-cased (DNS is case-insensitive, scheme is too); path
|
||||
// and query are preserved as-is. Trailing slash is trimmed only when the path
|
||||
// is the bare root, since /foo/ and /foo are sometimes distinct on real servers.
|
||||
var trimmed = (url ?? string.Empty).Trim();
|
||||
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var parsed))
|
||||
return trimmed.TrimEnd('/');
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(parsed.Scheme.ToLowerInvariant());
|
||||
sb.Append("://");
|
||||
sb.Append(parsed.Host.ToLowerInvariant());
|
||||
if (!parsed.IsDefaultPort)
|
||||
{
|
||||
sb.Append(':');
|
||||
sb.Append(parsed.Port);
|
||||
}
|
||||
var path = parsed.AbsolutePath;
|
||||
if (path == "/")
|
||||
sb.Append(path);
|
||||
else
|
||||
sb.Append(path.TrimEnd('/'));
|
||||
if (!string.IsNullOrEmpty(parsed.Query))
|
||||
sb.Append(parsed.Query);
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// Deterministic branch name from the normalized URL. Same URL always produces
|
||||
// the same branch; second concurrent submission collides on the git/refs create
|
||||
// and the controller surfaces the duplicate-open-PR shape.
|
||||
public static string BuildBranchName(string name, string normalizedUrl)
|
||||
{
|
||||
var slug = Slugify(name);
|
||||
var hash = SHA1.HashData(Encoding.UTF8.GetBytes(normalizedUrl));
|
||||
var suffix = Convert.ToHexString(hash)[..8].ToLowerInvariant();
|
||||
return $"btcmaps/{slug}-{suffix}";
|
||||
}
|
||||
|
||||
public static string Slugify(string input)
|
||||
{
|
||||
var chars = new StringBuilder();
|
||||
var lastWasDash = true;
|
||||
foreach (var c in input.ToLowerInvariant())
|
||||
{
|
||||
if (c is >= 'a' and <= 'z' or >= '0' and <= '9')
|
||||
{
|
||||
chars.Append(c);
|
||||
lastWasDash = false;
|
||||
}
|
||||
else if (!lastWasDash)
|
||||
{
|
||||
chars.Append('-');
|
||||
lastWasDash = true;
|
||||
}
|
||||
}
|
||||
var result = chars.ToString().Trim('-');
|
||||
if (result.Length > 40) result = result[..40].TrimEnd('-');
|
||||
return result.Length == 0 ? "merchant" : result;
|
||||
}
|
||||
|
||||
// Lightweight wrapper that re-attaches the per-request Authorization header on
|
||||
// each call. The named-client handler is reused (socket pool, factory rotation),
|
||||
// but auth stays out of the singleton handler.
|
||||
private sealed class HttpRequestAuth : IDisposable
|
||||
{
|
||||
public HttpClient Client { get; }
|
||||
public string Token { get; }
|
||||
public HttpRequestAuth(HttpClient client, string token) { Client = client; Token = token; }
|
||||
public void Dispose() { /* HttpClient is owned by IHttpClientFactory; do not dispose */ }
|
||||
}
|
||||
|
||||
private static HttpRequestMessage NewRequest(HttpMethod method, string path, string token)
|
||||
{
|
||||
var req = new HttpRequestMessage(method, path);
|
||||
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
return req;
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> GetJsonAsync(HttpRequestAuth auth, string path, CancellationToken ct)
|
||||
{
|
||||
using var req = NewRequest(HttpMethod.Get, path, auth.Token);
|
||||
using var response = await auth.Client.SendAsync(req, ct);
|
||||
await EnsureSuccess(response, path, ct);
|
||||
var text = await response.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PostJsonAsync(HttpRequestAuth auth, string path, object body, CancellationToken ct)
|
||||
{
|
||||
using var req = NewRequest(HttpMethod.Post, path, auth.Token);
|
||||
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||
using var response = await auth.Client.SendAsync(req, ct);
|
||||
await EnsureSuccess(response, path, ct);
|
||||
var text = await response.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
private readonly record struct ConflictAware(JsonElement? Body, bool IsConflict);
|
||||
|
||||
private static async Task<ConflictAware> PostJsonAllowConflictAsync(HttpRequestAuth auth, string path, object body, CancellationToken ct)
|
||||
{
|
||||
using var req = NewRequest(HttpMethod.Post, path, auth.Token);
|
||||
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||
using var response = await auth.Client.SendAsync(req, ct);
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.UnprocessableEntity)
|
||||
{
|
||||
// GitHub returns 422 "Reference already exists" when the branch ref is
|
||||
// pre-claimed by a concurrent or earlier submission. That's the idempotency
|
||||
// signal we want; surface it.
|
||||
var conflictText = await response.Content.ReadAsStringAsync(ct);
|
||||
if (conflictText.Contains("Reference already exists", StringComparison.OrdinalIgnoreCase))
|
||||
return new ConflictAware(null, true);
|
||||
throw new HttpRequestException($"GitHub {(int)response.StatusCode} {path}: {conflictText}");
|
||||
}
|
||||
await EnsureSuccess(response, path, ct);
|
||||
var text = await response.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
return new ConflictAware(doc.RootElement.Clone(), false);
|
||||
}
|
||||
|
||||
private static async Task<JsonElement> PutJsonAsync(HttpRequestAuth auth, string path, object body, CancellationToken ct)
|
||||
{
|
||||
using var req = NewRequest(HttpMethod.Put, path, auth.Token);
|
||||
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||
using var response = await auth.Client.SendAsync(req, ct);
|
||||
await EnsureSuccess(response, path, ct);
|
||||
var text = await response.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(text);
|
||||
return doc.RootElement.Clone();
|
||||
}
|
||||
|
||||
private static async Task EnsureSuccess(HttpResponseMessage response, string path, CancellationToken ct)
|
||||
{
|
||||
if (response.IsSuccessStatusCode) return;
|
||||
var body = await response.Content.ReadAsStringAsync(ct);
|
||||
throw new HttpRequestException($"GitHub {(int)response.StatusCode} {path}: {body}");
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,8 @@
|
||||
using System.Threading.Channels;
|
||||
using System.Xml.Linq;
|
||||
using Dapper;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using PluginBuilder.Configuration;
|
||||
using PluginBuilder.DataModels;
|
||||
using PluginBuilder.Events;
|
||||
using PluginBuilder.JsonConverters;
|
||||
using PluginBuilder.Util;
|
||||
using PluginBuilder.Util.Extensions;
|
||||
|
||||
@ -16,7 +13,7 @@ public class BuildServiceException(string message) : Exception(message);
|
||||
public class BuildService
|
||||
{
|
||||
private static readonly SemaphoreSlim _semaphore = new(5);
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly GitHostingProviderFactory _providerFactory;
|
||||
private readonly PluginBuilderOptions _options;
|
||||
|
||||
public BuildService(
|
||||
@ -26,7 +23,7 @@ public class BuildService
|
||||
DBConnectionFactory connectionFactory,
|
||||
EventAggregator eventAggregator,
|
||||
AzureStorageClient azureStorageClient,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
GitHostingProviderFactory providerFactory)
|
||||
{
|
||||
Logger = logger;
|
||||
_options = options;
|
||||
@ -34,7 +31,7 @@ public class BuildService
|
||||
ConnectionFactory = connectionFactory;
|
||||
EventAggregator = eventAggregator;
|
||||
AzureStorageClient = azureStorageClient;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_providerFactory = providerFactory;
|
||||
}
|
||||
|
||||
public ILogger<BuildService> Logger { get; }
|
||||
@ -43,11 +40,6 @@ public class BuildService
|
||||
public EventAggregator EventAggregator { get; }
|
||||
public AzureStorageClient AzureStorageClient { get; }
|
||||
|
||||
private HttpClient GitHubClient()
|
||||
{
|
||||
return _httpClientFactory.CreateClient(HttpClientNames.GitHub);
|
||||
}
|
||||
|
||||
public async Task Build(FullBuildId fullBuildId)
|
||||
{
|
||||
BuildInfo buildParameters;
|
||||
@ -232,8 +224,10 @@ public class BuildService
|
||||
{
|
||||
try
|
||||
{
|
||||
var githubClient = _httpClientFactory.CreateClient(HttpClientNames.GitHub);
|
||||
var contributors = await GithubService.GetContributorsAsync(githubClient, buildInfo.GitRepository, buildInfo.PluginDir);
|
||||
var provider = _providerFactory.GetProvider(buildInfo.GitRepository);
|
||||
if (provider == null)
|
||||
return;
|
||||
var contributors = await provider.GetContributorsAsync(buildInfo.GitRepository, buildInfo.PluginDir);
|
||||
await GithubService.SaveSnapshot(_options.PluginDataDir, pluginSlug, contributors);
|
||||
}
|
||||
catch (Exception) { }
|
||||
@ -281,71 +275,12 @@ public class BuildService
|
||||
EventAggregator.Publish(new BuildChanged(fullBuildId, newState) { BuildInfo = buildInfo?.ToString(), ManifestInfo = manifestInfo?.ToString() });
|
||||
}
|
||||
|
||||
public async Task<string> FetchIdentifierFromGithubCsprojAsync(string repoUrl, string gitRef, string? pluginDir = null)
|
||||
public async Task<string> FetchIdentifierFromCsprojAsync(string repoUrl, string gitRef, string? pluginDir = null)
|
||||
{
|
||||
var githubClient = GitHubClient();
|
||||
|
||||
var (owner, repo) = ExtractOwnerRepo(repoUrl);
|
||||
var dir = string.IsNullOrWhiteSpace(pluginDir) ? "" : pluginDir.Trim('/');
|
||||
|
||||
var encodedDir = string.Join('/', dir.Split('/', StringSplitOptions.RemoveEmptyEntries).Select(Uri.EscapeDataString));
|
||||
var apiUrl = $"repos/{owner}/{repo}/contents" + (string.IsNullOrEmpty(encodedDir) ? "" : "/" + encodedDir);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gitRef))
|
||||
apiUrl += $"?ref={Uri.EscapeDataString(gitRef.Trim())}";
|
||||
|
||||
using var resp = await githubClient.GetAsync(apiUrl);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new BuildServiceException(
|
||||
$"GitHub error ({(int)resp.StatusCode}) listing '{(string.IsNullOrEmpty(dir) ? "/" : dir)}': {apiUrl}\nBody: {body}");
|
||||
|
||||
if (body.TrimStart().StartsWith('{'))
|
||||
throw new BuildServiceException(
|
||||
$"Expected directory listing but GitHub returned an object. Check pluginDir='{pluginDir}' (must be a directory). Path: {apiUrl}");
|
||||
|
||||
var items = SafeJson.Deserialize<List<GithubContentItem>>(body);
|
||||
var csprojs = items
|
||||
.Where(i => string.Equals(i.type, "file", StringComparison.OrdinalIgnoreCase)
|
||||
&& i.name.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (csprojs == null || csprojs.Count == 0)
|
||||
throw new BuildServiceException(
|
||||
$"No .csproj found in '{(string.IsNullOrEmpty(dir) ? "/" : dir)}' at {(string.IsNullOrWhiteSpace(gitRef) ? "default branch" : gitRef)}.");
|
||||
if (csprojs.Count > 1)
|
||||
throw new BuildServiceException("Multiple .csproj found. Keep exactly one.");
|
||||
|
||||
var downloadUrl = csprojs[0].download_url;
|
||||
if (string.IsNullOrWhiteSpace(downloadUrl))
|
||||
throw new BuildServiceException($"GitHub item '{csprojs[0].name}' has no download_url.");
|
||||
|
||||
using var csprojResp = await githubClient.GetAsync(downloadUrl);
|
||||
var csprojBody = await csprojResp.Content.ReadAsStringAsync();
|
||||
|
||||
if (!csprojResp.IsSuccessStatusCode || string.IsNullOrWhiteSpace(csprojBody))
|
||||
throw new BuildServiceException(
|
||||
$"GitHub error downloading '{csprojs[0].name}' from {downloadUrl} (HTTP {(int)csprojResp.StatusCode}).\nBody: {csprojBody}");
|
||||
|
||||
var doc = XDocument.Parse(csprojBody);
|
||||
var assemblyName = doc.Descendants("AssemblyName").FirstOrDefault()?.Value ?? Path.GetFileNameWithoutExtension(csprojs[0].name);
|
||||
|
||||
return assemblyName;
|
||||
}
|
||||
|
||||
private (string owner, string repo) ExtractOwnerRepo(string repoUrl)
|
||||
{
|
||||
repoUrl = repoUrl.Trim().Replace(".git", "");
|
||||
|
||||
if (!repoUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
repoUrl = "https://" + repoUrl.TrimStart('/');
|
||||
|
||||
if (!Uri.TryCreate(repoUrl, UriKind.Absolute, out var uri))
|
||||
throw new BuildServiceException("Invalid repository URL");
|
||||
|
||||
var parts = uri.AbsolutePath.Trim('/').Split('/');
|
||||
if (parts.Length < 2)
|
||||
throw new BuildServiceException("Invalid repository URL");
|
||||
return (parts[0], parts[1]);
|
||||
var provider = _providerFactory.GetProvider(repoUrl);
|
||||
if (provider == null)
|
||||
throw new BuildServiceException("Unsupported git hosting provider. Supported: GitHub, GitLab.");
|
||||
return await provider.FetchIdentifierFromCsprojAsync(repoUrl, gitRef, pluginDir);
|
||||
}
|
||||
|
||||
|
||||
@ -393,5 +328,4 @@ public class BuildService
|
||||
}
|
||||
}
|
||||
|
||||
private record GithubContentItem(string name, string type, string? download_url);
|
||||
}
|
||||
|
||||
@ -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();
|
||||
|
||||
18
PluginBuilder/Services/GitHostingProviderFactory.cs
Normal file
18
PluginBuilder/Services/GitHostingProviderFactory.cs
Normal file
@ -0,0 +1,18 @@
|
||||
namespace PluginBuilder.Services;
|
||||
|
||||
public class GitHostingProviderFactory
|
||||
{
|
||||
private readonly IEnumerable<IGitHostingProvider> _providers;
|
||||
|
||||
public GitHostingProviderFactory(IEnumerable<IGitHostingProvider> providers)
|
||||
{
|
||||
_providers = providers;
|
||||
}
|
||||
|
||||
public IGitHostingProvider? GetProvider(string? repoUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(repoUrl))
|
||||
return null;
|
||||
return _providers.FirstOrDefault(p => p.CanHandle(repoUrl));
|
||||
}
|
||||
}
|
||||
218
PluginBuilder/Services/GitHubHostingProvider.cs
Normal file
218
PluginBuilder/Services/GitHubHostingProvider.cs
Normal file
@ -0,0 +1,218 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using PluginBuilder.APIModels;
|
||||
using PluginBuilder.DataModels;
|
||||
using PluginBuilder.JsonConverters;
|
||||
|
||||
namespace PluginBuilder.Services;
|
||||
|
||||
public class GitHubHostingProvider : IGitHostingProvider
|
||||
{
|
||||
private static readonly Regex HostRegex = new(
|
||||
@"^https?://(www\.)?github\.com/", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private static readonly Regex RepoRegex = new(
|
||||
@"^https://(www\.)?github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$",
|
||||
RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
|
||||
public GitHubHostingProvider(IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
public bool CanHandle(string repoUrl) => HostRegex.IsMatch(repoUrl);
|
||||
|
||||
public (string Owner, string RepoName)? ParseRepository(string repoUrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(repoUrl))
|
||||
return null;
|
||||
var match = RepoRegex.Match(repoUrl.Trim());
|
||||
if (!match.Success)
|
||||
return null;
|
||||
return (match.Groups[2].Value, match.Groups[3].Value);
|
||||
}
|
||||
|
||||
public string? GetSourceUrl(string repoUrl, string? commit, string? pluginDir)
|
||||
{
|
||||
if (commit is null)
|
||||
return null;
|
||||
|
||||
var repo = ParseRepository(repoUrl);
|
||||
if (repo is null)
|
||||
{
|
||||
// Handle git@ and other URL variants for backward compat
|
||||
return GetSourceUrlFromRawUrl(repoUrl, commit, pluginDir);
|
||||
}
|
||||
|
||||
var link = $"https://github.com/{repo.Value.Owner}/{repo.Value.RepoName}/tree/{commit}";
|
||||
if (!string.IsNullOrEmpty(pluginDir))
|
||||
link += $"/{pluginDir}";
|
||||
return link;
|
||||
}
|
||||
|
||||
public async Task<string> FetchIdentifierFromCsprojAsync(string repoUrl, string gitRef, string? pluginDir = null)
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient(HttpClientNames.GitHub);
|
||||
var (owner, repoName) = ExtractOwnerRepo(repoUrl);
|
||||
var dir = string.IsNullOrWhiteSpace(pluginDir) ? "" : pluginDir.Trim('/');
|
||||
|
||||
var encodedDir = string.Join('/', dir.Split('/', StringSplitOptions.RemoveEmptyEntries).Select(Uri.EscapeDataString));
|
||||
var apiUrl = $"repos/{owner}/{repoName}/contents" + (string.IsNullOrEmpty(encodedDir) ? "" : "/" + encodedDir);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gitRef))
|
||||
apiUrl += $"?ref={Uri.EscapeDataString(gitRef.Trim())}";
|
||||
|
||||
using var resp = await client.GetAsync(apiUrl);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new BuildServiceException(
|
||||
$"GitHub error ({(int)resp.StatusCode}) listing '{(string.IsNullOrEmpty(dir) ? "/" : dir)}': {apiUrl}\nBody: {body}");
|
||||
|
||||
if (body.TrimStart().StartsWith('{'))
|
||||
throw new BuildServiceException(
|
||||
$"Expected directory listing but GitHub returned an object. Check pluginDir='{pluginDir}' (must be a directory). Path: {apiUrl}");
|
||||
|
||||
var items = SafeJson.Deserialize<List<GithubContentItem>>(body);
|
||||
var csprojs = items
|
||||
.Where(i => string.Equals(i.type, "file", StringComparison.OrdinalIgnoreCase)
|
||||
&& i.name.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (csprojs == null || csprojs.Count == 0)
|
||||
throw new BuildServiceException(
|
||||
$"No .csproj found in '{(string.IsNullOrEmpty(dir) ? "/" : dir)}' at {(string.IsNullOrWhiteSpace(gitRef) ? "default branch" : gitRef)}.");
|
||||
if (csprojs.Count > 1)
|
||||
throw new BuildServiceException("Multiple .csproj found. Keep exactly one.");
|
||||
|
||||
var downloadUrl = csprojs[0].download_url;
|
||||
if (string.IsNullOrWhiteSpace(downloadUrl))
|
||||
throw new BuildServiceException($"GitHub item '{csprojs[0].name}' has no download_url.");
|
||||
|
||||
using var csprojResp = await client.GetAsync(downloadUrl);
|
||||
var csprojBody = await csprojResp.Content.ReadAsStringAsync();
|
||||
|
||||
if (!csprojResp.IsSuccessStatusCode || string.IsNullOrWhiteSpace(csprojBody))
|
||||
throw new BuildServiceException(
|
||||
$"GitHub error downloading '{csprojs[0].name}' from {downloadUrl} (HTTP {(int)csprojResp.StatusCode}).\nBody: {csprojBody}");
|
||||
|
||||
XDocument doc;
|
||||
try
|
||||
{
|
||||
doc = XDocument.Parse(csprojBody);
|
||||
}
|
||||
catch (System.Xml.XmlException ex)
|
||||
{
|
||||
throw new BuildServiceException($"Failed to parse '{csprojs[0].name}' as XML: {ex.Message}");
|
||||
}
|
||||
var assemblyName = doc.Descendants("AssemblyName").FirstOrDefault()?.Value ?? Path.GetFileNameWithoutExtension(csprojs[0].name);
|
||||
|
||||
return assemblyName;
|
||||
}
|
||||
|
||||
public async Task<List<GitHubContributor>> GetContributorsAsync(string repoUrl, string pluginDir)
|
||||
{
|
||||
var repo = ParseRepository(repoUrl);
|
||||
if (repo == null)
|
||||
return new List<GitHubContributor>();
|
||||
|
||||
var client = _httpClientFactory.CreateClient(HttpClientNames.GitHub);
|
||||
var pathQuery = string.IsNullOrEmpty(pluginDir) ? "" : $"&path={Uri.EscapeDataString(pluginDir)}";
|
||||
var contributors = new Dictionary<string, GitHubContributor>(StringComparer.OrdinalIgnoreCase);
|
||||
int page = 1;
|
||||
const int perPage = 100;
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var apiPath = $"repos/{repo.Value.Owner}/{repo.Value.RepoName}/commits?per_page={perPage}&page={page}{pathQuery}";
|
||||
using var response = await client.GetAsync(apiPath);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
break;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var commits = JsonConvert.DeserializeObject<List<GitHubCommit>>(json);
|
||||
if (commits == null || commits.Count == 0)
|
||||
break;
|
||||
|
||||
foreach (var commit in commits)
|
||||
{
|
||||
var login = commit.Author?.Login;
|
||||
if (commit.Author == null || string.IsNullOrEmpty(login))
|
||||
continue;
|
||||
|
||||
if (contributors.TryGetValue(login, out var existing))
|
||||
{
|
||||
existing.Contributions++;
|
||||
}
|
||||
else
|
||||
{
|
||||
contributors[login] = new GitHubContributor
|
||||
{
|
||||
Login = commit.Author.Login,
|
||||
AvatarUrl = commit.Author.AvatarUrl,
|
||||
HtmlUrl = commit.Author.HtmlUrl,
|
||||
Contributions = 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.Headers.TryGetValues("Link", out var linkHeaders) || !linkHeaders.Any(l => l.Contains("rel=\"next\"")))
|
||||
break;
|
||||
|
||||
page++;
|
||||
}
|
||||
|
||||
return contributors.Values.OrderByDescending(c => c.Contributions).ToList();
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new List<GitHubContributor>();
|
||||
}
|
||||
}
|
||||
|
||||
private static (string owner, string repo) ExtractOwnerRepo(string repoUrl)
|
||||
{
|
||||
repoUrl = repoUrl.Trim();
|
||||
if (repoUrl.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
|
||||
repoUrl = repoUrl[..^4];
|
||||
|
||||
if (!repoUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
|
||||
repoUrl = "https://" + repoUrl.TrimStart('/');
|
||||
|
||||
if (!Uri.TryCreate(repoUrl, UriKind.Absolute, out var uri))
|
||||
throw new BuildServiceException("Invalid repository URL");
|
||||
|
||||
var parts = uri.AbsolutePath.Trim('/').Split('/');
|
||||
if (parts.Length < 2)
|
||||
throw new BuildServiceException("Invalid repository URL");
|
||||
return (parts[0], parts[1]);
|
||||
}
|
||||
|
||||
private static string? GetSourceUrlFromRawUrl(string repo, string commit, string? pluginDir)
|
||||
{
|
||||
string? repoName = null;
|
||||
// git@github.com:Kukks/btcpayserver.git
|
||||
if (repo.StartsWith("git@github.com:", StringComparison.OrdinalIgnoreCase))
|
||||
repoName = repo.Substring("git@github.com:".Length);
|
||||
// https://github.com/Kukks/btcpayserver.git
|
||||
// https://github.com/Kukks/btcpayserver
|
||||
else if (repo.StartsWith("https://github.com/"))
|
||||
repoName = repo.Substring("https://github.com/".Length);
|
||||
|
||||
if (repoName is null)
|
||||
return null;
|
||||
|
||||
if (repoName.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
|
||||
repoName = repoName.Substring(0, repoName.Length - 4);
|
||||
|
||||
var link = $"https://github.com/{repoName}/tree/{commit}";
|
||||
if (!string.IsNullOrEmpty(pluginDir))
|
||||
link += $"/{pluginDir}";
|
||||
return link;
|
||||
}
|
||||
|
||||
private record GithubContentItem(string name, string type, string? download_url);
|
||||
}
|
||||
325
PluginBuilder/Services/GitLabHostingProvider.cs
Normal file
325
PluginBuilder/Services/GitLabHostingProvider.cs
Normal file
@ -0,0 +1,325 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Xml.Linq;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using PluginBuilder.APIModels;
|
||||
using PluginBuilder.DataModels;
|
||||
|
||||
namespace PluginBuilder.Services;
|
||||
|
||||
public class GitLabHostingProvider : IGitHostingProvider
|
||||
{
|
||||
private static readonly Regex HostRegex = new(
|
||||
@"^https?://(www\.)?gitlab\.com/", RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly IEnumerable<string> _additionalHosts;
|
||||
|
||||
public GitLabHostingProvider(IHttpClientFactory httpClientFactory, IEnumerable<string>? additionalHosts = null)
|
||||
{
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_additionalHosts = additionalHosts ?? Enumerable.Empty<string>();
|
||||
}
|
||||
|
||||
public bool CanHandle(string repoUrl)
|
||||
{
|
||||
if (HostRegex.IsMatch(repoUrl))
|
||||
return true;
|
||||
|
||||
// Support self-hosted GitLab instances via configured additional hosts
|
||||
if (!Uri.TryCreate(repoUrl, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
return _additionalHosts.Any(h => uri.Authority.Equals(h, StringComparison.OrdinalIgnoreCase)
|
||||
|| uri.Host.Equals(h, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public (string Owner, string RepoName)? ParseRepository(string repoUrl)
|
||||
{
|
||||
if (string.IsNullOrEmpty(repoUrl))
|
||||
return null;
|
||||
|
||||
if (!Uri.TryCreate(repoUrl.Trim(), UriKind.Absolute, out var uri))
|
||||
return null;
|
||||
|
||||
var path = uri.AbsolutePath.Trim('/');
|
||||
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
|
||||
path = path[..^4];
|
||||
|
||||
var segments = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (segments.Length < 2)
|
||||
return null;
|
||||
|
||||
// Last segment is the repo name, everything before is the namespace/owner
|
||||
var repoName = segments[^1];
|
||||
var owner = string.Join("/", segments[..^1]);
|
||||
return (owner, repoName);
|
||||
}
|
||||
|
||||
public string? GetSourceUrl(string repoUrl, string? commit, string? pluginDir)
|
||||
{
|
||||
if (commit is null)
|
||||
return null;
|
||||
|
||||
if (!Uri.TryCreate(repoUrl.Trim(), UriKind.Absolute, out var uri))
|
||||
return null;
|
||||
|
||||
var path = uri.AbsolutePath.Trim('/');
|
||||
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
|
||||
path = path[..^4];
|
||||
|
||||
var baseUrl = $"{uri.Scheme}://{uri.Authority}/{path}";
|
||||
var link = $"{baseUrl}/-/tree/{commit}";
|
||||
if (!string.IsNullOrEmpty(pluginDir))
|
||||
link += $"/{pluginDir}";
|
||||
return link;
|
||||
}
|
||||
|
||||
public async Task<string> FetchIdentifierFromCsprojAsync(string repoUrl, string gitRef, string? pluginDir = null)
|
||||
{
|
||||
var client = CreateClientForRepo(repoUrl);
|
||||
var projectId = GetProjectId(repoUrl);
|
||||
var dir = string.IsNullOrWhiteSpace(pluginDir) ? "" : pluginDir.Trim('/');
|
||||
|
||||
// List files in the directory using the Repository Tree API
|
||||
var apiUrl = $"projects/{Uri.EscapeDataString(projectId)}/repository/tree";
|
||||
var queryParams = new List<string>();
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
queryParams.Add($"path={Uri.EscapeDataString(dir)}");
|
||||
if (!string.IsNullOrWhiteSpace(gitRef))
|
||||
queryParams.Add($"ref={Uri.EscapeDataString(gitRef.Trim())}");
|
||||
if (queryParams.Count > 0)
|
||||
apiUrl += "?" + string.Join("&", queryParams);
|
||||
|
||||
using var resp = await client.GetAsync(apiUrl);
|
||||
var body = await resp.Content.ReadAsStringAsync();
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
throw new BuildServiceException(
|
||||
$"GitLab error ({(int)resp.StatusCode}) listing '{(string.IsNullOrEmpty(dir) ? "/" : dir)}': {apiUrl}\nBody: {body}");
|
||||
|
||||
var items = JsonConvert.DeserializeObject<List<GitLabTreeItem>>(body);
|
||||
if (items == null)
|
||||
throw new BuildServiceException(
|
||||
$"Failed to parse GitLab tree response for '{(string.IsNullOrEmpty(dir) ? "/" : dir)}'.");
|
||||
|
||||
var csprojs = items
|
||||
.Where(i => string.Equals(i.Type, "blob", StringComparison.OrdinalIgnoreCase)
|
||||
&& i.Name.EndsWith(".csproj", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
|
||||
if (csprojs.Count == 0)
|
||||
throw new BuildServiceException(
|
||||
$"No .csproj found in '{(string.IsNullOrEmpty(dir) ? "/" : dir)}' at {(string.IsNullOrWhiteSpace(gitRef) ? "default branch" : gitRef)}.");
|
||||
if (csprojs.Count > 1)
|
||||
throw new BuildServiceException("Multiple .csproj found. Keep exactly one.");
|
||||
|
||||
// Download the .csproj file content using the Repository Files API
|
||||
var filePath = csprojs[0].Path;
|
||||
var fileApiUrl = $"projects/{Uri.EscapeDataString(projectId)}/repository/files/{Uri.EscapeDataString(filePath)}/raw";
|
||||
if (!string.IsNullOrWhiteSpace(gitRef))
|
||||
fileApiUrl += $"?ref={Uri.EscapeDataString(gitRef.Trim())}";
|
||||
|
||||
using var fileResp = await client.GetAsync(fileApiUrl);
|
||||
var csprojBody = await fileResp.Content.ReadAsStringAsync();
|
||||
|
||||
if (!fileResp.IsSuccessStatusCode || string.IsNullOrWhiteSpace(csprojBody))
|
||||
throw new BuildServiceException(
|
||||
$"GitLab error downloading '{csprojs[0].Name}' (HTTP {(int)fileResp.StatusCode}).\nBody: {csprojBody}");
|
||||
|
||||
XDocument doc;
|
||||
try
|
||||
{
|
||||
doc = XDocument.Parse(csprojBody);
|
||||
}
|
||||
catch (System.Xml.XmlException ex)
|
||||
{
|
||||
throw new BuildServiceException($"Failed to parse '{csprojs[0].Name}' as XML: {ex.Message}");
|
||||
}
|
||||
var assemblyName = doc.Descendants("AssemblyName").FirstOrDefault()?.Value ?? Path.GetFileNameWithoutExtension(csprojs[0].Name);
|
||||
|
||||
return assemblyName;
|
||||
}
|
||||
|
||||
public async Task<List<GitHubContributor>> GetContributorsAsync(string repoUrl, string pluginDir)
|
||||
{
|
||||
var repo = ParseRepository(repoUrl);
|
||||
if (repo == null)
|
||||
return new List<GitHubContributor>();
|
||||
|
||||
var client = CreateClientForRepo(repoUrl);
|
||||
var projectId = GetProjectId(repoUrl);
|
||||
var contributors = new Dictionary<string, GitHubContributor>(StringComparer.OrdinalIgnoreCase);
|
||||
int page = 1;
|
||||
const int perPage = 100;
|
||||
|
||||
try
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var apiPath = $"projects/{Uri.EscapeDataString(projectId)}/repository/commits?per_page={perPage}&page={page}";
|
||||
if (!string.IsNullOrEmpty(pluginDir))
|
||||
apiPath += $"&path={Uri.EscapeDataString(pluginDir)}";
|
||||
|
||||
using var response = await client.GetAsync(apiPath);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
break;
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var commits = JsonConvert.DeserializeObject<List<GitLabCommit>>(json);
|
||||
if (commits == null || commits.Count == 0)
|
||||
break;
|
||||
|
||||
foreach (var commit in commits)
|
||||
{
|
||||
var name = commit.AuthorName;
|
||||
if (string.IsNullOrEmpty(name))
|
||||
continue;
|
||||
|
||||
// Use author email as key since GitLab commits don't have user login
|
||||
var key = commit.AuthorEmail ?? name;
|
||||
if (contributors.TryGetValue(key, out var existing))
|
||||
{
|
||||
existing.Contributions++;
|
||||
}
|
||||
else
|
||||
{
|
||||
contributors[key] = new GitHubContributor
|
||||
{
|
||||
Login = name,
|
||||
AvatarUrl = null,
|
||||
HtmlUrl = null,
|
||||
Contributions = 1
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Check for next page via header
|
||||
if (!response.Headers.TryGetValues("x-next-page", out var nextPageHeaders))
|
||||
break;
|
||||
var nextPage = nextPageHeaders.FirstOrDefault();
|
||||
if (string.IsNullOrEmpty(nextPage) || !int.TryParse(nextPage, out var np) || np <= page)
|
||||
break;
|
||||
|
||||
page = np;
|
||||
}
|
||||
|
||||
var result = contributors.Values.OrderByDescending(c => c.Contributions).ToList();
|
||||
await ResolveUsersAsync(client, contributors);
|
||||
return result;
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return new List<GitHubContributor>();
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task ResolveUsersAsync(
|
||||
HttpClient client,
|
||||
Dictionary<string, GitHubContributor> contributors)
|
||||
{
|
||||
foreach (var (key, contributor) in contributors)
|
||||
{
|
||||
// key is the email when available
|
||||
if (!key.Contains('@'))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
var user = await TryResolveGitLabUser(client, key);
|
||||
if (user is not null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(user.WebUrl))
|
||||
contributor.HtmlUrl = user.WebUrl;
|
||||
if (!string.IsNullOrWhiteSpace(user.AvatarUrl))
|
||||
contributor.AvatarUrl = user.AvatarUrl;
|
||||
// Prefer the canonical username if we found one
|
||||
if (!string.IsNullOrWhiteSpace(user.Username))
|
||||
contributor.Login = user.Username;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback: avatar-only lookup via /avatar
|
||||
var avatarUrl = await TryResolveAvatarOnly(client, key);
|
||||
if (!string.IsNullOrWhiteSpace(avatarUrl))
|
||||
contributor.AvatarUrl = avatarUrl;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Best effort — skip if anything goes wrong
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task<GitLabUser?> TryResolveGitLabUser(HttpClient client, string email)
|
||||
{
|
||||
// Try exact public_email match first (only returns users who made their email public)
|
||||
var user = await QueryUser(client, $"users?public_email={Uri.EscapeDataString(email)}");
|
||||
if (user is not null)
|
||||
return user;
|
||||
|
||||
// Fall back to search (matches any field including email substring)
|
||||
return await QueryUser(client, $"users?search={Uri.EscapeDataString(email)}");
|
||||
}
|
||||
|
||||
private static async Task<GitLabUser?> QueryUser(HttpClient client, string apiPath)
|
||||
{
|
||||
using var resp = await client.GetAsync(apiPath);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return null;
|
||||
var json = await resp.Content.ReadAsStringAsync();
|
||||
var users = JsonConvert.DeserializeObject<List<GitLabUser>>(json);
|
||||
// Only accept unambiguous matches
|
||||
return users is { Count: 1 } ? users[0] : null;
|
||||
}
|
||||
|
||||
private static async Task<string?> TryResolveAvatarOnly(HttpClient client, string email)
|
||||
{
|
||||
var apiUrl = $"avatar?email={Uri.EscapeDataString(email)}&size=48";
|
||||
using var resp = await client.GetAsync(apiUrl);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
return null;
|
||||
var json = await resp.Content.ReadAsStringAsync();
|
||||
var obj = JObject.Parse(json);
|
||||
return obj["avatar_url"]?.ToString();
|
||||
}
|
||||
|
||||
private HttpClient CreateClientForRepo(string repoUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(repoUrl.Trim(), UriKind.Absolute, out var uri))
|
||||
throw new BuildServiceException("Invalid repository URL");
|
||||
|
||||
// Use the named GitLab client so GITLAB_TOKEN config is applied
|
||||
var client = _httpClientFactory.CreateClient(HttpClientNames.GitLab);
|
||||
// Override base address to target the repo's actual host (self-hosted support)
|
||||
client.BaseAddress = new Uri($"{uri.Scheme}://{uri.Authority}/api/v4/");
|
||||
return client;
|
||||
}
|
||||
|
||||
private static string GetProjectId(string repoUrl)
|
||||
{
|
||||
if (!Uri.TryCreate(repoUrl.Trim(), UriKind.Absolute, out var uri))
|
||||
throw new BuildServiceException("Invalid repository URL");
|
||||
|
||||
var path = uri.AbsolutePath.Trim('/');
|
||||
if (path.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
|
||||
path = path[..^4];
|
||||
|
||||
if (string.IsNullOrEmpty(path))
|
||||
throw new BuildServiceException("Invalid repository URL");
|
||||
|
||||
// GitLab project ID is the URL-encoded full path (e.g., "group/subgroup/repo")
|
||||
return path;
|
||||
}
|
||||
|
||||
private record GitLabTreeItem(
|
||||
[property: JsonProperty("name")] string Name,
|
||||
[property: JsonProperty("type")] string Type,
|
||||
[property: JsonProperty("path")] string Path);
|
||||
|
||||
private record GitLabCommit(
|
||||
[property: JsonProperty("author_name")] string AuthorName,
|
||||
[property: JsonProperty("author_email")] string? AuthorEmail);
|
||||
|
||||
private record GitLabUser(
|
||||
[property: JsonProperty("username")] string? Username,
|
||||
[property: JsonProperty("web_url")] string? WebUrl,
|
||||
[property: JsonProperty("avatar_url")] string? AvatarUrl);
|
||||
}
|
||||
12
PluginBuilder/Services/IGitHostingProvider.cs
Normal file
12
PluginBuilder/Services/IGitHostingProvider.cs
Normal file
@ -0,0 +1,12 @@
|
||||
using PluginBuilder.APIModels;
|
||||
|
||||
namespace PluginBuilder.Services;
|
||||
|
||||
public interface IGitHostingProvider
|
||||
{
|
||||
bool CanHandle(string repoUrl);
|
||||
Task<string> FetchIdentifierFromCsprojAsync(string repoUrl, string gitRef, string? pluginDir = null);
|
||||
Task<List<GitHubContributor>> GetContributorsAsync(string repoUrl, string pluginDir);
|
||||
string? GetSourceUrl(string repoUrl, string? commit, string? pluginDir);
|
||||
(string Owner, string RepoName)? ParseRepository(string repoUrl);
|
||||
}
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@ public static class FileExtensions
|
||||
private const long _maxFileSize = 1024 * 1024;
|
||||
private static readonly string[] _permittedExtensions = { ".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg" };
|
||||
|
||||
public static bool ValidateUploadedImage(this IFormFile file, out string error, long maxFileSizeInBytes = 1_000_000)
|
||||
public static bool ValidateImageFile(this IFormFile file, out string error, long maxFileSizeInBytes = 1_000_000)
|
||||
{
|
||||
if (file == null || file.Length == 0)
|
||||
{
|
||||
|
||||
@ -241,6 +241,15 @@ public static class NpgsqlConnectionExtensions
|
||||
return true;
|
||||
}
|
||||
|
||||
public static async Task<bool> DeletePlugin(this NpgsqlConnection connection, PluginSlug pluginSlug)
|
||||
{
|
||||
var affectedRows = await connection.ExecuteAsync(
|
||||
"DELETE FROM plugins WHERE slug = @pluginSlug;",
|
||||
new { pluginSlug = pluginSlug.ToString() });
|
||||
|
||||
return affectedRows == 1;
|
||||
}
|
||||
|
||||
public static async Task UpdateBuild(this NpgsqlConnection connection, FullBuildId fullBuildId, BuildStates newState, JObject? buildInfo,
|
||||
PluginManifest? manifestInfo = null, NpgsqlTransaction? tx = null)
|
||||
{
|
||||
@ -851,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 = """
|
||||
|
||||
@ -20,6 +20,7 @@ public static class PluginSettingExtensions
|
||||
BuildConfig = viewModel.BuildConfig ?? string.Empty,
|
||||
Logo = viewModel.LogoUrl,
|
||||
VideoUrl = viewModel.VideoUrl,
|
||||
Images = viewModel.ImagesUrl,
|
||||
RequireGPGSignatureForRelease = viewModel.RequireGPGSignatureForRelease
|
||||
};
|
||||
}
|
||||
@ -37,6 +38,7 @@ public static class PluginSettingExtensions
|
||||
BuildConfig = settings.BuildConfig ?? string.Empty,
|
||||
LogoUrl = settings.Logo,
|
||||
VideoUrl = settings.VideoUrl,
|
||||
ImagesUrl = settings.Images ?? [],
|
||||
RequireGPGSignatureForRelease = settings.RequireGPGSignatureForRelease
|
||||
};
|
||||
}
|
||||
|
||||
@ -17,56 +17,70 @@ public static partial class VideoUrlExtensions
|
||||
|
||||
public static string? GetVideoEmbedUrl(this string? videoUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(videoUrl))
|
||||
return null;
|
||||
if (!TryParseVideoUri(videoUrl, out var uri)) return null;
|
||||
|
||||
if (!Uri.TryCreate(videoUrl, UriKind.Absolute, out var uri))
|
||||
return null;
|
||||
var youtubeId = TryGetYoutubeVideoId(uri!);
|
||||
if (!string.IsNullOrEmpty(youtubeId))
|
||||
return $"https://www.youtube.com/embed/{youtubeId}";
|
||||
|
||||
if (uri.Scheme != Uri.UriSchemeHttps)
|
||||
return null;
|
||||
|
||||
return TryGetYoutubeEmbedUrl(uri)
|
||||
?? TryGetYoutubeShortEmbedUrl(uri)
|
||||
?? TryGetVimeoEmbedUrl(uri);
|
||||
var vimeoId = TryGetVimeoVideoId(uri!);
|
||||
return !string.IsNullOrEmpty(vimeoId) ? $"https://player.vimeo.com/video/{vimeoId}" : null;
|
||||
}
|
||||
|
||||
private static string? TryGetYoutubeEmbedUrl(Uri uri)
|
||||
public static string? GetVideoThumbnailUrl(this string? videoUrl)
|
||||
{
|
||||
if (!IsHost(uri, "youtube.com"))
|
||||
return null;
|
||||
if (!TryParseVideoUri(videoUrl, out var uri)) return null;
|
||||
|
||||
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var videoId = query["v"];
|
||||
var youtubeId = TryGetYoutubeVideoId(uri);
|
||||
if (!string.IsNullOrEmpty(youtubeId))
|
||||
return $"https://i.ytimg.com/vi/{youtubeId}/hqdefault.jpg";
|
||||
|
||||
if (string.IsNullOrWhiteSpace(videoId) || !YoutubeIdRegex().IsMatch(videoId))
|
||||
return null;
|
||||
var vimeoId = TryGetVimeoVideoId(uri);
|
||||
if (!string.IsNullOrEmpty(vimeoId))
|
||||
return $"https://vumbnail.com/{vimeoId}.jpg";
|
||||
|
||||
return $"https://www.youtube.com/embed/{videoId}";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryGetYoutubeShortEmbedUrl(Uri uri)
|
||||
private static string? TryGetYoutubeVideoId(Uri uri)
|
||||
{
|
||||
if (!IsHost(uri, "youtu.be"))
|
||||
return null;
|
||||
if (IsHost(uri, "youtube.com"))
|
||||
{
|
||||
var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
|
||||
var videoId = query["v"];
|
||||
return string.IsNullOrWhiteSpace(videoId) || !YoutubeIdRegex().IsMatch(videoId)
|
||||
? null
|
||||
: videoId;
|
||||
}
|
||||
|
||||
var videoId = uri.AbsolutePath.TrimStart('/');
|
||||
if (string.IsNullOrWhiteSpace(videoId) || !YoutubeIdRegex().IsMatch(videoId))
|
||||
return null;
|
||||
if (IsHost(uri, "youtu.be"))
|
||||
{
|
||||
var videoId = uri.AbsolutePath.TrimStart('/');
|
||||
return string.IsNullOrWhiteSpace(videoId) || !YoutubeIdRegex().IsMatch(videoId)
|
||||
? null
|
||||
: videoId;
|
||||
}
|
||||
|
||||
return $"https://www.youtube.com/embed/{videoId}";
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryGetVimeoEmbedUrl(Uri uri)
|
||||
private static string? TryGetVimeoVideoId(Uri uri)
|
||||
{
|
||||
if (!IsHost(uri, "vimeo.com"))
|
||||
return null;
|
||||
|
||||
var videoId = uri.AbsolutePath.TrimStart('/').Split('/')[0];
|
||||
if (string.IsNullOrWhiteSpace(videoId) || !VimeoIdRegex().IsMatch(videoId))
|
||||
return null;
|
||||
return string.IsNullOrWhiteSpace(videoId) || !VimeoIdRegex().IsMatch(videoId)
|
||||
? null
|
||||
: videoId;
|
||||
}
|
||||
|
||||
return $"https://player.vimeo.com/video/{videoId}";
|
||||
private static bool TryParseVideoUri(string? videoUrl, out Uri? uri)
|
||||
{
|
||||
uri = null;
|
||||
if (string.IsNullOrWhiteSpace(videoUrl)) return false;
|
||||
if (!Uri.TryCreate(videoUrl, UriKind.Absolute, out uri)) return false;
|
||||
return uri.Scheme == Uri.UriSchemeHttps;
|
||||
}
|
||||
|
||||
private static bool IsHost(Uri uri, string host)
|
||||
|
||||
@ -40,6 +40,9 @@ public class PluginEditViewModel : PluginViewModel
|
||||
|
||||
[Display(Name = "Logo")]
|
||||
public IFormFile? LogoFile { get; set; }
|
||||
|
||||
[Display(Name = "Images")]
|
||||
public List<IFormFile> Images { get; set; } = [];
|
||||
}
|
||||
|
||||
public static class PluginEditTabs
|
||||
|
||||
@ -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; }
|
||||
|
||||
@ -43,4 +43,10 @@ public class PluginSettingViewModel
|
||||
[MaxLength(200)]
|
||||
[Display(Name = "Video URL")]
|
||||
public string? VideoUrl { get; set; }
|
||||
|
||||
[Display(Name = "Images")]
|
||||
public List<string> ImagesUrl { get; set; } = [];
|
||||
|
||||
[Display(Name = "Images")]
|
||||
public List<IFormFile?> Images { get; set; } = [];
|
||||
}
|
||||
|
||||
14
PluginBuilder/ViewModels/Shared/EditImagesViewModel.cs
Normal file
14
PluginBuilder/ViewModels/Shared/EditImagesViewModel.cs
Normal file
@ -0,0 +1,14 @@
|
||||
namespace PluginBuilder.ViewModels.Shared;
|
||||
|
||||
public class EditImagesViewModel
|
||||
{
|
||||
public List<string> ExistingImages { get; init; } = [];
|
||||
|
||||
public static EditImagesViewModel Create(List<string>? images)
|
||||
{
|
||||
return new EditImagesViewModel
|
||||
{
|
||||
ExistingImages = images?.Where(s => !string.IsNullOrWhiteSpace(s)).ToList() ?? []
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -10,26 +10,34 @@
|
||||
<h2 class="mb-0">@ViewData["Title"]</h2>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="btn-group" role="group">
|
||||
<a asp-action="ListingRequests" asp-route-status="pending"
|
||||
class="btn btn-@(Model.StatusFilter == "pending" ? "primary" : "outline-secondary")">
|
||||
<nav id="SectionNav" class="mb-3">
|
||||
<div class="nav gap-4">
|
||||
<a asp-action="ListingRequests"
|
||||
asp-route-status="pending"
|
||||
class="nav-link @(Model.StatusFilter == "pending" ? "active" : null)"
|
||||
role="tab">
|
||||
Pending
|
||||
</a>
|
||||
<a asp-action="ListingRequests" asp-route-status="approved"
|
||||
class="btn btn-@(Model.StatusFilter == "approved" ? "primary" : "outline-secondary")">
|
||||
<a asp-action="ListingRequests"
|
||||
asp-route-status="approved"
|
||||
class="nav-link @(Model.StatusFilter == "approved" ? "active" : null)"
|
||||
role="tab">
|
||||
Approved
|
||||
</a>
|
||||
<a asp-action="ListingRequests" asp-route-status="rejected"
|
||||
class="btn btn-@(Model.StatusFilter == "rejected" ? "primary" : "outline-secondary")">
|
||||
<a asp-action="ListingRequests"
|
||||
asp-route-status="rejected"
|
||||
class="nav-link @(Model.StatusFilter == "rejected" ? "active" : null)"
|
||||
role="tab">
|
||||
Rejected
|
||||
</a>
|
||||
<a asp-action="ListingRequests" asp-route-status="all"
|
||||
class="btn btn-@(Model.StatusFilter == "all" ? "primary" : "outline-secondary")">
|
||||
<a asp-action="ListingRequests"
|
||||
asp-route-status="all"
|
||||
class="nav-link @(Model.StatusFilter == "all" ? "active" : null)"
|
||||
role="tab">
|
||||
All
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@if (!Model.Requests.Any())
|
||||
{
|
||||
@ -91,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>
|
||||
|
||||
@ -27,6 +27,7 @@
|
||||
var selectedSourceValue = ((int)Model.ImportReview.Source).ToString();
|
||||
var openCompatibilityModalId = Model.PublishedVersions
|
||||
.FirstOrDefault(v => string.Equals(v.Version, Model.OpenCompatibilityVersion, StringComparison.Ordinal))?.CompatibilityModalId;
|
||||
var editImagesVm = EditImagesViewModel.Create(Model.PluginSettings.Images);
|
||||
}
|
||||
|
||||
<div class="d-flex flex-column flex-md-row align-items-start justify-content-between gap-3 mb-4">
|
||||
@ -42,8 +43,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul class="nav nav-pills gap-4 mb-4" id="plugin-edit-tabs" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<nav id="SectionNav" class="mb-4">
|
||||
<div class="nav gap-4" id="plugin-edit-tabs" role="tablist">
|
||||
<a class="nav-link @(isSettingsTab ? "active" : null)"
|
||||
id="plugin-edit-settings-tab"
|
||||
asp-controller="Admin"
|
||||
@ -52,8 +53,6 @@
|
||||
asp-route-tab="@PluginEditTabs.Settings">
|
||||
Settings
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link @(isOwnersTab ? "active" : null)"
|
||||
id="plugin-edit-owners-tab"
|
||||
asp-controller="Admin"
|
||||
@ -62,8 +61,6 @@
|
||||
asp-route-tab="@PluginEditTabs.Owners">
|
||||
Owners
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link @(isVersionsTab ? "active" : null)"
|
||||
id="plugin-edit-versions-tab"
|
||||
asp-controller="Admin"
|
||||
@ -72,8 +69,6 @@
|
||||
asp-route-tab="@PluginEditTabs.Versions">
|
||||
Versions
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<a class="nav-link @(isReviewsTab ? "active" : null)"
|
||||
id="plugin-edit-reviews-tab"
|
||||
asp-controller="Admin"
|
||||
@ -82,8 +77,8 @@
|
||||
asp-route-tab="@PluginEditTabs.Reviews">
|
||||
Import Reviews
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="tab-content" id="plugin-edit-tab-content">
|
||||
<div class="tab-pane fade @(isSettingsTab ? "show active" : null)"
|
||||
@ -94,7 +89,7 @@
|
||||
<div class="col-md-10 col-lg-8">
|
||||
<h5 class="fw-bold mb-3">Settings</h5>
|
||||
|
||||
<form asp-action="PluginEdit" method="post" enctype="multipart/form-data">
|
||||
<form id="plugin-edit-form" asp-action="PluginEdit" method="post" enctype="multipart/form-data">
|
||||
@Html.AntiForgeryToken()
|
||||
<input type="hidden" name="@nameof(Model.ActiveTab)" value="@PluginEditTabs.Settings" />
|
||||
<div class="mb-3">
|
||||
@ -152,6 +147,7 @@
|
||||
</div>
|
||||
<span asp-validation-for="LogoFile" class="text-danger"></span>
|
||||
</div>
|
||||
<partial name="_EditImages" model="editImagesVm"></partial>
|
||||
|
||||
<div class="mb-3">
|
||||
<label asp-for="Visibility" class="form-label"></label>
|
||||
@ -364,13 +360,12 @@
|
||||
<style>
|
||||
#plugin-edit-tabs .nav-link {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-weight: var(--btcpay-font-weight-semibold);
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
color: var(--btcpay-nav-link);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
#plugin-edit-tabs .nav-link.active {
|
||||
color: var(--btcpay-primary);
|
||||
padding: 0 0 .75rem;
|
||||
}
|
||||
|
||||
.admin-compatibility-input {
|
||||
@ -448,5 +443,6 @@
|
||||
if (modalElement)
|
||||
bootstrap.Modal.getOrCreateInstance(modalElement).show();
|
||||
}
|
||||
|
||||
</script>
|
||||
}
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
@using PluginBuilder.Services
|
||||
@inject GitHostingProviderFactory GitHostingProviderFactory
|
||||
@model List<PluginBuilder.APIModels.PublishedPlugin>?
|
||||
@{
|
||||
Layout = "_LayoutPublicModal";
|
||||
@ -95,10 +97,17 @@
|
||||
{
|
||||
@foreach (var plugin in Model)
|
||||
{
|
||||
var owner = plugin.GetGithubRepository()?.Owner;
|
||||
var owner = plugin.GetOwnerName(GitHostingProviderFactory);
|
||||
var ownerProfileUrl = plugin.GetOwnerProfileUrl(GitHostingProviderFactory);
|
||||
<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;">
|
||||
@ -116,7 +125,7 @@
|
||||
|
||||
<div style="font-size: 0.875em; color: #6c757d;">
|
||||
<div style="margin-bottom: 3px;">
|
||||
<span>by <a href="https://github.com/@owner" rel="noreferrer noopener" target="_blank">@owner</a></span>
|
||||
<span>by @if (ownerProfileUrl != null) {<a href="@ownerProfileUrl" rel="noreferrer noopener" target="_blank">@owner</a>} else {@owner}</span>
|
||||
</div>
|
||||
<div>
|
||||
Version: @plugin.Version - Published:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -191,23 +191,16 @@
|
||||
</table>
|
||||
|
||||
<div>
|
||||
<ul class="nav nav-pills gap-4 mb-3" id="artifacts-tabs" role="tablist">
|
||||
<li class="nav-item">
|
||||
<nav id="SectionNav">
|
||||
<div class="nav gap-4" id="artifacts-tabs" role="tablist">
|
||||
<button class="nav-link active" id="build-logs-tab" data-bs-toggle="pill" data-bs-target="#build-logs-pane" type="button" role="tab"
|
||||
aria-controls="build-logs-pane">Logs
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
aria-controls="build-logs-pane">Logs</button>
|
||||
<button class="nav-link" id="build-info-tab" data-bs-toggle="pill" data-bs-target="#build-info-pane" type="button" role="tab"
|
||||
aria-controls="build-info-pane">Build info
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
aria-controls="build-info-pane">Build info</button>
|
||||
<button class="nav-link" id="manifest-info-tab" data-bs-toggle="pill" data-bs-target="#manifest-info-pane" type="button" role="tab"
|
||||
aria-controls="manifest-info-pane">Plugin manifest
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
aria-controls="manifest-info-pane">Plugin manifest</button>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="tab-content" id="pills-tabContent">
|
||||
<div class="tab-pane show active" id="build-logs-pane" role="tabpanel" aria-labelledby="build-logs-tab">
|
||||
<pre><code class="text hljs" id="Logs">@Model.Logs</code>
|
||||
@ -256,14 +249,14 @@
|
||||
|
||||
#artifacts-tabs .nav-link {
|
||||
background: none;
|
||||
padding: 0;
|
||||
font-weight: var(--btcpay-font-weight-semibold);
|
||||
border: 0;
|
||||
border-bottom: 2px solid transparent;
|
||||
border-radius: 0;
|
||||
color: var(--btcpay-nav-link);
|
||||
font-size: 1.125rem;
|
||||
padding: 0 0 .75rem;
|
||||
}
|
||||
|
||||
#artifacts-tabs .nav-link.active {
|
||||
color: var(--btcpay-primary);
|
||||
}
|
||||
</style>
|
||||
}
|
||||
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
@model PluginSettingViewModel
|
||||
@using PluginBuilder.ViewModels.Shared
|
||||
@{
|
||||
Layout = "_Layout";
|
||||
ViewData.SetActivePage(PluginNavPages.Settings, "Plugin settings");
|
||||
var editImagesVm = EditImagesViewModel.Create(Model.ImagesUrl);
|
||||
}
|
||||
<div class="d-flex align-items-center justify-content-between mb-4">
|
||||
<h2 class="mb-0">
|
||||
@ -93,6 +95,7 @@
|
||||
</div>
|
||||
<span asp-validation-for="LogoUrl" class="text-danger"></span>
|
||||
</div>
|
||||
<partial name="_EditImages" model="editImagesVm"></partial>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
61
PluginBuilder/Views/Shared/_EditImages.cshtml
Normal file
61
PluginBuilder/Views/Shared/_EditImages.cshtml
Normal file
@ -0,0 +1,61 @@
|
||||
@model PluginBuilder.ViewModels.Shared.EditImagesViewModel
|
||||
|
||||
@{
|
||||
Layout = null;
|
||||
}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="images-input" class="form-label">Images</label>
|
||||
<input id="images-input" name="Images" type="file" class="form-control" accept="image/*" multiple>
|
||||
<span class="text-danger field-validation-valid" data-valmsg-for="Images" data-valmsg-replace="true"></span>
|
||||
<div id="images-limit-error" class="text-danger small mt-1 d-none"></div>
|
||||
<input type="hidden" name="ImagesUrlSubmitted" value="true" />
|
||||
<div id="images-order-list" class="images-grid mt-2">
|
||||
@if (Model.ExistingImages is { Count: > 0 })
|
||||
{
|
||||
var images = Model.ExistingImages.Where(s => !string.IsNullOrWhiteSpace(s)).ToList();
|
||||
@for (var i = 0; i < images.Count; i++)
|
||||
{
|
||||
<div class="image-item" data-image-item draggable="true">
|
||||
@{ var imageUrl = images[i]; }
|
||||
<input type="hidden" name="ImagesOrder" value="existing" data-order-input />
|
||||
<input type="hidden" name="ImagesUrl" value="@imageUrl" data-existing-input />
|
||||
<button type="button" class="image-preview-btn" data-preview-trigger>
|
||||
<img src="@imageUrl" alt="Image" class="image-preview" />
|
||||
</button>
|
||||
<div class="image-actions">
|
||||
<span class="text-muted small">Drag to reorder</span>
|
||||
<button type="submit" class="image-remove-action" name="removeImageUrl" value="@imageUrl" aria-label="Remove image">
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal fade" id="images-preview-modal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered modal-xl">
|
||||
<div class="modal-content bg-transparent border-0 shadow-none">
|
||||
<div class="d-flex justify-content-end mb-2">
|
||||
<button type="button" class="image-modal-close" data-bs-dismiss="modal" aria-label="Close preview">
|
||||
<span class="image-close-glyph" aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
<img id="images-preview-modal-img" src="" alt="Image preview" class="image-preview-modal-image" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="~/scripts/plugin-images-manager.js" asp-append-version="true"></script>
|
||||
<script>
|
||||
window.PluginImagesManager?.init({
|
||||
listId: 'images-order-list',
|
||||
inputId: 'images-input',
|
||||
limitErrorId: 'images-limit-error',
|
||||
modalId: 'images-preview-modal',
|
||||
modalImageId: 'images-preview-modal-img',
|
||||
maxImages: 10
|
||||
});
|
||||
</script>
|
||||
@ -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>
|
||||
@ -410,12 +413,12 @@
|
||||
</symbol>
|
||||
|
||||
<symbol id="code" viewBox="0 0 24 24">
|
||||
<path fill="currentColor" d="M7 8L3 11.6923L7 16M17 8L21 11.6923L17 16M14 4L10 20" stroke="#000000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path fill="currentColor" d="M7 8L3 11.6923L7 16M17 8L21 11.6923L17 16M14 4L10 20" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</symbol>
|
||||
|
||||
<symbol id="link" viewBox="0 0 24 24">
|
||||
<path fill="none" d="M10.0464 14C8.54044 12.4882 8.67609 9.90087 10.3494 8.22108L15.197 3.35462C16.8703 1.67483 19.4476 1.53865 20.9536 3.05046C22.4596 4.56228 22.3239 7.14956 20.6506 8.82935L18.2268 11.2626" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path fill="none" d="M13.9536 10C15.4596 11.5118 15.3239 14.0991 13.6506 15.7789L11.2268 18.2121L8.80299 20.6454C7.12969 22.3252 4.55237 22.4613 3.0464 20.9495C1.54043 19.4377 1.67609 16.8504 3.34939 15.1706L5.77323 12.7373" stroke="#1C274C" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path fill="none" d="M10.0464 14C8.54044 12.4882 8.67609 9.90087 10.3494 8.22108L15.197 3.35462C16.8703 1.67483 19.4476 1.53865 20.9536 3.05046C22.4596 4.56228 22.3239 7.14956 20.6506 8.82935L18.2268 11.2626" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path fill="none" d="M13.9536 10C15.4596 11.5118 15.3239 14.0991 13.6506 15.7789L11.2268 18.2121L8.80299 20.6454C7.12969 22.3252 4.55237 22.4613 3.0464 20.9495C1.54043 19.4377 1.67609 16.8504 3.34939 15.1706L5.77323 12.7373" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
</symbol>
|
||||
<symbol id="cross" viewBox="0 0 24 24">
|
||||
<path d="m 12.130988,3.8368041 c 0.1405,0.14063 0.2194,0.33125 0.2194,0.53 0,0.19875 -0.0789,0.38937 -0.2194,0.53 0,0 -7.1796704,7.1797099 -7.2499804,7.2499599 -0.4782845,0.337104 -0.6862778,0.290768 -1.06,0 -0.3737222,-0.290768 -0.019811,-1.025771 -0.019811,-1.025771 L 11.070988,3.8368041 c 0.1406,-0.14045 0.3313,-0.21934 0.53,-0.21934 0.1988,0 0.3894,0.07889 0.53,0.21934 z" fill="currentColor" />
|
||||
|
||||
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 56 KiB |
234
PluginBuilder/wwwroot/main/bootstrap/bootstrap.css
vendored
234
PluginBuilder/wwwroot/main/bootstrap/bootstrap.css
vendored
@ -5372,6 +5372,130 @@ fieldset:disabled .btn {
|
||||
animation: 1s linear infinite progress-bar-stripes;
|
||||
}
|
||||
|
||||
|
||||
.plugin-media-carousel .plugin-media-stage {
|
||||
position: relative;
|
||||
background: #0f131a;
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-slide {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plugin-media-carousel iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 2;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-nav:hover {
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-nav-prev {
|
||||
left: 0.75rem;
|
||||
right: auto;
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-nav-next {
|
||||
right: 0.75rem;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-thumbs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
overflow-x: auto;
|
||||
padding-top: 0.75rem;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-thumb {
|
||||
flex: 0 0 auto;
|
||||
width: 8rem;
|
||||
height: 4.5rem;
|
||||
padding: 0;
|
||||
border: 2px solid transparent;
|
||||
border-radius: 0.35rem;
|
||||
background: transparent;
|
||||
overflow: hidden;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-thumb:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-thumb.is-active {
|
||||
border-color: var(--btcpay-primary, #0d6efd);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-thumb img,
|
||||
.plugin-media-carousel .plugin-media-video-thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.85rem;
|
||||
color: #fff;
|
||||
background: linear-gradient(135deg, #1e2a38 0%, #0f131a 100%);
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-video-thumb-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-video-thumb-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.plugin-media-carousel .plugin-media-video-play {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
|
||||
.list-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -6690,6 +6814,116 @@ fieldset:disabled .btn {
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.images-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.images-grid .image-item {
|
||||
border: 1px solid var(--btcpay-body-border-light, var(--bs-border-color));
|
||||
border-radius: 0.5rem;
|
||||
background: var(--btcpay-bg-tile, var(--btcpay-body-bg, #fff));
|
||||
padding: 0.5rem;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.images-grid .image-item.is-dragging {
|
||||
opacity: 0.82;
|
||||
cursor: grabbing;
|
||||
box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.28), 0 0.5rem 1.1rem rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.images-grid .image-preview-btn {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.images-grid .image-preview {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.35rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.images-grid .image-actions {
|
||||
margin-top: 0.35rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.image-remove-action {
|
||||
width: 1.6rem;
|
||||
height: 1.6rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--btcpay-danger, var(--bs-danger));
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.image-remove-action:hover {
|
||||
background: rgba(220, 53, 69, 0.12);
|
||||
color: var(--btcpay-danger, var(--bs-danger));
|
||||
}
|
||||
|
||||
.image-remove-action:focus-visible {
|
||||
outline: 2px solid rgba(220, 53, 69, 0.35);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.image-close-glyph {
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1em;
|
||||
height: 1em;
|
||||
transform: translateY(-0.06em);
|
||||
}
|
||||
|
||||
.image-modal-close {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(15, 18, 24, 0.78);
|
||||
color: #fff;
|
||||
backdrop-filter: blur(3px);
|
||||
transition: transform .12s ease, background-color .12s ease;
|
||||
}
|
||||
|
||||
.image-modal-close:hover {
|
||||
transform: scale(1.06);
|
||||
background: rgba(15, 18, 24, 0.92);
|
||||
}
|
||||
|
||||
.image-modal-close:focus-visible {
|
||||
outline: 2px solid rgba(255,255,255,.75);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.image-preview-modal-image {
|
||||
width: 100%;
|
||||
border-radius: 0.375rem;
|
||||
max-height: 85vh;
|
||||
object-fit: contain;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.offcanvas {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
|
||||
204
PluginBuilder/wwwroot/scripts/plugin-images-manager.js
Normal file
204
PluginBuilder/wwwroot/scripts/plugin-images-manager.js
Normal file
@ -0,0 +1,204 @@
|
||||
(() => {
|
||||
const DEFAULT_MAX_IMAGES = 10;
|
||||
|
||||
function init(options) {
|
||||
const {
|
||||
listId,
|
||||
inputId,
|
||||
limitErrorId,
|
||||
modalId,
|
||||
modalImageId,
|
||||
maxImages = DEFAULT_MAX_IMAGES
|
||||
} = options || {};
|
||||
|
||||
const list = document.getElementById(listId);
|
||||
const input = document.getElementById(inputId);
|
||||
const limitError = document.getElementById(limitErrorId);
|
||||
const modalElement = document.getElementById(modalId);
|
||||
const modalImage = document.getElementById(modalImageId);
|
||||
|
||||
const getModal = () => {
|
||||
if (!modalElement || !window.bootstrap?.Modal)
|
||||
return null;
|
||||
|
||||
return window.bootstrap.Modal.getOrCreateInstance(modalElement);
|
||||
};
|
||||
|
||||
if (!list || !input) {
|
||||
return;
|
||||
}
|
||||
|
||||
let newImageIndex = 0;
|
||||
const newImages = new Map();
|
||||
let draggedItem = null;
|
||||
|
||||
const disableNativeImageDrag = (scope) => {
|
||||
Array.from((scope || list).querySelectorAll('.image-preview')).forEach(img => {
|
||||
img.setAttribute('draggable', 'false');
|
||||
});
|
||||
};
|
||||
|
||||
const setLimitMessage = (message) => {
|
||||
if (!limitError)
|
||||
return;
|
||||
|
||||
if (message) {
|
||||
limitError.textContent = message;
|
||||
limitError.classList.remove('d-none');
|
||||
}
|
||||
else {
|
||||
limitError.textContent = '';
|
||||
limitError.classList.add('d-none');
|
||||
}
|
||||
};
|
||||
|
||||
const rebuildInputFileList = () => {
|
||||
const dataTransfer = new DataTransfer();
|
||||
Array.from(list.querySelectorAll('[data-image-item][data-new-id]')).forEach(entry => {
|
||||
const id = entry.getAttribute('data-new-id');
|
||||
const image = id ? newImages.get(id) : null;
|
||||
if (image)
|
||||
dataTransfer.items.add(image.file);
|
||||
});
|
||||
input.files = dataTransfer.files;
|
||||
};
|
||||
|
||||
input.addEventListener('change', () => {
|
||||
const selectedFiles = Array.from(input.files || []);
|
||||
const currentCount = list.querySelectorAll('[data-image-item]').length;
|
||||
const remaining = Math.max(maxImages - currentCount, 0);
|
||||
const acceptedFiles = selectedFiles.slice(0, remaining);
|
||||
const droppedCount = selectedFiles.length - acceptedFiles.length;
|
||||
|
||||
for (let i = acceptedFiles.length - 1; i >= 0; i--) {
|
||||
const file = acceptedFiles[i];
|
||||
const imageId = `new-${newImageIndex++}`;
|
||||
const previewUrl = URL.createObjectURL(file);
|
||||
newImages.set(imageId, { file, previewUrl });
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'image-item';
|
||||
card.setAttribute('data-image-item', 'true');
|
||||
card.setAttribute('draggable', 'true');
|
||||
card.setAttribute('data-new-id', imageId);
|
||||
card.innerHTML = `
|
||||
<input type="hidden" name="ImagesOrder" value="new" data-order-input />
|
||||
<button type="button" class="image-preview-btn" data-preview-trigger>
|
||||
<img src="${previewUrl}" alt="Image" class="image-preview" draggable="false" />
|
||||
</button>
|
||||
<div class="image-actions">
|
||||
<span class="text-muted small">Drag to reorder</span>
|
||||
<button type="button" class="image-remove-action" data-remove-image aria-label="Remove image" title="Remove">
|
||||
<span class="image-close-glyph" aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>`;
|
||||
list.prepend(card);
|
||||
}
|
||||
|
||||
disableNativeImageDrag();
|
||||
|
||||
if (droppedCount > 0) {
|
||||
setLimitMessage(`Maximum ${maxImages} images per plugin. ${droppedCount} file(s) were ignored.`);
|
||||
}
|
||||
else {
|
||||
setLimitMessage('');
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
rebuildInputFileList();
|
||||
});
|
||||
|
||||
list.addEventListener('dragstart', evt => {
|
||||
const item = evt.target.closest('[data-image-item]');
|
||||
if (!item) {
|
||||
evt.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
draggedItem = item;
|
||||
item.classList.add('is-dragging');
|
||||
if (evt.dataTransfer) {
|
||||
evt.dataTransfer.effectAllowed = 'move';
|
||||
evt.dataTransfer.setData('text/plain', 'image');
|
||||
const rect = item.getBoundingClientRect();
|
||||
evt.dataTransfer.setDragImage(item, rect.width / 2, rect.height / 2);
|
||||
}
|
||||
});
|
||||
|
||||
list.addEventListener('dragover', evt => {
|
||||
if (!draggedItem)
|
||||
return;
|
||||
|
||||
evt.preventDefault();
|
||||
const target = evt.target.closest('[data-image-item]');
|
||||
if (!target || target === draggedItem)
|
||||
return;
|
||||
|
||||
const rect = target.getBoundingClientRect();
|
||||
const placeBefore = evt.clientY < rect.top + (rect.height / 2);
|
||||
list.insertBefore(draggedItem, placeBefore ? target : target.nextElementSibling);
|
||||
});
|
||||
|
||||
list.addEventListener('drop', evt => {
|
||||
if (!draggedItem)
|
||||
return;
|
||||
|
||||
evt.preventDefault();
|
||||
rebuildInputFileList();
|
||||
});
|
||||
|
||||
list.addEventListener('dragend', () => {
|
||||
if (draggedItem) {
|
||||
draggedItem.classList.remove('is-dragging');
|
||||
draggedItem = null;
|
||||
}
|
||||
rebuildInputFileList();
|
||||
});
|
||||
|
||||
list.addEventListener('click', evt => {
|
||||
const target = evt.target.closest('button');
|
||||
if (!target)
|
||||
return;
|
||||
|
||||
const item = target.closest('[data-image-item]');
|
||||
if (!item)
|
||||
return;
|
||||
|
||||
if (target.hasAttribute('data-preview-trigger')) {
|
||||
if (modalImage) {
|
||||
const img = item.querySelector('.image-preview');
|
||||
if (img) {
|
||||
const modal = getModal();
|
||||
if (!modal)
|
||||
return;
|
||||
|
||||
modalImage.src = img.getAttribute('src') || '';
|
||||
modalImage.alt = img.getAttribute('alt') || 'Image preview';
|
||||
modal.show();
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.hasAttribute('data-remove-image')) {
|
||||
const imageId = item.getAttribute('data-new-id');
|
||||
if (imageId && newImages.has(imageId)) {
|
||||
URL.revokeObjectURL(newImages.get(imageId).previewUrl);
|
||||
newImages.delete(imageId);
|
||||
}
|
||||
|
||||
item.remove();
|
||||
rebuildInputFileList();
|
||||
setLimitMessage('');
|
||||
}
|
||||
});
|
||||
|
||||
const existingCount = list.querySelectorAll('[data-image-item]').length;
|
||||
if (existingCount >= maxImages)
|
||||
setLimitMessage(`Maximum ${maxImages} images per plugin.`);
|
||||
|
||||
disableNativeImageDrag();
|
||||
}
|
||||
|
||||
window.PluginImagesManager = { init };
|
||||
})();
|
||||
@ -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