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.
This commit is contained in:
thgO.O 2026-04-26 12:45:37 -03:00
parent 645f28c7d8
commit 04a64d9178
No known key found for this signature in database
GPG Key ID: EDC540B3D14756CB
6 changed files with 108 additions and 7 deletions

View File

@ -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()}",
]
});

View File

@ -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);
}
}

View File

@ -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")]

View File

@ -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"
}
}

View File

@ -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; }
}

View File

@ -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`)