From 04a64d917842c06cb6331a47b31a30276baaf3e3 Mon Sep 17 00:00:00 2001 From: "thgO.O" Date: Sun, 26 Apr 2026 12:45:37 -0300 Subject: [PATCH] Handle local loopback plugin downloads Add an explicit local artifact download proxy flag for development and tests. Keep the public download endpoint as a redirect while routing enabled loopback artifacts through the internal proxy. --- PluginBuilder.Tests/ServerTester.cs | 4 ++ PluginBuilder.Tests/UnitTest1.cs | 51 ++++++++++++++++++ PluginBuilder/Controllers/ApiController.cs | 56 +++++++++++++++++--- PluginBuilder/Properties/launchSettings.json | 1 + PluginBuilder/Services/ServerEnvironment.cs | 2 + README.md | 1 + 6 files changed, 108 insertions(+), 7 deletions(-) diff --git a/PluginBuilder.Tests/ServerTester.cs b/PluginBuilder.Tests/ServerTester.cs index d6976d4..2fa6292 100644 --- a/PluginBuilder.Tests/ServerTester.cs +++ b/PluginBuilder.Tests/ServerTester.cs @@ -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()}", ] }); diff --git a/PluginBuilder.Tests/UnitTest1.cs b/PluginBuilder.Tests/UnitTest1.cs index 2cce928..66599e4 100644 --- a/PluginBuilder.Tests/UnitTest1.cs +++ b/PluginBuilder.Tests/UnitTest1.cs @@ -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); + } + } diff --git a/PluginBuilder/Controllers/ApiController.cs b/PluginBuilder/Controllers/ApiController.cs index ca16a44..bf742a2 100644 --- a/PluginBuilder/Controllers/ApiController.cs +++ b/PluginBuilder/Controllers/ApiController.cs @@ -26,7 +26,9 @@ public class ApiController( BuildService buildService, VersionLifecycleService versionLifecycleService, UserManager 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( + 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 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 GetArtifactUrl(PluginSlug pluginSlug, PluginVersion version) + { + await using var conn = await connectionFactory.Open(); + return await conn.ExecuteScalarAsync( "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")] diff --git a/PluginBuilder/Properties/launchSettings.json b/PluginBuilder/Properties/launchSettings.json index a323b3c..b83c489 100644 --- a/PluginBuilder/Properties/launchSettings.json +++ b/PluginBuilder/Properties/launchSettings.json @@ -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" } } diff --git a/PluginBuilder/Services/ServerEnvironment.cs b/PluginBuilder/Services/ServerEnvironment.cs index 02aee0b..df4c36d 100644 --- a/PluginBuilder/Services/ServerEnvironment.cs +++ b/PluginBuilder/Services/ServerEnvironment.cs @@ -5,7 +5,9 @@ public class ServerEnvironment public ServerEnvironment(IConfiguration configuration) { CheatMode = configuration.GetValue("CHEAT_MODE") ?? false; + EnableLocalArtifactDownloadProxy = configuration.GetValue("ENABLE_LOCAL_ARTIFACT_DOWNLOAD_PROXY") ?? false; } public bool CheatMode { get; set; } + public bool EnableLocalArtifactDownloadProxy { get; set; } } diff --git a/README.md b/README.md index d9dd092..9946dbd 100644 --- a/README.md +++ b/README.md @@ -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`)