Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Various plugin fixes #5577

Merged
merged 9 commits into from
Jan 18, 2024
2 changes: 1 addition & 1 deletion BTCPayServer/Controllers/UIServerController.Plugins.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ public class ListPluginsViewModel
public IEnumerable<PluginService.AvailablePlugin> Available { get; set; }
public (string command, string plugin)[] Commands { get; set; }
public bool CanShowRestart { get; set; }
public string[] Disabled { get; set; }
public Dictionary<string, Version> Disabled { get; set; }
public Dictionary<string, AvailablePlugin> DownloadedPluginsByIdentifier { get; set; } = new Dictionary<string, AvailablePlugin>();
}

Expand Down
6 changes: 4 additions & 2 deletions BTCPayServer/HostedServices/PluginUpdateFetcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,18 @@ public async Task Do(CancellationToken cancellationToken)
var remotePluginsList = remotePlugins
.GroupBy(plugin => plugin.Identifier)
.Select(group => group.OrderByDescending(plugin => plugin.Version).First())
.Where(pair => installedPlugins.ContainsKey(pair.Identifier) || disabledPlugins.Contains(pair.Name))
.Where(pair => installedPlugins.ContainsKey(pair.Identifier) || disabledPlugins.ContainsKey(pair.Name))
.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version);
var notify = new HashSet<string>();
foreach (var pair in remotePluginsList)
{
if (dh.LastVersions.TryGetValue(pair.Key, out var lastVersion) && lastVersion >= pair.Value)
continue;
if (installedPlugins.TryGetValue(pair.Key, out var installedVersion) && installedVersion < pair.Value)
{
notify.Add(pair.Key);
if (disabledPlugins.Contains(pair.Key))
}
else if (disabledPlugins.TryGetValue(pair.Key, out var disabledVersion) && disabledVersion < pair.Value)
{
notify.Add(pair.Key);
}
Expand Down
64 changes: 48 additions & 16 deletions BTCPayServer/Plugins/PluginManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,11 @@ public static bool IsExceptionByPlugin(Exception exception, [MaybeNullWhen(false
pluginName = null;
return false;
}

public static IMvcBuilder AddPlugins(this IMvcBuilder mvcBuilder, IServiceCollection serviceCollection,
IConfiguration config, ILoggerFactory loggerFactory, ServiceProvider bootstrapServiceProvider)
{
void LoadPluginsFromAssemblies(Assembly systemAssembly1, HashSet<string> hashSet, HashSet<string> loadedPluginIdentifiers1,
void LoadPluginsFromAssemblies(Assembly systemAssembly1, HashSet<string> exclude, HashSet<string> loadedPluginIdentifiers1,
List<IBTCPayServerPlugin> btcPayServerPlugins)
{
// Load the referenced assembly plugins
Expand All @@ -73,7 +74,7 @@ public static bool IsExceptionByPlugin(Exception exception, [MaybeNullWhen(false
{
var assemblyName = assembly.GetName().Name;
bool isSystemPlugin = assembly == systemAssembly1;
if (!isSystemPlugin && hashSet.Contains(assemblyName))
if (!isSystemPlugin && exclude.Contains(assemblyName))
continue;

foreach (var plugin in GetPluginInstancesFromAssembly(assembly))
Expand Down Expand Up @@ -101,15 +102,15 @@ public static bool IsExceptionByPlugin(Exception exception, [MaybeNullWhen(false
Directory.CreateDirectory(pluginsFolder);
ExecuteCommands(pluginsFolder);

var disabledPlugins = GetDisabledPlugins(pluginsFolder);
var disabledPluginIdentifiers = GetDisabledPluginIdentifiers(pluginsFolder);
var systemAssembly = typeof(Program).Assembly;
LoadPluginsFromAssemblies(systemAssembly, disabledPlugins, loadedPluginIdentifiers, plugins);
LoadPluginsFromAssemblies(systemAssembly, disabledPluginIdentifiers, loadedPluginIdentifiers, plugins);

if (ExecuteCommands(pluginsFolder, plugins.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version)))
{
plugins.Clear();
loadedPluginIdentifiers.Clear();
LoadPluginsFromAssemblies(systemAssembly, disabledPlugins, loadedPluginIdentifiers, plugins);
LoadPluginsFromAssemblies(systemAssembly, disabledPluginIdentifiers, loadedPluginIdentifiers, plugins);
}

var pluginsToLoad = new List<(string PluginIdentifier, string PluginFilePath)>();
Expand All @@ -135,7 +136,7 @@ public static bool IsExceptionByPlugin(Exception exception, [MaybeNullWhen(false
var pluginFilePath = Path.Combine(directory, pluginIdentifier + ".dll");
if (!File.Exists(pluginFilePath))
continue;
if (disabledPlugins.Contains(pluginIdentifier))
if (disabledPluginIdentifiers.Contains(pluginIdentifier))
continue;
pluginsToLoad.Add((pluginIdentifier, pluginFilePath));
}
Expand Down Expand Up @@ -288,6 +289,31 @@ private static bool ExecuteCommands(string pluginsFolder, Dictionary<string, Ver

return remainingCommands.Count != pendingCommands.Length;
}
private static Dictionary<string, (Version, IBTCPayServerPlugin.PluginDependency[] Dependencies, bool Disabled)> TryGetInstalledInfo(
string pluginsFolder)
{
var disabled = GetDisabledPluginIdentifiers(pluginsFolder);
var installed = new Dictionary<string, (Version, IBTCPayServerPlugin.PluginDependency[] Dependencies, bool Disabled)>();
foreach (string pluginDir in Directory.EnumerateDirectories(pluginsFolder))
{
var plugin = Path.GetFileName(pluginDir);
var dirName = Path.Combine(pluginsFolder, plugin);
var isDisabled = disabled.Contains(plugin);
var manifestFileName = Path.Combine(dirName, plugin + ".json");
if (File.Exists(manifestFileName))
{
var pluginManifest = JObject.Parse(File.ReadAllText(manifestFileName)).ToObject<PluginService.AvailablePlugin>();
installed.TryAdd(pluginManifest.Identifier, (pluginManifest.Version, pluginManifest.Dependencies, isDisabled));
}
else if (isDisabled)
{
// Disabled plugin might not have a manifest, but we still need to include
// it in the list, so that it can be shown on the Manage Plugins page
installed.TryAdd(plugin, (null, null, true));
}
}
return installed;
}

private static bool DependenciesMet(string pluginsFolder, string plugin, Dictionary<string, Version> installed)
{
Expand All @@ -299,20 +325,20 @@ private static bool DependenciesMet(string pluginsFolder, string plugin, Diction
}

private static bool ExecuteCommand((string command, string extension) command, string pluginsFolder,
bool ignoreOrder = false, Dictionary<string, Version> installed = null)
bool ignoreOrder, Dictionary<string, Version> installed)
{
var dirName = Path.Combine(pluginsFolder, command.extension);
switch (command.command)
{
case "update":
if (!DependenciesMet(pluginsFolder, command.extension, installed))
return false;
ExecuteCommand(("delete", command.extension), pluginsFolder, true);
ExecuteCommand(("install", command.extension), pluginsFolder, true);
ExecuteCommand(("delete", command.extension), pluginsFolder, true, installed);
ExecuteCommand(("install", command.extension), pluginsFolder, true, installed);
break;

case "delete":
ExecuteCommand(("enable", command.extension), pluginsFolder, true);
ExecuteCommand(("enable", command.extension), pluginsFolder, true, installed);
if (File.Exists(dirName))
{
File.Delete(dirName);
Expand All @@ -335,7 +361,7 @@ private static bool DependenciesMet(string pluginsFolder, string plugin, Diction
if (!DependenciesMet(pluginsFolder, command.extension, installed))
return false;

ExecuteCommand(("enable", command.extension), pluginsFolder, true);
ExecuteCommand(("enable", command.extension), pluginsFolder, true, installed);
if (File.Exists(fileName))
{
ZipFile.ExtractToDirectory(fileName, dirName, true);
Expand Down Expand Up @@ -426,12 +452,18 @@ public static void DisablePlugin(string pluginDir, string plugin)
QueueCommands(pluginDir, ("disable", plugin));
}

public static HashSet<string> GetDisabledPlugins(string pluginsFolder)
// Loads the list of disabled plugins from the file
private static HashSet<string> GetDisabledPluginIdentifiers(string pluginsFolder)
{
var disabledPath = Path.Combine(pluginsFolder, "disabled");
return File.Exists(disabledPath) ? File.ReadAllLines(disabledPath).ToHashSet() : [];
}

// List of disabled plugins with additional info, like the disabled version and its dependencies
public static Dictionary<string, Version> GetDisabledPlugins(string pluginsFolder)
{
var disabledFilePath = Path.Combine(pluginsFolder, "disabled");
return File.Exists(disabledFilePath)
? File.ReadLines(disabledFilePath).ToHashSet()
: [];
return TryGetInstalledInfo(pluginsFolder).Where(pair => pair.Value.Disabled)
.ToDictionary(pair => pair.Key, pair => pair.Value.Item1);
}

public static bool DependencyMet(IBTCPayServerPlugin.PluginDependency dependency,
Expand Down
5 changes: 3 additions & 2 deletions BTCPayServer/Plugins/PluginService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ public async Task UploadPlugin(IFormFile plugin)
public void UninstallPlugin(string plugin)
{
var dest = _dataDirectories.Value.PluginDir;
PluginManager.CancelCommands(dest, plugin);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is actually a bugfix where you could end up in a situation of scheduling installs/updates and then scheduling a delete, causing the UI to look unintuitive on what's going to happen until they execute

PluginManager.QueueCommands(dest, ("delete", plugin));
}

Expand Down Expand Up @@ -155,9 +156,9 @@ public void CancelCommands(string plugin)
PluginManager.CancelCommands(_dataDirectories.Value.PluginDir, plugin);
}

public string[] GetDisabledPlugins()
public Dictionary<string, Version> GetDisabledPlugins()
{
return PluginManager.GetDisabledPlugins(_dataDirectories.Value.PluginDir).ToArray();
return PluginManager.GetDisabledPlugins(_dataDirectories.Value.PluginDir);
}
}
}
65 changes: 41 additions & 24 deletions BTCPayServer/Views/UIServer/ListPlugins.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@
Layout = "_Layout";
ViewData.SetActivePage(ServerNavPages.Plugins);
var installed = Model.Installed.ToDictionary(plugin => plugin.Identifier, plugin => plugin.Version);

var installedWithoutSystemPlugins = Model.Installed.Where(i => !i.SystemPlugin).ToList();
var availableAndNotInstalled = new List<PluginService.AvailablePlugin>();
var availableAndNotInstalledx = Model.Available
.Where(plugin => !installed.ContainsKey(plugin.Identifier))
.GroupBy(plugin => plugin.Identifier)
.ToList();

var availableAndNotInstalled = new List<PluginService.AvailablePlugin>();

foreach (var availableAndNotInstalledItem in availableAndNotInstalledx)
{
var ordered = availableAndNotInstalledItem.OrderByDescending(plugin => plugin.Version).ToArray();
Expand Down Expand Up @@ -76,12 +76,18 @@
<div class="mb-5">
<h3 class="mb-4">Disabled Plugins</h3>
<ul class="list-group list-group-flush d-inline-block">
@foreach (var d in Model.Disabled)
@foreach (var (plugin, version) in Model.Disabled)
{
<li class="list-group-item px-0">
<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">
<span>@d</span>
<form asp-action="UnInstallPlugin" asp-route-plugin="@d">
<span>
@plugin
@if (version != null)
{
<span>({version})</span>
}
</span>
<form asp-action="UnInstallPlugin" asp-route-plugin="@plugin">
<button type="submit" class="btn btn-sm btn-outline-danger">Uninstall</button>
</form>
</div>
Expand Down Expand Up @@ -233,26 +239,27 @@
</div>
</div>
@{
var pendingAction = Model.Commands.Any(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase));
var pendingAction = Model.Commands.LastOrDefault(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase)).command;
var exclusivePendingAction = true;

var versionOfPendingInstall = PluginService.GetVersionOfPendingInstall(plugin.Identifier);
}
<div class="card-footer border-0 pb-3 d-flex gap-2">
@if (pendingAction && updateAvailable)
@if (pendingAction is not null && updateAvailable)
{
var isUpdateAction = Model.Commands.Last(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase)).command == "update";
if (isUpdateAction)
{
var version = PluginService.GetVersionOfPendingInstall(plugin.Identifier);
exclusivePendingAction = version == x.Version;
exclusivePendingAction = versionOfPendingInstall == x.Version;
}
}
@if (pendingAction)
@if (pendingAction is not null)
{
<form asp-action="CancelPluginCommands" asp-route-plugin="@plugin.Identifier">
<button type="submit" class="btn btn-outline-secondary">Cancel pending action</button>
<button type="submit" class="btn btn-outline-secondary">Cancel pending @pendingAction @(versionOfPendingInstall is null? "": $"of {versionOfPendingInstall}")</button>
</form>
}
@if (!pendingAction || !exclusivePendingAction)
@if (pendingAction is null || !exclusivePendingAction)
{
@if (updateAvailable && x != null)
{
Expand Down Expand Up @@ -294,7 +301,7 @@
@foreach (var plugin in availableAndNotInstalled)
{
var recommended = BTCPayServerOptions.RecommendedPlugins.Any(id => string.Equals(id, plugin.Identifier, StringComparison.InvariantCultureIgnoreCase));
var disabled = Model.Disabled?.Contains(plugin.Identifier) ?? false;
Model.Disabled.TryGetValue(plugin.Identifier, out var disabled);

<div class="col col-12 col-md-6 col-lg-12 col-xl-6 col-xxl-4 mb-4">
<div class="card h-100" id="@plugin.Identifier">
Expand All @@ -313,7 +320,11 @@
</div>
<h5 class="text-muted d-flex align-items-center mt-1 gap-2">
@plugin.Version
@if (disabled)
@if (disabled is { } && disabled != plugin.Version)
{
<div class="badge bg-light">Disabled (@disabled)</div>
}
else if (disabled is { } && disabled == plugin.Version)
{
<div class="badge bg-light">Disabled</div>
}
Expand Down Expand Up @@ -384,27 +395,33 @@
</div>
<div class="card-footer border-0 pb-3 d-flex gap-2">
@{
var pendingAction = Model.Commands.LastOrDefault(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase));
var version = PluginService.GetVersionOfPendingInstall(plugin.Identifier);
var exclusivePendingAction = version == plugin.Version;
var res = Model.Commands.LastOrDefault(tuple => tuple.plugin.Equals(plugin.Identifier, StringComparison.InvariantCultureIgnoreCase));
var pendingAction = res != default ? res.command : null;
var versionOfPendingInstall = PluginService.GetVersionOfPendingInstall(plugin.Identifier);
var exclusivePendingAction = pendingAction is not null && (pendingAction == "delete" || versionOfPendingInstall == plugin.Version);
}
@if (!pendingAction.Equals(default))
@if (pendingAction is not null)
{
<form asp-action="CancelPluginCommands" asp-route-plugin="@plugin.Identifier">
<button type="submit" class="btn btn-outline-secondary">Cancel pending @pendingAction.command</button>
<button type="submit" class="btn btn-outline-secondary">Cancel pending @pendingAction @(versionOfPendingInstall is null? "": $"of {versionOfPendingInstall}")</button>
</form>
}
@if (pendingAction.Equals(default) || !exclusivePendingAction)
@if (pendingAction is null|| !exclusivePendingAction)
{
if (PluginManager.DependenciesMet(plugin.Dependencies, installed))
{
@* Don't show the "Install" button if plugin has been disabled *@
@if (!disabled)
@if (disabled is null)
{
<form asp-action="InstallPlugin" asp-route-plugin="@plugin.Identifier" asp-route-version="@plugin.Version">
<button type="submit" class="btn btn-primary">Install</button>
</form>
}
else if (disabled != plugin.Version)
{
<form asp-action="InstallPlugin" asp-route-plugin="@plugin.Identifier" asp-route-version="@plugin.Version" asp-route-update="true">
<button type="submit" class="btn btn-primary">Update</button>
</form>
}
}
else
{
Expand All @@ -413,7 +430,7 @@
</form>
}
}
@if (disabled)
@if (disabled is not null && pendingAction is null)
{
<form asp-action="UnInstallPlugin" asp-route-plugin="@plugin.Identifier">
<button type="submit" class="btn btn-sm btn-outline-danger">Uninstall</button>
Expand Down