| | 1 | | using System; |
| | 2 | | using System.Collections.Generic; |
| | 3 | | using System.Globalization; |
| | 4 | | using System.IO; |
| | 5 | | using System.Linq; |
| | 6 | | using System.Net.Http; |
| | 7 | | using System.Reflection; |
| | 8 | | using System.Runtime.Loader; |
| | 9 | | using System.Text; |
| | 10 | | using System.Text.Json; |
| | 11 | | using System.Threading.Tasks; |
| | 12 | | using Emby.Server.Implementations.Library; |
| | 13 | | using Jellyfin.Extensions.Json; |
| | 14 | | using Jellyfin.Extensions.Json.Converters; |
| | 15 | | using MediaBrowser.Common.Extensions; |
| | 16 | | using MediaBrowser.Common.Net; |
| | 17 | | using MediaBrowser.Common.Plugins; |
| | 18 | | using MediaBrowser.Controller; |
| | 19 | | using MediaBrowser.Controller.Plugins; |
| | 20 | | using MediaBrowser.Model.Configuration; |
| | 21 | | using MediaBrowser.Model.IO; |
| | 22 | | using MediaBrowser.Model.Plugins; |
| | 23 | | using MediaBrowser.Model.Updates; |
| | 24 | | using Microsoft.Extensions.DependencyInjection; |
| | 25 | | using Microsoft.Extensions.Logging; |
| | 26 | |
|
| | 27 | | namespace Emby.Server.Implementations.Plugins |
| | 28 | | { |
| | 29 | | /// <summary> |
| | 30 | | /// Defines the <see cref="PluginManager" />. |
| | 31 | | /// </summary> |
| | 32 | | public sealed class PluginManager : IPluginManager, IDisposable |
| | 33 | | { |
| | 34 | | private const string MetafileName = "meta.json"; |
| | 35 | |
|
| | 36 | | private readonly string _pluginsPath; |
| | 37 | | private readonly Version _appVersion; |
| | 38 | | private readonly List<AssemblyLoadContext> _assemblyLoadContexts; |
| | 39 | | private readonly JsonSerializerOptions _jsonOptions; |
| | 40 | | private readonly ILogger<PluginManager> _logger; |
| | 41 | | private readonly IServerApplicationHost _appHost; |
| | 42 | | private readonly ServerConfiguration _config; |
| | 43 | | private readonly List<LocalPlugin> _plugins; |
| | 44 | | private readonly Version _minimumVersion; |
| | 45 | |
|
| | 46 | | private IHttpClientFactory? _httpClientFactory; |
| | 47 | |
|
| | 48 | | /// <summary> |
| | 49 | | /// Initializes a new instance of the <see cref="PluginManager"/> class. |
| | 50 | | /// </summary> |
| | 51 | | /// <param name="logger">The <see cref="ILogger{PluginManager}"/>.</param> |
| | 52 | | /// <param name="appHost">The <see cref="IServerApplicationHost"/>.</param> |
| | 53 | | /// <param name="config">The <see cref="ServerConfiguration"/>.</param> |
| | 54 | | /// <param name="pluginsPath">The plugin path.</param> |
| | 55 | | /// <param name="appVersion">The application version.</param> |
| | 56 | | public PluginManager( |
| | 57 | | ILogger<PluginManager> logger, |
| | 58 | | IServerApplicationHost appHost, |
| | 59 | | ServerConfiguration config, |
| | 60 | | string pluginsPath, |
| | 61 | | Version appVersion) |
| | 62 | | { |
| 38 | 63 | | _logger = logger ?? throw new ArgumentNullException(nameof(logger)); |
| 38 | 64 | | _pluginsPath = pluginsPath; |
| 38 | 65 | | _appVersion = appVersion ?? throw new ArgumentNullException(nameof(appVersion)); |
| 38 | 66 | | _jsonOptions = new JsonSerializerOptions(JsonDefaults.Options) |
| 38 | 67 | | { |
| 38 | 68 | | WriteIndented = true |
| 38 | 69 | | }; |
| | 70 | |
|
| | 71 | | // We need to use the default GUID converter, so we need to remove any custom ones. |
| 684 | 72 | | for (int a = _jsonOptions.Converters.Count - 1; a >= 0; a--) |
| | 73 | | { |
| 342 | 74 | | if (_jsonOptions.Converters[a] is JsonGuidConverter convertor) |
| | 75 | | { |
| 38 | 76 | | _jsonOptions.Converters.Remove(convertor); |
| 38 | 77 | | break; |
| | 78 | | } |
| | 79 | | } |
| | 80 | |
|
| 38 | 81 | | _config = config; |
| 38 | 82 | | _appHost = appHost; |
| 38 | 83 | | _minimumVersion = new Version(0, 0, 0, 1); |
| 38 | 84 | | _plugins = Directory.Exists(_pluginsPath) ? DiscoverPlugins().ToList() : new List<LocalPlugin>(); |
| | 85 | |
|
| 38 | 86 | | _assemblyLoadContexts = new List<AssemblyLoadContext>(); |
| 38 | 87 | | } |
| | 88 | |
|
| | 89 | | private IHttpClientFactory HttpClientFactory |
| | 90 | | { |
| | 91 | | get |
| | 92 | | { |
| 0 | 93 | | return _httpClientFactory ??= _appHost.Resolve<IHttpClientFactory>(); |
| | 94 | | } |
| | 95 | | } |
| | 96 | |
|
| | 97 | | /// <summary> |
| | 98 | | /// Gets the Plugins. |
| | 99 | | /// </summary> |
| 22 | 100 | | public IReadOnlyList<LocalPlugin> Plugins => _plugins; |
| | 101 | |
|
| | 102 | | /// <summary> |
| | 103 | | /// Returns all the assemblies. |
| | 104 | | /// </summary> |
| | 105 | | /// <returns>An IEnumerable{Assembly}.</returns> |
| | 106 | | public IEnumerable<Assembly> LoadAssemblies() |
| | 107 | | { |
| | 108 | | // Attempt to remove any deleted plugins and change any successors to be active. |
| | 109 | | for (int i = _plugins.Count - 1; i >= 0; i--) |
| | 110 | | { |
| | 111 | | var plugin = _plugins[i]; |
| | 112 | | if (plugin.Manifest.Status == PluginStatus.Deleted && DeletePlugin(plugin)) |
| | 113 | | { |
| | 114 | | // See if there is another version, and if so make that active. |
| | 115 | | ProcessAlternative(plugin); |
| | 116 | | } |
| | 117 | | } |
| | 118 | |
|
| | 119 | | // Now load the assemblies.. |
| | 120 | | foreach (var plugin in _plugins) |
| | 121 | | { |
| | 122 | | UpdatePluginSupersededStatus(plugin); |
| | 123 | |
|
| | 124 | | if (plugin.IsEnabledAndSupported == false) |
| | 125 | | { |
| | 126 | | _logger.LogInformation("Skipping disabled plugin {Version} of {Name} ", plugin.Version, plugin.Name) |
| | 127 | | continue; |
| | 128 | | } |
| | 129 | |
|
| | 130 | | var assemblyLoadContext = new PluginLoadContext(plugin.Path); |
| | 131 | | _assemblyLoadContexts.Add(assemblyLoadContext); |
| | 132 | |
|
| | 133 | | var assemblies = new List<Assembly>(plugin.DllFiles.Count); |
| | 134 | | var loadedAll = true; |
| | 135 | |
|
| | 136 | | foreach (var file in plugin.DllFiles) |
| | 137 | | { |
| | 138 | | try |
| | 139 | | { |
| | 140 | | assemblies.Add(assemblyLoadContext.LoadFromAssemblyPath(file)); |
| | 141 | | } |
| | 142 | | catch (FileLoadException ex) |
| | 143 | | { |
| | 144 | | _logger.LogError(ex, "Failed to load assembly {Path}. Disabling plugin", file); |
| | 145 | | ChangePluginState(plugin, PluginStatus.Malfunctioned); |
| | 146 | | loadedAll = false; |
| | 147 | | break; |
| | 148 | | } |
| | 149 | | #pragma warning disable CA1031 // Do not catch general exception types |
| | 150 | | catch (Exception ex) |
| | 151 | | #pragma warning restore CA1031 // Do not catch general exception types |
| | 152 | | { |
| | 153 | | _logger.LogError(ex, "Failed to load assembly {Path}. Unknown exception was thrown. Disabling pl |
| | 154 | | ChangePluginState(plugin, PluginStatus.Malfunctioned); |
| | 155 | | loadedAll = false; |
| | 156 | | break; |
| | 157 | | } |
| | 158 | | } |
| | 159 | |
|
| | 160 | | if (!loadedAll) |
| | 161 | | { |
| | 162 | | continue; |
| | 163 | | } |
| | 164 | |
|
| | 165 | | foreach (var assembly in assemblies) |
| | 166 | | { |
| | 167 | | try |
| | 168 | | { |
| | 169 | | // Load all required types to verify that the plugin will load |
| | 170 | | assembly.GetTypes(); |
| | 171 | | } |
| | 172 | | catch (SystemException ex) when (ex is TypeLoadException or ReflectionTypeLoadException) // Undocume |
| | 173 | | { |
| | 174 | | _logger.LogError(ex, "Failed to load assembly {Path}. This error occurs when a plugin references |
| | 175 | | ChangePluginState(plugin, PluginStatus.NotSupported); |
| | 176 | | break; |
| | 177 | | } |
| | 178 | | #pragma warning disable CA1031 // Do not catch general exception types |
| | 179 | | catch (Exception ex) |
| | 180 | | #pragma warning restore CA1031 // Do not catch general exception types |
| | 181 | | { |
| | 182 | | _logger.LogError(ex, "Failed to load assembly {Path}. Unknown exception was thrown. Disabling pl |
| | 183 | | ChangePluginState(plugin, PluginStatus.Malfunctioned); |
| | 184 | | break; |
| | 185 | | } |
| | 186 | |
|
| | 187 | | _logger.LogInformation("Loaded assembly {Assembly} from {Path}", assembly.FullName, assembly.Locatio |
| | 188 | | yield return assembly; |
| | 189 | | } |
| | 190 | | } |
| | 191 | | } |
| | 192 | |
|
| | 193 | | /// <summary> |
| | 194 | | /// Creates all the plugin instances. |
| | 195 | | /// </summary> |
| | 196 | | public void CreatePlugins() |
| | 197 | | { |
| 21 | 198 | | _ = _appHost.GetExports<IPlugin>(CreatePluginInstance); |
| 21 | 199 | | } |
| | 200 | |
|
| | 201 | | /// <summary> |
| | 202 | | /// Registers the plugin's services with the DI. |
| | 203 | | /// Note: DI is not yet instantiated yet. |
| | 204 | | /// </summary> |
| | 205 | | /// <param name="serviceCollection">A <see cref="ServiceCollection"/> instance.</param> |
| | 206 | | public void RegisterServices(IServiceCollection serviceCollection) |
| | 207 | | { |
| 42 | 208 | | foreach (var pluginServiceRegistrator in _appHost.GetExportTypes<IPluginServiceRegistrator>()) |
| | 209 | | { |
| 0 | 210 | | var plugin = GetPluginByAssembly(pluginServiceRegistrator.Assembly); |
| 0 | 211 | | if (plugin is null) |
| | 212 | | { |
| 0 | 213 | | _logger.LogError("Unable to find plugin in assembly {Assembly}", pluginServiceRegistrator.Assembly.F |
| 0 | 214 | | continue; |
| | 215 | | } |
| | 216 | |
|
| 0 | 217 | | UpdatePluginSupersededStatus(plugin); |
| 0 | 218 | | if (!plugin.IsEnabledAndSupported) |
| | 219 | | { |
| | 220 | | continue; |
| | 221 | | } |
| | 222 | |
|
| | 223 | | try |
| | 224 | | { |
| 0 | 225 | | var instance = (IPluginServiceRegistrator?)Activator.CreateInstance(pluginServiceRegistrator); |
| 0 | 226 | | instance?.RegisterServices(serviceCollection, _appHost); |
| 0 | 227 | | } |
| | 228 | | #pragma warning disable CA1031 // Do not catch general exception types |
| 0 | 229 | | catch (Exception ex) |
| | 230 | | #pragma warning restore CA1031 // Do not catch general exception types |
| | 231 | | { |
| 0 | 232 | | _logger.LogError(ex, "Error registering plugin services from {Assembly}.", pluginServiceRegistrator. |
| 0 | 233 | | if (ChangePluginState(plugin, PluginStatus.Malfunctioned)) |
| | 234 | | { |
| 0 | 235 | | _logger.LogInformation("Disabling plugin {Path}", plugin.Path); |
| | 236 | | } |
| 0 | 237 | | } |
| | 238 | | } |
| 21 | 239 | | } |
| | 240 | |
|
| | 241 | | /// <summary> |
| | 242 | | /// Imports a plugin manifest from <paramref name="folder"/>. |
| | 243 | | /// </summary> |
| | 244 | | /// <param name="folder">Folder of the plugin.</param> |
| | 245 | | public void ImportPluginFrom(string folder) |
| | 246 | | { |
| 0 | 247 | | ArgumentException.ThrowIfNullOrEmpty(folder); |
| | 248 | |
|
| | 249 | | // Load the plugin. |
| 0 | 250 | | var plugin = LoadManifest(folder); |
| | 251 | | // Make sure we haven't already loaded this. |
| 0 | 252 | | if (_plugins.Any(p => p.Manifest.Equals(plugin.Manifest))) |
| | 253 | | { |
| 0 | 254 | | return; |
| | 255 | | } |
| | 256 | |
|
| 0 | 257 | | _plugins.Add(plugin); |
| 0 | 258 | | EnablePlugin(plugin); |
| 0 | 259 | | } |
| | 260 | |
|
| | 261 | | /// <summary> |
| | 262 | | /// Removes the plugin reference '<paramref name="plugin"/>. |
| | 263 | | /// </summary> |
| | 264 | | /// <param name="plugin">The plugin.</param> |
| | 265 | | /// <returns>Outcome of the operation.</returns> |
| | 266 | | public bool RemovePlugin(LocalPlugin plugin) |
| | 267 | | { |
| 0 | 268 | | ArgumentNullException.ThrowIfNull(plugin); |
| | 269 | |
|
| 0 | 270 | | if (DeletePlugin(plugin)) |
| | 271 | | { |
| 0 | 272 | | ProcessAlternative(plugin); |
| 0 | 273 | | return true; |
| | 274 | | } |
| | 275 | |
|
| 0 | 276 | | _logger.LogWarning("Unable to delete {Path}, so marking as deleteOnStartup.", plugin.Path); |
| | 277 | | // Unable to delete, so disable. |
| 0 | 278 | | if (ChangePluginState(plugin, PluginStatus.Deleted)) |
| | 279 | | { |
| 0 | 280 | | ProcessAlternative(plugin); |
| 0 | 281 | | return true; |
| | 282 | | } |
| | 283 | |
|
| 0 | 284 | | return false; |
| | 285 | | } |
| | 286 | |
|
| | 287 | | /// <summary> |
| | 288 | | /// Attempts to find the plugin with and id of <paramref name="id"/>. |
| | 289 | | /// </summary> |
| | 290 | | /// <param name="id">The <see cref="Guid"/> of plugin.</param> |
| | 291 | | /// <param name="version">Optional <see cref="Version"/> of the plugin to locate.</param> |
| | 292 | | /// <returns>A <see cref="LocalPlugin"/> if located, or null if not.</returns> |
| | 293 | | public LocalPlugin? GetPlugin(Guid id, Version? version = null) |
| | 294 | | { |
| | 295 | | LocalPlugin? plugin; |
| | 296 | |
|
| 0 | 297 | | if (version is null) |
| | 298 | | { |
| | 299 | | // If no version is given, return the current instance. |
| 0 | 300 | | var plugins = _plugins.Where(p => p.Id.Equals(id)).ToList(); |
| | 301 | |
|
| 0 | 302 | | plugin = plugins.FirstOrDefault(p => p.Instance is not null) ?? plugins.MaxBy(p => p.Version); |
| | 303 | | } |
| | 304 | | else |
| | 305 | | { |
| | 306 | | // Match id and version number. |
| 0 | 307 | | plugin = _plugins.FirstOrDefault(p => p.Id.Equals(id) && p.Version.Equals(version)); |
| | 308 | | } |
| | 309 | |
|
| 0 | 310 | | return plugin; |
| | 311 | | } |
| | 312 | |
|
| | 313 | | /// <summary> |
| | 314 | | /// Enables the plugin, disabling all other versions. |
| | 315 | | /// </summary> |
| | 316 | | /// <param name="plugin">The <see cref="LocalPlugin"/> of the plug to disable.</param> |
| | 317 | | public void EnablePlugin(LocalPlugin plugin) |
| | 318 | | { |
| 0 | 319 | | ArgumentNullException.ThrowIfNull(plugin); |
| | 320 | |
|
| 0 | 321 | | if (ChangePluginState(plugin, PluginStatus.Active)) |
| | 322 | | { |
| | 323 | | // See if there is another version, and if so, supercede it. |
| 0 | 324 | | ProcessAlternative(plugin); |
| | 325 | | } |
| 0 | 326 | | } |
| | 327 | |
|
| | 328 | | /// <summary> |
| | 329 | | /// Disable the plugin. |
| | 330 | | /// </summary> |
| | 331 | | /// <param name="plugin">The <see cref="LocalPlugin"/> of the plug to disable.</param> |
| | 332 | | public void DisablePlugin(LocalPlugin plugin) |
| | 333 | | { |
| 0 | 334 | | ArgumentNullException.ThrowIfNull(plugin); |
| | 335 | |
|
| | 336 | | // Update the manifest on disk |
| 0 | 337 | | if (ChangePluginState(plugin, PluginStatus.Disabled)) |
| | 338 | | { |
| | 339 | | // If there is another version, activate it. |
| 0 | 340 | | ProcessAlternative(plugin); |
| | 341 | | } |
| 0 | 342 | | } |
| | 343 | |
|
| | 344 | | /// <summary> |
| | 345 | | /// Disable the plugin. |
| | 346 | | /// </summary> |
| | 347 | | /// <param name="assembly">The <see cref="Assembly"/> of the plug to disable.</param> |
| | 348 | | public void FailPlugin(Assembly assembly) |
| | 349 | | { |
| | 350 | | // Only save if disabled. |
| 0 | 351 | | ArgumentNullException.ThrowIfNull(assembly); |
| | 352 | |
|
| 0 | 353 | | var plugin = _plugins.FirstOrDefault(p => p.DllFiles.Contains(assembly.Location)); |
| 0 | 354 | | if (plugin is null) |
| | 355 | | { |
| | 356 | | // A plugin's assembly didn't cause this issue, so ignore it. |
| 0 | 357 | | return; |
| | 358 | | } |
| | 359 | |
|
| 0 | 360 | | ChangePluginState(plugin, PluginStatus.Malfunctioned); |
| 0 | 361 | | } |
| | 362 | |
|
| | 363 | | /// <inheritdoc/> |
| | 364 | | public bool SaveManifest(PluginManifest manifest, string path) |
| | 365 | | { |
| | 366 | | try |
| | 367 | | { |
| 14 | 368 | | var data = JsonSerializer.Serialize(manifest, _jsonOptions); |
| 14 | 369 | | File.WriteAllText(Path.Combine(path, MetafileName), data); |
| 14 | 370 | | return true; |
| | 371 | | } |
| 0 | 372 | | catch (ArgumentException e) |
| | 373 | | { |
| 0 | 374 | | _logger.LogWarning(e, "Unable to save plugin manifest due to invalid value. {Path}", path); |
| 0 | 375 | | return false; |
| | 376 | | } |
| 14 | 377 | | } |
| | 378 | |
|
| | 379 | | /// <inheritdoc/> |
| | 380 | | public async Task<bool> PopulateManifest(PackageInfo packageInfo, Version version, string path, PluginStatus sta |
| | 381 | | { |
| | 382 | | var versionInfo = packageInfo.Versions.First(v => v.Version == version.ToString()); |
| | 383 | | var imagePath = string.Empty; |
| | 384 | |
|
| | 385 | | if (!string.IsNullOrEmpty(packageInfo.ImageUrl)) |
| | 386 | | { |
| | 387 | | var url = new Uri(packageInfo.ImageUrl); |
| | 388 | | imagePath = Path.Join(path, url.Segments[^1]); |
| | 389 | |
|
| | 390 | | var fileStream = AsyncFile.OpenWrite(imagePath); |
| | 391 | | Stream? downloadStream = null; |
| | 392 | | try |
| | 393 | | { |
| | 394 | | downloadStream = await HttpClientFactory |
| | 395 | | .CreateClient(NamedClient.Default) |
| | 396 | | .GetStreamAsync(url) |
| | 397 | | .ConfigureAwait(false); |
| | 398 | |
|
| | 399 | | await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false); |
| | 400 | | } |
| | 401 | | catch (HttpRequestException ex) |
| | 402 | | { |
| | 403 | | _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath); |
| | 404 | | imagePath = string.Empty; |
| | 405 | | } |
| | 406 | | finally |
| | 407 | | { |
| | 408 | | await fileStream.DisposeAsync().ConfigureAwait(false); |
| | 409 | | if (downloadStream is not null) |
| | 410 | | { |
| | 411 | | await downloadStream.DisposeAsync().ConfigureAwait(false); |
| | 412 | | } |
| | 413 | | } |
| | 414 | | } |
| | 415 | |
|
| | 416 | | var manifest = new PluginManifest |
| | 417 | | { |
| | 418 | | Category = packageInfo.Category, |
| | 419 | | Changelog = versionInfo.Changelog ?? string.Empty, |
| | 420 | | Description = packageInfo.Description, |
| | 421 | | Id = packageInfo.Id, |
| | 422 | | Name = packageInfo.Name, |
| | 423 | | Overview = packageInfo.Overview, |
| | 424 | | Owner = packageInfo.Owner, |
| | 425 | | TargetAbi = versionInfo.TargetAbi ?? string.Empty, |
| | 426 | | Timestamp = string.IsNullOrEmpty(versionInfo.Timestamp) ? DateTime.MinValue : DateTime.Parse(versionInfo |
| | 427 | | Version = versionInfo.Version, |
| | 428 | | Status = status == PluginStatus.Disabled ? PluginStatus.Disabled : PluginStatus.Active, // Keep disabled |
| | 429 | | AutoUpdate = true, |
| | 430 | | ImagePath = imagePath |
| | 431 | | }; |
| | 432 | |
|
| | 433 | | if (!await ReconcileManifest(manifest, path).ConfigureAwait(false)) |
| | 434 | | { |
| | 435 | | // An error occurred during reconciliation and saving could be undesirable. |
| | 436 | | return false; |
| | 437 | | } |
| | 438 | |
|
| | 439 | | return SaveManifest(manifest, path); |
| | 440 | | } |
| | 441 | |
|
| | 442 | | /// <inheritdoc /> |
| | 443 | | public void Dispose() |
| | 444 | | { |
| 42 | 445 | | foreach (var assemblyLoadContext in _assemblyLoadContexts) |
| | 446 | | { |
| 0 | 447 | | assemblyLoadContext.Unload(); |
| | 448 | | } |
| 21 | 449 | | } |
| | 450 | |
|
| | 451 | | /// <summary> |
| | 452 | | /// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the p |
| | 453 | | /// If no file is found, no reconciliation occurs. |
| | 454 | | /// </summary> |
| | 455 | | /// <param name="manifest">The <see cref="PluginManifest"/> to reconcile against.</param> |
| | 456 | | /// <param name="path">The plugin path.</param> |
| | 457 | | /// <returns>The reconciled <see cref="PluginManifest"/>.</returns> |
| | 458 | | private async Task<bool> ReconcileManifest(PluginManifest manifest, string path) |
| | 459 | | { |
| | 460 | | try |
| | 461 | | { |
| | 462 | | var metafile = Path.Combine(path, MetafileName); |
| | 463 | | if (!File.Exists(metafile)) |
| | 464 | | { |
| | 465 | | _logger.LogInformation("No local manifest exists for plugin {Plugin}. Skipping manifest reconciliati |
| | 466 | | return true; |
| | 467 | | } |
| | 468 | |
|
| | 469 | | using var metaStream = File.OpenRead(metafile); |
| | 470 | | var localManifest = await JsonSerializer.DeserializeAsync<PluginManifest>(metaStream, _jsonOptions).Conf |
| | 471 | | localManifest ??= new PluginManifest(); |
| | 472 | |
|
| | 473 | | if (!Equals(localManifest.Id, manifest.Id)) |
| | 474 | | { |
| | 475 | | _logger.LogError("The manifest ID {LocalUUID} did not match the package info ID {PackageUUID}.", loc |
| | 476 | | manifest.Status = PluginStatus.Malfunctioned; |
| | 477 | | } |
| | 478 | |
|
| | 479 | | if (localManifest.Version != manifest.Version) |
| | 480 | | { |
| | 481 | | // Package information provides the version and is the source of truth. Pre-packages meta.json is as |
| | 482 | | _logger.LogWarning("The version of the local manifest was {LocalVersion}, but {PackageVersion} was e |
| | 483 | | } |
| | 484 | |
|
| | 485 | | // Explicitly mapping properties instead of using reflection is preferred here. |
| | 486 | | manifest.Category = string.IsNullOrEmpty(localManifest.Category) ? manifest.Category : localManifest.Cat |
| | 487 | | manifest.AutoUpdate = localManifest.AutoUpdate; // Preserve whatever is local. Package info does not hav |
| | 488 | | manifest.Changelog = string.IsNullOrEmpty(localManifest.Changelog) ? manifest.Changelog : localManifest. |
| | 489 | | manifest.Description = string.IsNullOrEmpty(localManifest.Description) ? manifest.Description : localMan |
| | 490 | | manifest.Name = string.IsNullOrEmpty(localManifest.Name) ? manifest.Name : localManifest.Name; |
| | 491 | | manifest.Overview = string.IsNullOrEmpty(localManifest.Overview) ? manifest.Overview : localManifest.Ove |
| | 492 | | manifest.Owner = string.IsNullOrEmpty(localManifest.Owner) ? manifest.Owner : localManifest.Owner; |
| | 493 | | manifest.TargetAbi = string.IsNullOrEmpty(localManifest.TargetAbi) ? manifest.TargetAbi : localManifest. |
| | 494 | | manifest.Timestamp = localManifest.Timestamp.Equals(default) ? manifest.Timestamp : localManifest.Timest |
| | 495 | | manifest.ImagePath = string.IsNullOrEmpty(localManifest.ImagePath) ? manifest.ImagePath : localManifest. |
| | 496 | | manifest.Assemblies = localManifest.Assemblies; |
| | 497 | |
|
| | 498 | | return true; |
| | 499 | | } |
| | 500 | | catch (Exception e) |
| | 501 | | { |
| | 502 | | _logger.LogWarning(e, "Unable to reconcile plugin manifest due to an error. {Path}", path); |
| | 503 | | return false; |
| | 504 | | } |
| | 505 | | } |
| | 506 | |
|
| | 507 | | /// <summary> |
| | 508 | | /// Changes a plugin's load status. |
| | 509 | | /// </summary> |
| | 510 | | /// <param name="plugin">The <see cref="LocalPlugin"/> instance.</param> |
| | 511 | | /// <param name="state">The <see cref="PluginStatus"/> of the plugin.</param> |
| | 512 | | /// <returns>Success of the task.</returns> |
| | 513 | | private bool ChangePluginState(LocalPlugin plugin, PluginStatus state) |
| | 514 | | { |
| 9 | 515 | | if (plugin.Manifest.Status == state || string.IsNullOrEmpty(plugin.Path)) |
| | 516 | | { |
| | 517 | | // No need to save as the state hasn't changed. |
| 0 | 518 | | return true; |
| | 519 | | } |
| | 520 | |
|
| 9 | 521 | | plugin.Manifest.Status = state; |
| 9 | 522 | | return SaveManifest(plugin.Manifest, plugin.Path); |
| | 523 | | } |
| | 524 | |
|
| | 525 | | /// <summary> |
| | 526 | | /// Finds the plugin record using the assembly. |
| | 527 | | /// </summary> |
| | 528 | | /// <param name="assembly">The <see cref="Assembly"/> being sought.</param> |
| | 529 | | /// <returns>The matching record, or null if not found.</returns> |
| | 530 | | private LocalPlugin? GetPluginByAssembly(Assembly assembly) |
| | 531 | | { |
| | 532 | | // Find which plugin it is by the path. |
| 147 | 533 | | return _plugins.FirstOrDefault(p => p.DllFiles.Contains(assembly.Location, StringComparer.Ordinal)); |
| | 534 | | } |
| | 535 | |
|
| | 536 | | /// <summary> |
| | 537 | | /// Creates the instance safe. |
| | 538 | | /// </summary> |
| | 539 | | /// <param name="type">The type.</param> |
| | 540 | | /// <returns>System.Object.</returns> |
| | 541 | | private IPlugin? CreatePluginInstance(Type type) |
| | 542 | | { |
| | 543 | | // Find the record for this plugin. |
| 147 | 544 | | var plugin = GetPluginByAssembly(type.Assembly); |
| 147 | 545 | | if (plugin?.Manifest.Status < PluginStatus.Active) |
| | 546 | | { |
| 0 | 547 | | return null; |
| | 548 | | } |
| | 549 | |
|
| | 550 | | try |
| | 551 | | { |
| 147 | 552 | | _logger.LogDebug("Creating instance of {Type}", type); |
| | 553 | | // _appHost.ServiceProvider is already assigned when we create the plugins |
| 147 | 554 | | var instance = (IPlugin)ActivatorUtilities.CreateInstance(_appHost.ServiceProvider!, type); |
| 147 | 555 | | if (plugin is null) |
| | 556 | | { |
| | 557 | | // Create a dummy record for the providers. |
| | 558 | | // TODO: remove this code once all provided have been released as separate plugins. |
| 147 | 559 | | plugin = new LocalPlugin( |
| 147 | 560 | | instance.AssemblyFilePath, |
| 147 | 561 | | true, |
| 147 | 562 | | new PluginManifest |
| 147 | 563 | | { |
| 147 | 564 | | Id = instance.Id, |
| 147 | 565 | | Status = PluginStatus.Active, |
| 147 | 566 | | Name = instance.Name, |
| 147 | 567 | | Version = instance.Version.ToString() |
| 147 | 568 | | }) |
| 147 | 569 | | { |
| 147 | 570 | | Instance = instance |
| 147 | 571 | | }; |
| | 572 | |
|
| 147 | 573 | | _plugins.Add(plugin); |
| | 574 | |
|
| 147 | 575 | | plugin.Manifest.Status = PluginStatus.Active; |
| | 576 | | } |
| | 577 | | else |
| | 578 | | { |
| 0 | 579 | | plugin.Instance = instance; |
| 0 | 580 | | var manifest = plugin.Manifest; |
| 0 | 581 | | var pluginStr = instance.Version.ToString(); |
| 0 | 582 | | bool changed = false; |
| 0 | 583 | | if (string.Equals(manifest.Version, pluginStr, StringComparison.Ordinal) |
| 0 | 584 | | || !manifest.Id.Equals(instance.Id)) |
| | 585 | | { |
| | 586 | | // If a plugin without a manifest failed to load due to an external issue (eg config), |
| | 587 | | // this updates the manifest to the actual plugin values. |
| 0 | 588 | | manifest.Version = pluginStr; |
| 0 | 589 | | manifest.Name = plugin.Instance.Name; |
| 0 | 590 | | manifest.Description = plugin.Instance.Description; |
| 0 | 591 | | manifest.Id = plugin.Instance.Id; |
| 0 | 592 | | changed = true; |
| | 593 | | } |
| | 594 | |
|
| 0 | 595 | | changed = changed || manifest.Status != PluginStatus.Active; |
| 0 | 596 | | manifest.Status = PluginStatus.Active; |
| | 597 | |
|
| 0 | 598 | | if (changed) |
| | 599 | | { |
| 0 | 600 | | SaveManifest(manifest, plugin.Path); |
| | 601 | | } |
| | 602 | | } |
| | 603 | |
|
| 147 | 604 | | _logger.LogInformation("Loaded plugin: {PluginName} {PluginVersion}", plugin.Name, plugin.Version); |
| | 605 | |
|
| 147 | 606 | | return instance; |
| | 607 | | } |
| | 608 | | #pragma warning disable CA1031 // Do not catch general exception types |
| 0 | 609 | | catch (Exception ex) |
| | 610 | | #pragma warning restore CA1031 // Do not catch general exception types |
| | 611 | | { |
| 0 | 612 | | _logger.LogError(ex, "Error creating {Type}", type.FullName); |
| 0 | 613 | | if (plugin is not null) |
| | 614 | | { |
| 0 | 615 | | if (ChangePluginState(plugin, PluginStatus.Malfunctioned)) |
| | 616 | | { |
| 0 | 617 | | _logger.LogInformation("Plugin {Path} has been disabled.", plugin.Path); |
| 0 | 618 | | return null; |
| | 619 | | } |
| | 620 | | } |
| | 621 | |
|
| 0 | 622 | | _logger.LogDebug("Unable to auto-disable."); |
| 0 | 623 | | return null; |
| | 624 | | } |
| 147 | 625 | | } |
| | 626 | |
|
| | 627 | | private void UpdatePluginSupersededStatus(LocalPlugin plugin) |
| | 628 | | { |
| 0 | 629 | | if (plugin.Manifest.Status != PluginStatus.Superseded) |
| | 630 | | { |
| 0 | 631 | | return; |
| | 632 | | } |
| | 633 | |
|
| 0 | 634 | | var predecessor = _plugins.OrderByDescending(p => p.Version) |
| 0 | 635 | | .FirstOrDefault(p => p.Id.Equals(plugin.Id) && p.IsEnabledAndSupported && p.Version != plugin.Version); |
| 0 | 636 | | if (predecessor is not null) |
| | 637 | | { |
| 0 | 638 | | return; |
| | 639 | | } |
| | 640 | |
|
| 0 | 641 | | plugin.Manifest.Status = PluginStatus.Active; |
| 0 | 642 | | } |
| | 643 | |
|
| | 644 | | /// <summary> |
| | 645 | | /// Attempts to delete a plugin. |
| | 646 | | /// </summary> |
| | 647 | | /// <param name="plugin">A <see cref="LocalPlugin"/> instance to delete.</param> |
| | 648 | | /// <returns>True if successful.</returns> |
| | 649 | | private bool DeletePlugin(LocalPlugin plugin) |
| | 650 | | { |
| | 651 | | // Attempt a cleanup of old folders. |
| | 652 | | try |
| | 653 | | { |
| 0 | 654 | | Directory.Delete(plugin.Path, true); |
| 0 | 655 | | _logger.LogDebug("Deleted {Path}", plugin.Path); |
| 0 | 656 | | } |
| | 657 | | #pragma warning disable CA1031 // Do not catch general exception types |
| 0 | 658 | | catch |
| | 659 | | #pragma warning restore CA1031 // Do not catch general exception types |
| | 660 | | { |
| 0 | 661 | | return false; |
| | 662 | | } |
| | 663 | |
|
| 0 | 664 | | return _plugins.Remove(plugin); |
| 0 | 665 | | } |
| | 666 | |
|
| | 667 | | internal LocalPlugin LoadManifest(string dir) |
| | 668 | | { |
| | 669 | | Version? version; |
| 16 | 670 | | PluginManifest? manifest = null; |
| 16 | 671 | | var metafile = Path.Combine(dir, MetafileName); |
| 16 | 672 | | if (File.Exists(metafile)) |
| | 673 | | { |
| | 674 | | // Only path where this stays null is when File.ReadAllBytes throws an IOException |
| 16 | 675 | | byte[] data = null!; |
| | 676 | | try |
| | 677 | | { |
| 16 | 678 | | data = File.ReadAllBytes(metafile); |
| 16 | 679 | | manifest = JsonSerializer.Deserialize<PluginManifest>(data, _jsonOptions); |
| 16 | 680 | | } |
| 0 | 681 | | catch (IOException ex) |
| | 682 | | { |
| 0 | 683 | | _logger.LogError(ex, "Error reading file {Path}.", dir); |
| 0 | 684 | | } |
| 0 | 685 | | catch (JsonException ex) |
| | 686 | | { |
| 0 | 687 | | _logger.LogError(ex, "Error deserializing {Json}.", Encoding.UTF8.GetString(data)); |
| 0 | 688 | | } |
| | 689 | |
|
| 16 | 690 | | if (manifest is not null) |
| | 691 | | { |
| 16 | 692 | | if (!Version.TryParse(manifest.TargetAbi, out var targetAbi)) |
| | 693 | | { |
| 16 | 694 | | targetAbi = _minimumVersion; |
| | 695 | | } |
| | 696 | |
|
| 16 | 697 | | if (!Version.TryParse(manifest.Version, out version)) |
| | 698 | | { |
| 12 | 699 | | manifest.Version = _minimumVersion.ToString(); |
| | 700 | | } |
| | 701 | |
|
| 16 | 702 | | return new LocalPlugin(dir, _appVersion >= targetAbi, manifest); |
| | 703 | | } |
| | 704 | | } |
| | 705 | |
|
| | 706 | | // No metafile, so lets see if the folder is versioned. |
| | 707 | | // TODO: Phase this support out in future versions. |
| 0 | 708 | | metafile = dir.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries)[^1]; |
| 0 | 709 | | int versionIndex = dir.LastIndexOf('_'); |
| 0 | 710 | | if (versionIndex != -1) |
| | 711 | | { |
| | 712 | | // Get the version number from the filename if possible. |
| 0 | 713 | | metafile = Path.GetFileName(dir[..versionIndex]); |
| 0 | 714 | | version = Version.TryParse(dir.AsSpan()[(versionIndex + 1)..], out Version? parsedVersion) ? parsedVersi |
| | 715 | | } |
| | 716 | | else |
| | 717 | | { |
| | 718 | | // Un-versioned folder - Add it under the path name and version it suitable for this instance. |
| 0 | 719 | | version = _appVersion; |
| | 720 | | } |
| | 721 | |
|
| | 722 | | // Auto-create a plugin manifest, so we can disable it, if it fails to load. |
| 0 | 723 | | manifest = new PluginManifest |
| 0 | 724 | | { |
| 0 | 725 | | Status = PluginStatus.Active, |
| 0 | 726 | | Name = metafile, |
| 0 | 727 | | AutoUpdate = false, |
| 0 | 728 | | Id = metafile.GetMD5(), |
| 0 | 729 | | TargetAbi = _appVersion.ToString(), |
| 0 | 730 | | Version = version.ToString() |
| 0 | 731 | | }; |
| | 732 | |
|
| 0 | 733 | | return new LocalPlugin(dir, true, manifest); |
| | 734 | | } |
| | 735 | |
|
| | 736 | | /// <summary> |
| | 737 | | /// Gets the list of local plugins. |
| | 738 | | /// </summary> |
| | 739 | | /// <returns>Enumerable of local plugins.</returns> |
| | 740 | | private IEnumerable<LocalPlugin> DiscoverPlugins() |
| | 741 | | { |
| 15 | 742 | | var versions = new List<LocalPlugin>(); |
| | 743 | |
|
| 15 | 744 | | if (!Directory.Exists(_pluginsPath)) |
| | 745 | | { |
| | 746 | | // Plugin path doesn't exist, don't try to enumerate sub-folders. |
| 0 | 747 | | return Enumerable.Empty<LocalPlugin>(); |
| | 748 | | } |
| | 749 | |
|
| 15 | 750 | | var directories = Directory.EnumerateDirectories(_pluginsPath, "*.*", SearchOption.TopDirectoryOnly); |
| 60 | 751 | | foreach (var dir in directories) |
| | 752 | | { |
| 15 | 753 | | versions.Add(LoadManifest(dir)); |
| | 754 | | } |
| | 755 | |
|
| 15 | 756 | | string lastName = string.Empty; |
| 15 | 757 | | versions.Sort(LocalPlugin.Compare); |
| | 758 | | // Traverse backwards through the list. |
| | 759 | | // The first item will be the latest version. |
| 60 | 760 | | for (int x = versions.Count - 1; x >= 0; x--) |
| | 761 | | { |
| 15 | 762 | | var entry = versions[x]; |
| 15 | 763 | | if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase)) |
| | 764 | | { |
| 12 | 765 | | if (!TryGetPluginDlls(entry, out var allowedDlls)) |
| | 766 | | { |
| 9 | 767 | | _logger.LogError("One or more assembly paths was invalid. Marking plugin {Plugin} as \"Malfuncti |
| 9 | 768 | | ChangePluginState(entry, PluginStatus.Malfunctioned); |
| 9 | 769 | | continue; |
| | 770 | | } |
| | 771 | |
|
| 3 | 772 | | entry.DllFiles = allowedDlls; |
| | 773 | |
|
| 3 | 774 | | if (entry.IsEnabledAndSupported) |
| | 775 | | { |
| 3 | 776 | | lastName = entry.Name; |
| 3 | 777 | | continue; |
| | 778 | | } |
| | 779 | | } |
| | 780 | |
|
| 3 | 781 | | if (string.IsNullOrEmpty(lastName)) |
| | 782 | | { |
| | 783 | | continue; |
| | 784 | | } |
| | 785 | |
|
| 0 | 786 | | var cleaned = false; |
| 0 | 787 | | var path = entry.Path; |
| | 788 | | // Attempt a cleanup of old folders. |
| | 789 | | try |
| | 790 | | { |
| 0 | 791 | | _logger.LogDebug("Deleting {Path}", path); |
| 0 | 792 | | Directory.Delete(path, true); |
| 0 | 793 | | cleaned = true; |
| 0 | 794 | | } |
| | 795 | | #pragma warning disable CA1031 // Do not catch general exception types |
| 0 | 796 | | catch (Exception e) |
| | 797 | | #pragma warning restore CA1031 // Do not catch general exception types |
| | 798 | | { |
| 0 | 799 | | _logger.LogWarning(e, "Unable to delete {Path}", path); |
| 0 | 800 | | } |
| | 801 | |
|
| 0 | 802 | | if (cleaned) |
| | 803 | | { |
| 0 | 804 | | versions.RemoveAt(x); |
| | 805 | | } |
| | 806 | | else |
| | 807 | | { |
| 0 | 808 | | ChangePluginState(entry, PluginStatus.Deleted); |
| | 809 | | } |
| | 810 | | } |
| | 811 | |
|
| | 812 | | // Only want plugin folders which have files. |
| 15 | 813 | | return versions.Where(p => p.DllFiles.Count != 0); |
| | 814 | | } |
| | 815 | |
|
| | 816 | | /// <summary> |
| | 817 | | /// Attempts to retrieve valid DLLs from the plugin path. This method will consider the assembly whitelist |
| | 818 | | /// from the manifest. |
| | 819 | | /// </summary> |
| | 820 | | /// <remarks> |
| | 821 | | /// Loading DLLs from externally supplied paths introduces a path traversal risk. This method |
| | 822 | | /// uses a safelisting tactic of considering DLLs from the plugin directory and only using |
| | 823 | | /// the plugin's canonicalized assembly whitelist for comparison. See |
| | 824 | | /// <see href="https://owasp.org/www-community/attacks/Path_Traversal"/> for more details. |
| | 825 | | /// </remarks> |
| | 826 | | /// <param name="plugin">The plugin.</param> |
| | 827 | | /// <param name="whitelistedDlls">The whitelisted DLLs. If the method returns <see langword="false"/>, this will |
| | 828 | | /// <returns> |
| | 829 | | /// <see langword="true"/> if all assemblies listed in the manifest were available in the plugin directory. |
| | 830 | | /// <see langword="false"/> if any assemblies were invalid or missing from the plugin directory. |
| | 831 | | /// </returns> |
| | 832 | | /// <exception cref="ArgumentNullException">If the <see cref="LocalPlugin"/> is null.</exception> |
| | 833 | | private bool TryGetPluginDlls(LocalPlugin plugin, out IReadOnlyList<string> whitelistedDlls) |
| | 834 | | { |
| 12 | 835 | | ArgumentNullException.ThrowIfNull(plugin); |
| | 836 | |
|
| 12 | 837 | | IReadOnlyList<string> pluginDlls = Directory.GetFiles(plugin.Path, "*.dll", SearchOption.AllDirectories); |
| | 838 | |
|
| 12 | 839 | | whitelistedDlls = Array.Empty<string>(); |
| 12 | 840 | | if (pluginDlls.Count > 0 && plugin.Manifest.Assemblies.Count > 0) |
| | 841 | | { |
| 12 | 842 | | _logger.LogInformation("Registering whitelisted assemblies for plugin \"{Plugin}\"...", plugin.Name); |
| | 843 | |
|
| 12 | 844 | | var canonicalizedPaths = new List<string>(); |
| 45 | 845 | | foreach (var path in plugin.Manifest.Assemblies) |
| | 846 | | { |
| 12 | 847 | | var canonicalized = Path.Combine(plugin.Path, path).Canonicalize(); |
| | 848 | |
|
| | 849 | | // Ensure we stay in the plugin directory. |
| 12 | 850 | | if (!canonicalized.StartsWith(plugin.Path.NormalizePath(), StringComparison.Ordinal)) |
| | 851 | | { |
| 3 | 852 | | _logger.LogError("Assembly path {Path} is not inside the plugin directory.", path); |
| 3 | 853 | | return false; |
| | 854 | | } |
| | 855 | |
|
| 9 | 856 | | canonicalizedPaths.Add(canonicalized); |
| | 857 | | } |
| | 858 | |
|
| 9 | 859 | | var intersected = pluginDlls.Intersect(canonicalizedPaths).ToList(); |
| | 860 | |
|
| 9 | 861 | | if (intersected.Count != canonicalizedPaths.Count) |
| | 862 | | { |
| 6 | 863 | | _logger.LogError("Plugin {Plugin} contained assembly paths that were not found in the directory.", p |
| 6 | 864 | | return false; |
| | 865 | | } |
| | 866 | |
|
| 3 | 867 | | whitelistedDlls = intersected; |
| | 868 | | } |
| | 869 | | else |
| | 870 | | { |
| | 871 | | // No whitelist, default to loading all DLLs in plugin directory. |
| 0 | 872 | | whitelistedDlls = pluginDlls; |
| | 873 | | } |
| | 874 | |
|
| 3 | 875 | | return true; |
| 3 | 876 | | } |
| | 877 | |
|
| | 878 | | /// <summary> |
| | 879 | | /// Changes the status of the other versions of the plugin to "Superseded". |
| | 880 | | /// </summary> |
| | 881 | | /// <param name="plugin">The <see cref="LocalPlugin"/> that's master.</param> |
| | 882 | | private void ProcessAlternative(LocalPlugin plugin) |
| | 883 | | { |
| | 884 | | // Detect whether there is another version of this plugin that needs disabling. |
| 0 | 885 | | var previousVersion = _plugins.OrderByDescending(p => p.Version) |
| 0 | 886 | | .FirstOrDefault( |
| 0 | 887 | | p => p.Id.Equals(plugin.Id) |
| 0 | 888 | | && p.IsEnabledAndSupported |
| 0 | 889 | | && p.Version != plugin.Version); |
| | 890 | |
|
| 0 | 891 | | if (previousVersion is null) |
| | 892 | | { |
| | 893 | | // This value is memory only - so that the web will show restart required. |
| 0 | 894 | | plugin.Manifest.Status = PluginStatus.Restart; |
| 0 | 895 | | plugin.Manifest.AutoUpdate = false; |
| 0 | 896 | | return; |
| | 897 | | } |
| | 898 | |
|
| 0 | 899 | | if (plugin.Manifest.Status == PluginStatus.Active && !ChangePluginState(previousVersion, PluginStatus.Supers |
| | 900 | | { |
| 0 | 901 | | _logger.LogError("Unable to enable version {Version} of {Name}", previousVersion.Version, previousVersio |
| | 902 | | } |
| 0 | 903 | | else if (plugin.Manifest.Status == PluginStatus.Superseded && !ChangePluginState(previousVersion, PluginStat |
| | 904 | | { |
| 0 | 905 | | _logger.LogError("Unable to supercede version {Version} of {Name}", previousVersion.Version, previousVer |
| | 906 | | } |
| | 907 | |
|
| | 908 | | // This value is memory only - so that the web will show restart required. |
| 0 | 909 | | plugin.Manifest.Status = PluginStatus.Restart; |
| 0 | 910 | | plugin.Manifest.AutoUpdate = false; |
| 0 | 911 | | } |
| | 912 | | } |
| | 913 | | } |