Merge pull request #6896 from btcpayserver/fix/unlisted-plugins-updates

fix: ensure unlisted installed plugins appear as updatable
This commit is contained in:
rockstardev
2025-09-05 11:33:04 -05:00
committed by GitHub
3 changed files with 101 additions and 26 deletions

View File

@@ -26,12 +26,12 @@ namespace BTCPayServer.Controllers
{ {
availablePlugins = await pluginService.GetRemotePlugins(search); availablePlugins = await pluginService.GetRemotePlugins(search);
} }
catch (Exception) catch (Exception ex)
{ {
TempData.SetStatusMessageModel(new StatusMessageModel TempData.SetStatusMessageModel(new StatusMessageModel
{ {
Severity = StatusMessageModel.StatusSeverity.Error, Severity = StatusMessageModel.StatusSeverity.Error,
Message = StringLocalizer["Remote plugins lookup failed. Try again later."].Value Message = StringLocalizer["Remote plugins lookup failed. Try again later. Error: {0}", ex.Message].Value
}); });
availablePlugins = Array.Empty<PluginService.AvailablePlugin>(); availablePlugins = Array.Empty<PluginService.AvailablePlugin>();
} }

View File

@@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Net.Http; using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using Newtonsoft.Json; using Newtonsoft.Json;
@@ -46,32 +47,35 @@ namespace BTCPayServer.Plugins
public JObject ManifestInfo { get; set; } public JObject ManifestInfo { get; set; }
public string Documentation { get; set; } public string Documentation { get; set; }
} }
public record InstalledPluginRequest(string Identifier, string Version);
public class PluginBuilderClient public class PluginBuilderClient
{ {
HttpClient httpClient; private readonly HttpClient _httpClient;
public HttpClient HttpClient => httpClient; public HttpClient HttpClient => _httpClient;
public PluginBuilderClient(HttpClient httpClient) public PluginBuilderClient(HttpClient httpClient)
{ {
this.httpClient = httpClient; _httpClient = httpClient;
} }
static JsonSerializerSettings serializerSettings = new() { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() }; static JsonSerializerSettings serializerSettings = new() { ContractResolver = new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver() };
public async Task<PublishedVersion[]> GetPublishedVersions(string btcpayVersion, bool includePreRelease, string searchPluginName = null, bool? includeAllVersions = null) public async Task<PublishedVersion[]> GetPublishedVersions(string btcpayVersion, bool includePreRelease, string searchPluginName = null, bool? includeAllVersions = null)
{ {
var queryString = $"?includePreRelease={includePreRelease}"; var queryString = $"?includePreRelease={includePreRelease}";
if (btcpayVersion is not null) if (btcpayVersion is not null)
queryString += $"&btcpayVersion={btcpayVersion}"; queryString += $"&btcpayVersion={Uri.EscapeDataString(btcpayVersion)}";
if (searchPluginName is not null) if (searchPluginName is not null)
queryString += $"&searchPluginName={searchPluginName}"; queryString += $"&searchPluginName={Uri.EscapeDataString(searchPluginName)}";
if (includeAllVersions is not null) if (includeAllVersions is not null)
queryString += $"&includeAllVersions={includeAllVersions}"; queryString += $"&includeAllVersions={includeAllVersions}";
var result = await httpClient.GetStringAsync($"api/v1/plugins{queryString}"); var result = await _httpClient.GetStringAsync($"api/v1/plugins{queryString}");
return JsonConvert.DeserializeObject<PublishedVersion[]>(result, serializerSettings) ?? throw new InvalidOperationException(); return JsonConvert.DeserializeObject<PublishedVersion[]>(result, serializerSettings) ?? throw new InvalidOperationException();
} }
public async Task<PublishedVersion> GetPlugin(string pluginSlug, string version) public async Task<PublishedVersion> GetPlugin(string pluginSlug, string version)
{ {
try try
{ {
var result = await httpClient.GetStringAsync($"api/v1/plugins/{pluginSlug}/versions/{version}"); var result = await _httpClient.GetStringAsync($"api/v1/plugins/{pluginSlug}/versions/{version}");
return JsonConvert.DeserializeObject<PublishedVersion>(result, serializerSettings); return JsonConvert.DeserializeObject<PublishedVersion>(result, serializerSettings);
} }
catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound)
@@ -85,9 +89,33 @@ namespace BTCPayServer.Plugins
var queryString = $"?btcpayVersion={btcpayVersion}&includePreRelease={includePreRelease}&includeAllVersions={includeAllVersions}"; var queryString = $"?btcpayVersion={btcpayVersion}&includePreRelease={includePreRelease}&includeAllVersions={includeAllVersions}";
var url = $"api/v1/plugins/{identifier}{queryString}"; var url = $"api/v1/plugins/{identifier}{queryString}";
var result = await httpClient.GetStringAsync(url); var result = await _httpClient.GetStringAsync(url);
return JsonConvert.DeserializeObject<PublishedVersion[]>(result, serializerSettings) return JsonConvert.DeserializeObject<PublishedVersion[]>(result, serializerSettings)
?? throw new InvalidOperationException(); ?? throw new InvalidOperationException();
} }
public async Task<PublishedVersion[]> GetInstalledPluginsUpdates(
string btcpayVersion,
bool includePreRelease,
IEnumerable<InstalledPluginRequest> plugins)
{
var queryString = $"?includePreRelease={includePreRelease}";
if (!string.IsNullOrWhiteSpace(btcpayVersion))
queryString += $"&btcpayVersion={Uri.EscapeDataString(btcpayVersion)}";
var json = JsonConvert.SerializeObject(plugins, serializerSettings);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var resp = await _httpClient.PostAsync($"api/v1/plugins/updates{queryString}", content);
resp.EnsureSuccessStatusCode();
var body = await resp.Content.ReadAsStringAsync();
var result = JsonConvert.DeserializeObject<PublishedVersion[]>(body, serializerSettings);
if (result is null)
throw new JsonException("Plugin updates response deserialized to null.");
return result;
}
} }
} }

View File

@@ -20,12 +20,14 @@ namespace BTCPayServer.Plugins
private readonly IOptions<DataDirectories> _dataDirectories; private readonly IOptions<DataDirectories> _dataDirectories;
private readonly PoliciesSettings _policiesSettings; private readonly PoliciesSettings _policiesSettings;
private readonly PluginBuilderClient _pluginBuilderClient; private readonly PluginBuilderClient _pluginBuilderClient;
public PluginService( public PluginService(
IEnumerable<IBTCPayServerPlugin> btcPayServerPlugins, IEnumerable<IBTCPayServerPlugin> btcPayServerPlugins,
PluginBuilderClient pluginBuilderClient, PluginBuilderClient pluginBuilderClient,
IOptions<DataDirectories> dataDirectories, IOptions<DataDirectories> dataDirectories,
PoliciesSettings policiesSettings, PoliciesSettings policiesSettings,
BTCPayServerEnvironment env) BTCPayServerEnvironment env
)
{ {
LoadedPlugins = btcPayServerPlugins; LoadedPlugins = btcPayServerPlugins;
Installed = btcPayServerPlugins.ToDictionary(p => p.Identifier, p => p.Version, StringComparer.OrdinalIgnoreCase); Installed = btcPayServerPlugins.ToDictionary(p => p.Identifier, p => p.Version, StringComparer.OrdinalIgnoreCase);
@@ -49,32 +51,77 @@ namespace BTCPayServer.Plugins
return pluginManifest.Version; return pluginManifest.Version;
} }
private string GetShortBtcpayVersion() => Env.Version.TrimStart('v').Split('+')[0];
public async Task<AvailablePlugin[]> GetRemotePlugins(string searchPluginName) public async Task<AvailablePlugin[]> GetRemotePlugins(string searchPluginName)
{ {
string btcpayVersion = Env.Version.TrimStart('v').Split('+')[0]; string btcpayVersion = GetShortBtcpayVersion();
var versions = await _pluginBuilderClient.GetPublishedVersions( var versions = await _pluginBuilderClient.GetPublishedVersions(
btcpayVersion, _policiesSettings.PluginPreReleases, searchPluginName); btcpayVersion, _policiesSettings.PluginPreReleases, searchPluginName);
return versions.Select(v =>
var plugins = versions
.Select(MapToAvailablePlugin)
.Where(p => p is not null)
.Select(p => p!)
.ToList();
var listedIds = new HashSet<string>(
plugins.Select(p => p.Identifier),
StringComparer.OrdinalIgnoreCase);
var loadedToCheck = LoadedPlugins
.Where(p => !p.SystemPlugin && !listedIds.Contains(p.Identifier))
.Select(p => new InstalledPluginRequest(p.Identifier, p.Version.ToString()))
.ToList();
if (loadedToCheck.Count <= 0) return plugins.ToArray();
var updates = await _pluginBuilderClient.GetInstalledPluginsUpdates(
btcpayVersion,
_policiesSettings.PluginPreReleases,
loadedToCheck);
if (updates is { Length: > 0 })
{ {
var p = v.ManifestInfo.ToObject<AvailablePlugin>(); plugins.AddRange(
p.Documentation = v.Documentation; updates.Select(MapToAvailablePlugin)
var github = v.BuildInfo.GetGithubRepository(); .Where(p => p is not null)
if (github != null) .Select(p => p!)
{ );
p.Source = github.GetSourceUrl(v.BuildInfo.gitCommit, v.BuildInfo.pluginDir); }
p.Author = github.Owner;
p.AuthorLink = $"https://github.com/{github.Owner}"; return plugins.ToArray();
}
p.SystemPlugin = false;
return p;
}).ToArray();
} }
#nullable enable
private AvailablePlugin? MapToAvailablePlugin(PublishedVersion publishedVersion)
{
if (publishedVersion.ManifestInfo is null)
return null;
var availablePlugin = publishedVersion.ManifestInfo.ToObject<AvailablePlugin>();
if (availablePlugin is null)
throw new InvalidDataException($"Manifest deserialized to null BuildId: {publishedVersion.BuildId} PluginSlug: {publishedVersion.ProjectSlug}");
availablePlugin.Documentation = publishedVersion.Documentation;
var buildInfo = publishedVersion.BuildInfo;
var github = buildInfo?.GetGithubRepository();
if (buildInfo is not null && github is not null)
{
availablePlugin.Source = github.GetSourceUrl(buildInfo.gitCommit, buildInfo.pluginDir);
availablePlugin.Author = github.Owner;
availablePlugin.AuthorLink = $"https://github.com/{github.Owner}";
}
availablePlugin.SystemPlugin = false;
return availablePlugin;
}
#nullable restore
public async Task<AvailablePlugin> DownloadRemotePlugin(string pluginIdentifier, string version, VersionCondition condition = null) public async Task<AvailablePlugin> DownloadRemotePlugin(string pluginIdentifier, string version, VersionCondition condition = null)
{ {
if (version is null) if (version is null)
{ {
string btcpayVersion = Env.Version.TrimStart('v').Split('+')[0]; string btcpayVersion = GetShortBtcpayVersion();
var versions = await _pluginBuilderClient.GetPluginVersionsForDownload(pluginIdentifier, var versions = await _pluginBuilderClient.GetPluginVersionsForDownload(pluginIdentifier,
btcpayVersion, _policiesSettings.PluginPreReleases, includeAllVersions: true); btcpayVersion, _policiesSettings.PluginPreReleases, includeAllVersions: true);
var potentialVersions = versions var potentialVersions = versions