diff --git a/Directory.Packages.props b/Directory.Packages.props index f1373d3..dfcf1d7 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -8,6 +8,7 @@ + diff --git a/SharpSite.sln b/SharpSite.sln index 9782609..9b8ed10 100644 --- a/SharpSite.sln +++ b/SharpSite.sln @@ -60,6 +60,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpSite.PluginPacker", "s EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2. Tools", "2. Tools", "{78F974E0-8074-0543-93D5-DC2AAC8BF3DF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SharpSite.Plugins.FileStorage.AzureBlobStorage", "plugins\SharpSite.Plugins.FileStorage.AzureBlobStorage\SharpSite.Plugins.FileStorage.AzureBlobStorage.csproj", "{45592FB3-E49B-23F5-D56C-2125498FA6E8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -134,6 +136,10 @@ Global {677B59E7-C4BA-4024-84D7-78CE6985F3F5}.Debug|Any CPU.Build.0 = Debug|Any CPU {677B59E7-C4BA-4024-84D7-78CE6985F3F5}.Release|Any CPU.ActiveCfg = Release|Any CPU {677B59E7-C4BA-4024-84D7-78CE6985F3F5}.Release|Any CPU.Build.0 = Release|Any CPU + {45592FB3-E49B-23F5-D56C-2125498FA6E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {45592FB3-E49B-23F5-D56C-2125498FA6E8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {45592FB3-E49B-23F5-D56C-2125498FA6E8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {45592FB3-E49B-23F5-D56C-2125498FA6E8}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -156,6 +162,7 @@ Global {EFCFB571-6B0C-35CD-6664-160CA5B39244} = {8779454A-1F9C-4705-8EE0-5980C6B9C2A5} {6B629CEE-5AAC-4885-89C6-7BED9DA7CF2C} = {3266CA51-9816-4037-9715-701EB6C2928A} {677B59E7-C4BA-4024-84D7-78CE6985F3F5} = {78F974E0-8074-0543-93D5-DC2AAC8BF3DF} + {45592FB3-E49B-23F5-D56C-2125498FA6E8} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {62A15C13-360B-4791-89E9-1FDDFE483970} diff --git a/plugins/SharpSite.Plugins.FileStorage.AzureBlobStorage/AzureBlobStorage.cs b/plugins/SharpSite.Plugins.FileStorage.AzureBlobStorage/AzureBlobStorage.cs new file mode 100644 index 0000000..ad36a2a --- /dev/null +++ b/plugins/SharpSite.Plugins.FileStorage.AzureBlobStorage/AzureBlobStorage.cs @@ -0,0 +1,157 @@ +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using SharpSite.Abstractions.Base; +using SharpSite.Abstractions.FileStorage; + +namespace SharpSite.Plugins.FileStorage.AzureBlobStorage; + +[RegisterPlugin(PluginServiceLocatorScope.Singleton, PluginRegisterType.FileStorage)] +public partial class AzureBlobStorage : IHandleFileStorage +{ + private readonly AzureBlobStorageConfigurationSection _configuration; + private readonly BlobServiceClient? _blobServiceClient; + private readonly BlobContainerClient? _containerClient; + + public AzureBlobStorage(AzureBlobStorageConfigurationSection configuration) + { + _configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); + + if (!string.IsNullOrWhiteSpace(_configuration.ConnectionString) && !string.IsNullOrWhiteSpace(_configuration.ContainerName)) + { + _blobServiceClient = new BlobServiceClient(_configuration.ConnectionString); + _containerClient = _blobServiceClient.GetBlobContainerClient(_configuration.ContainerName); + // Do not create container here; defer to OnConfigurationChanged + } + } + + private void EnsureConfigured() + { + if (_blobServiceClient is null || _containerClient is null) + { + throw new InvalidOperationException("Azure Blob Storage plugin is not configured. Please provide a valid connection string and container name in the settings."); + } + } + + public async Task AddFile(FileData file) + { + EnsureConfigured(); + ArgumentNullException.ThrowIfNull(file, nameof(file)); + if (file.File is null || file.File.Length == 0) + { + throw new ArgumentException("Missing file", nameof(file)); + } + + file.Metadata.ValidateFileName(); + + var blobClient = _containerClient!.GetBlobClient(file.Metadata.FileName); + + // Set content type if provided + var uploadOptions = new BlobUploadOptions(); + if (!string.IsNullOrWhiteSpace(file.Metadata.ContentType)) + { + uploadOptions.HttpHeaders = new BlobHttpHeaders + { + ContentType = file.Metadata.ContentType + }; + } + + // Reset stream position to beginning + file.File.Position = 0; + await blobClient.UploadAsync(file.File, uploadOptions, cancellationToken: default); + return file.Metadata.FileName; + } + + public async Task GetFile(string filename) + { + EnsureConfigured(); + ArgumentException.ThrowIfNullOrWhiteSpace(filename, nameof(filename)); + + var blobClient = _containerClient!.GetBlobClient(filename); + + // Check if blob exists + var exists = await blobClient.ExistsAsync(); + if (!exists) + { + return FileData.Missing; + } + + // Download blob content + var response = await blobClient.DownloadContentAsync(); + var content = response.Value.Content; + + // Get blob properties for metadata + var propertiesResponse = await blobClient.GetPropertiesAsync(); + var blobProperties = propertiesResponse.Value; + + var memoryStream = new MemoryStream(content.ToArray()); + var contentType = blobProperties.ContentType ?? MimeTypesMap.GetMimeType(Path.GetExtension(filename)); + var createDate = blobProperties.CreatedOn; + + var metadata = new FileMetaData(filename, contentType, createDate); + return new FileData(memoryStream, metadata); + } + + public Task> GetFiles(int page, int filesOnPage, out int totalFilesAvailable) + { + EnsureConfigured(); + var blobs = new List(); + + // Get all blobs synchronously (we need to work with the out parameter constraint) + var pageable = _containerClient!.GetBlobs(); + foreach (var blobItem in pageable) + { + blobs.Add(blobItem); + } + + totalFilesAvailable = blobs.Count; + + var pagedBlobs = blobs + .Skip((page - 1) * filesOnPage) + .Take(filesOnPage) + .Select(blob => new FileMetaData( + blob.Name, + blob.Properties.ContentType ?? MimeTypesMap.GetMimeType(Path.GetExtension(blob.Name)), + blob.Properties.CreatedOn ?? DateTimeOffset.UtcNow)); + + return Task.FromResult(pagedBlobs); + } + + public async Task RemoveFile(string filename) + { + EnsureConfigured(); + ArgumentException.ThrowIfNullOrWhiteSpace(filename, nameof(filename)); + + var blobClient = _containerClient!.GetBlobClient(filename); + await blobClient.DeleteIfExistsAsync(); + } + + private class MimeTypesMap + { + internal static string GetMimeType(string fileExtension) + { + // implement a map of file extensions to content types + // this is a very basic implementation and should be replaced with a more comprehensive solution + return fileExtension switch + { + // add basic image types + ".jpg" => "image/jpeg", + ".jpeg" => "image/jpeg", + ".png" => "image/png", + ".gif" => "image/gif", + ".bmp" => "image/bmp", + ".svg" => "image/svg+xml", + ".webp" => "image/webp", + + // add basic text types + ".txt" => "text/plain", + ".html" => "text/html", + ".css" => "text/css", + ".js" => "text/javascript", + ".json" => "application/json", + ".xml" => "application/xml", + + _ => "application/octet-stream" + }; + } + } +} diff --git a/plugins/SharpSite.Plugins.FileStorage.AzureBlobStorage/AzureBlobStorageConfigurationSection.cs b/plugins/SharpSite.Plugins.FileStorage.AzureBlobStorage/AzureBlobStorageConfigurationSection.cs new file mode 100644 index 0000000..ab71525 --- /dev/null +++ b/plugins/SharpSite.Plugins.FileStorage.AzureBlobStorage/AzureBlobStorageConfigurationSection.cs @@ -0,0 +1,188 @@ +using Azure.Storage.Blobs; +using Azure.Storage.Blobs.Models; +using SharpSite.Abstractions.Base; +using System.ComponentModel; +using System.ComponentModel.DataAnnotations; + +namespace SharpSite.Plugins.FileStorage.AzureBlobStorage; + +public class AzureBlobStorageConfigurationSection : ISharpSiteConfigurationSection +{ + public string SectionName { get; } = "Azure Blob Storage"; + + [DisplayName("Connection String"), Required, MaxLength(2000)] + public string ConnectionString { get; set; } = string.Empty; + + [DisplayName("Container Name"), Required, MaxLength(63)] + public string ContainerName { get; set; } = "sharpsite-files"; + + public async Task OnConfigurationChanged(ISharpSiteConfigurationSection? oldConfiguration, IPluginManager pluginManager) + { + // Only proceed if both ConnectionString and ContainerName are set + if (string.IsNullOrWhiteSpace(ConnectionString) || string.IsNullOrWhiteSpace(ContainerName)) + { + // Not enough info to connect, skip container creation + return; + } + + // If this is the first time setting up the configuration, just ensure container exists + if (oldConfiguration is not AzureBlobStorageConfigurationSection oldConfig || string.IsNullOrWhiteSpace(oldConfig.ConnectionString)) + { + await EnsureContainerExists(ConnectionString, ContainerName); + return; + } + + // Check if configuration has changed + bool connectionStringChanged = oldConfig.ConnectionString != ConnectionString; + bool containerNameChanged = oldConfig.ContainerName != ContainerName; + + if (!connectionStringChanged && !containerNameChanged) + { + // No changes, just ensure container exists + await EnsureContainerExists(ConnectionString, ContainerName); + return; + } + + // Configuration has changed, we need to migrate files + try + { + await MigrateFiles(oldConfig, connectionStringChanged, containerNameChanged); + } + catch (Exception ex) + { + // If migration fails, at least ensure the new container exists + await EnsureContainerExists(ConnectionString, ContainerName); + + // Re-throw with more context + throw new InvalidOperationException( + $"Failed to migrate files from old configuration. " + + $"Old: {oldConfig.ConnectionString}/{oldConfig.ContainerName} -> " + + $"New: {ConnectionString}/{ContainerName}. " + + $"Error: {ex.Message}", ex); + } + } + + private static async Task EnsureContainerExists(string connectionString, string containerName) + { + if (string.IsNullOrWhiteSpace(connectionString)) + { + throw new ArgumentException("Connection string cannot be null or empty", nameof(connectionString)); + } + + if (string.IsNullOrWhiteSpace(containerName)) + { + throw new ArgumentException("Container name cannot be null or empty", nameof(containerName)); + } + + try + { + var blobServiceClient = new BlobServiceClient(connectionString); + var containerClient = blobServiceClient.GetBlobContainerClient(containerName); + await containerClient.CreateIfNotExistsAsync(); + } + catch (Exception ex) + { + throw new InvalidOperationException( + $"Failed to create or access container '{containerName}' with the provided connection string. " + + $"Please verify the connection string and container name are correct. Error: {ex.Message}", ex); + } + } + + private async Task MigrateFiles(AzureBlobStorageConfigurationSection oldConfig, bool connectionStringChanged, bool containerNameChanged) + { + try + { + // Set up old and new clients + BlobServiceClient oldBlobServiceClient = new(oldConfig.ConnectionString); + BlobContainerClient oldContainerClient = oldBlobServiceClient.GetBlobContainerClient(oldConfig.ContainerName); + BlobServiceClient newBlobServiceClient = new(ConnectionString); + BlobContainerClient newContainerClient = newBlobServiceClient.GetBlobContainerClient(ContainerName); + + // Ensure new container exists + await newContainerClient.CreateIfNotExistsAsync(); + + // Check if old container exists + var oldContainerExists = await oldContainerClient.ExistsAsync(); + if (!oldContainerExists) + { + // Old container doesn't exist, nothing to migrate + return; + } + + // Get list of all blobs in the old container + var blobsToMigrate = new List(); + await foreach (var blobItem in oldContainerClient.GetBlobsAsync()) + { + blobsToMigrate.Add(blobItem); + } + + if (blobsToMigrate.Count == 0) + { + // No files to migrate + return; + } + + // Migrate each blob + foreach (var blobItem in blobsToMigrate) + { + await MigrateBlob(oldContainerClient, newContainerClient, blobItem.Name); + } + + // If we're moving to a different container/storage account, optionally clean up old files + // Only delete old files if the migration was successful and we're not in the same container + if (connectionStringChanged || containerNameChanged) + { + foreach (var blobItem in blobsToMigrate) + { + var oldBlobClient = oldContainerClient.GetBlobClient(blobItem.Name); + await oldBlobClient.DeleteIfExistsAsync(); + } + } + } + catch (Exception) + { + // Clean up: if migration failed, we should not leave partial state + // The calling method will handle the exception and ensure new container exists + throw; + } + } + + private static async Task MigrateBlob(BlobContainerClient oldContainer, BlobContainerClient newContainer, string blobName) + { + var oldBlobClient = oldContainer.GetBlobClient(blobName); + var newBlobClient = newContainer.GetBlobClient(blobName); + + // Check if source blob exists + var sourceExists = await oldBlobClient.ExistsAsync(); + if (!sourceExists) + { + return; + } + + // Check if destination already exists + var destinationExists = await newBlobClient.ExistsAsync(); + if (destinationExists) + { + // Skip if destination already exists to avoid overwriting + return; + } + + // For simplicity, download and re-upload the blob + // This works across different storage accounts and is more reliable + var downloadResponse = await oldBlobClient.DownloadContentAsync(); + var content = downloadResponse.Value.Content; + + // Get the original properties to preserve content type, etc. + var properties = await oldBlobClient.GetPropertiesAsync(); + var blobHttpHeaders = new BlobHttpHeaders + { + ContentType = properties.Value.ContentType + }; + + // Upload to new location + await newBlobClient.UploadAsync(content, new BlobUploadOptions + { + HttpHeaders = blobHttpHeaders + }); + } +} diff --git a/plugins/SharpSite.Plugins.FileStorage.AzureBlobStorage/SharpSite.Plugins.FileStorage.AzureBlobStorage.csproj b/plugins/SharpSite.Plugins.FileStorage.AzureBlobStorage/SharpSite.Plugins.FileStorage.AzureBlobStorage.csproj new file mode 100644 index 0000000..75f45b5 --- /dev/null +++ b/plugins/SharpSite.Plugins.FileStorage.AzureBlobStorage/SharpSite.Plugins.FileStorage.AzureBlobStorage.csproj @@ -0,0 +1,18 @@ + + + + net9.0 + enable + enable + true + + + + + + + + + + + diff --git a/plugins/SharpSite.Plugins.FileStorage.AzureBlobStorage/manifest.json b/plugins/SharpSite.Plugins.FileStorage.AzureBlobStorage/manifest.json new file mode 100644 index 0000000..0948b41 --- /dev/null +++ b/plugins/SharpSite.Plugins.FileStorage.AzureBlobStorage/manifest.json @@ -0,0 +1,45 @@ +{ + "id": "Blob.Storage", + "DisplayName": "Azure Blob Storage", + "Description": "File\u0027s handled with Azure Blob Storage", + "Version": "0.0.1", + "Icon": "https://place.dog/310/205", + "Published": "2025-07-03", + "SupportedVersions": "v0.7", + "Author": "SharpSite", + "Contact": "SharpSite", + "ContactEmail": "admin@localhost", + "AuthorWebsite": "https://github.com/fritzandfriends/sharpsite", + "Source": "https://github.com/fritzandfriends/sharpsite", + "KnownLicense": "MIT", + "Tags": [ + "azure", + "blob", + "filestorage" + ], + "Features": [ + "FileStorage" + ], + "NuGetDependencies": [ + { + "Package": "Azure.Storage.Blobs", + "Version": "12.19.1" + }, + { + "Package": "Azure.Core", + "Version": "1.44.1" + }, + { + "Package": "Azure.Storage.Common", + "Version": "12.22.0" + }, + { + "Package": "System.Memory.Data", + "Version": "6.0.0" + }, + { + "Package": "System.IO.Hashing", + "Version": "6.0.0" + } + ] +} \ No newline at end of file diff --git a/src/SharpSite.PluginPacker/ManifestPrompter.cs b/src/SharpSite.PluginPacker/ManifestPrompter.cs index 6ae49b4..f87f6d2 100644 --- a/src/SharpSite.PluginPacker/ManifestPrompter.cs +++ b/src/SharpSite.PluginPacker/ManifestPrompter.cs @@ -19,6 +19,19 @@ private static string PromptRequired(string label) return value; } + private static NuGetDependency[]? PromptNuGetDependencies() + { + Console.Write("NuGet Dependencies (package@version, comma separated): "); + var depsStr = (Console.ReadLine() ?? "").Trim(); + if (string.IsNullOrWhiteSpace(depsStr)) return null; + var deps = depsStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + return deps.Select(dep => + { + var parts = dep.Split('@', 2); + return parts.Length == 2 ? new NuGetDependency(parts[0], parts[1]) : null; + }).Where(d => d != null).ToArray()!; + } + public static PluginManifest PromptForManifest() { var id = PromptRequired("Id"); @@ -31,7 +44,6 @@ public static PluginManifest PromptForManifest() var contact = PromptRequired("Contact"); var contactEmail = PromptRequired("ContactEmail"); var authorWebsite = PromptRequired("AuthorWebsite"); - // Optional fields Console.Write("Icon (URL): "); var icon = (Console.ReadLine() ?? "").Trim(); @@ -42,11 +54,11 @@ public static PluginManifest PromptForManifest() Console.Write("Tags (comma separated): "); var tagsStr = (Console.ReadLine() ?? "").Trim(); var tags = tagsStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - Console.Write("Features (comma separated, e.g. Theme,FileStorage): "); var featuresStr = (Console.ReadLine() ?? "").Trim(); var features = featuresStr.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); var featureEnums = features.Length > 0 ? Array.ConvertAll(features, f => Enum.Parse(f, true)) : []; + var nugetDeps = PromptNuGetDependencies(); return new PluginManifest { Id = id, @@ -63,7 +75,8 @@ public static PluginManifest PromptForManifest() Source = string.IsNullOrWhiteSpace(source) ? null : source, KnownLicense = string.IsNullOrWhiteSpace(knownLicense) ? null : knownLicense, Tags = tags.Length > 0 ? tags : null, - Features = featureEnums + Features = featureEnums, + NuGetDependencies = nugetDeps }; } } diff --git a/src/SharpSite.PluginPacker/PluginPackager.cs b/src/SharpSite.PluginPacker/PluginPackager.cs index b08ad36..ddfa6a2 100644 --- a/src/SharpSite.PluginPacker/PluginPackager.cs +++ b/src/SharpSite.PluginPacker/PluginPackager.cs @@ -36,6 +36,9 @@ public static bool PackagePlugin(string inputPath, string outputPath) // Copy DLL to lib/ and rename CopyAndRenameDll(inputPath, tempBuildDir, tempDir, manifest); + // Copy NuGet dependencies to lib/ if specified in manifest + CopyNuGetDependencies(tempBuildDir, tempDir, manifest); + // If Theme, copy .css from wwwroot/ to web/ if (manifest.Features.Contains(PluginFeatures.Theme)) { @@ -43,15 +46,15 @@ public static bool PackagePlugin(string inputPath, string outputPath) } // Copy manifest.json and other required files CopyRequiredFiles(inputPath, tempDir); - // Zip tempDir to outputPath - use proper naming convention ID@VERSION.sspkg - // outputPath is always a directory, generate the filename from manifest - string outFile = Path.Combine(outputPath, $"{manifest.IdVersionToString()}.sspkg"); + // Zip tempDir to outputPath - use proper naming convention ID@VERSION.sspkg + // outputPath is always a directory, generate the filename from manifest + string outFile = Path.Combine(outputPath, $"{manifest.IdVersionToString()}.sspkg"); - // Ensure the output directory exists - if (!Directory.Exists(outputPath)) - { - Directory.CreateDirectory(outputPath); - } + // Ensure the output directory exists + if (!Directory.Exists(outputPath)) + { + Directory.CreateDirectory(outputPath); + } if (File.Exists(outFile)) File.Delete(outFile); ZipFile.CreateFromDirectory(tempDir, outFile); @@ -85,6 +88,32 @@ private static void CopyAndRenameDll(string inputPath, string tempBuildDir, stri File.Copy(dllSource, dllTarget, overwrite: true); } + private static void CopyNuGetDependencies(string tempBuildDir, string tempDir, PluginManifest manifest) + { + if (manifest.NuGetDependencies is null || manifest.NuGetDependencies.Length < 1) + { + return; + } + string libDir = Path.Combine(tempDir, "lib"); + Directory.CreateDirectory(libDir); + foreach (var dep in manifest.NuGetDependencies) + { + // Look for DLLs matching the package name in the build output + var dllPattern = dep.Package + ".dll"; + var files = Directory.GetFiles(tempBuildDir, dllPattern, SearchOption.TopDirectoryOnly); + foreach (var file in files) + { + var dest = Path.Combine(libDir, Path.GetFileName(file)); + if (string.IsNullOrEmpty(dest)) + { + throw new FileNotFoundException($"Dependency DLL not found for package: {dep.Package}"); + } + if (!File.Exists(dest)) + File.Copy(file, dest); + } + } + } + private static void CopyThemeCssFiles(string inputPath, string tempDir) { string webSrc = Path.Combine(inputPath, "wwwroot"); diff --git a/src/SharpSite.PluginPacker/Program.cs b/src/SharpSite.PluginPacker/Program.cs index 6e6d78d..0099f0a 100644 --- a/src/SharpSite.PluginPacker/Program.cs +++ b/src/SharpSite.PluginPacker/Program.cs @@ -22,9 +22,9 @@ } // Validate that output path is a directory, not a file -if (File.Exists(outputPath)) +if (Path.HasExtension(outputPath)) { - Console.WriteLine($"Error: Output path '{outputPath}' points to a file. Please specify a directory."); + Console.WriteLine($"Error: Output path '{outputPath}' appears to be a file. Please specify a directory."); return 1; } diff --git a/src/SharpSite.Plugins/PluginAssembly.cs b/src/SharpSite.Plugins/PluginAssembly.cs index cfc8a27..0e45f20 100644 --- a/src/SharpSite.Plugins/PluginAssembly.cs +++ b/src/SharpSite.Plugins/PluginAssembly.cs @@ -1,37 +1,29 @@ -using Microsoft.AspNetCore.Components; -using SharpSite.Abstractions; -using System.Reflection; -using System.Runtime.Loader; +using System.Reflection; namespace SharpSite.Plugins; -public class PluginAssembly +public class PluginAssembly(PluginManifest pluginMainfest, Plugin plugin) { - private readonly Plugin _plugin; - private readonly PluginManifest _pluginMainfest; + private readonly Plugin _plugin = plugin; + private readonly PluginManifest _pluginMainfest = pluginMainfest; private PluginAssemblyLoadContext? _loadContext; + public PluginAssemblyLoadContext? LoadContextInstance => _loadContext; private Assembly? _assembly; public Assembly? Assembly => _assembly; public PluginManifest Manifest => _pluginMainfest; - public PluginAssembly(PluginManifest pluginMainfest, Plugin plugin) + public void LoadContext(string? mainAssemblyPath = null) { - _plugin = plugin; - _pluginMainfest = pluginMainfest; - } - - public void LoadContext() - { - if (_loadContext != null) return; - _loadContext = new PluginAssemblyLoadContext(); + if (_loadContext is not null) return; + _loadContext = new PluginAssemblyLoadContext(mainAssemblyPath); _assembly = _loadContext.Load(_plugin.Bytes); } public void UnloadContext() { - if (_loadContext == null) return; + if (_loadContext is null) return; _loadContext.Unload(); _loadContext = null; GC.Collect(); diff --git a/src/SharpSite.Plugins/PluginAssemblyLoadContext.cs b/src/SharpSite.Plugins/PluginAssemblyLoadContext.cs index 73b6c57..e8482fb 100644 --- a/src/SharpSite.Plugins/PluginAssemblyLoadContext.cs +++ b/src/SharpSite.Plugins/PluginAssemblyLoadContext.cs @@ -5,13 +5,43 @@ namespace SharpSite.Plugins; public class PluginAssemblyLoadContext : AssemblyLoadContext { - public PluginAssemblyLoadContext() : base(isCollectible: true) { } + private readonly AssemblyDependencyResolver? _resolver; + + public PluginAssemblyLoadContext(string? mainAssemblyPath = null) : base(isCollectible: true) + { + if (!string.IsNullOrEmpty(mainAssemblyPath)) + _resolver = new AssemblyDependencyResolver(mainAssemblyPath); + } public Assembly Load(byte[] assemblyData) { - using (var ms = new MemoryStream(assemblyData)) + using var ms = new MemoryStream(assemblyData); + return LoadFromStream(ms); + } + + protected override Assembly? Load(AssemblyName assemblyName) + { + if (_resolver is not null) + { + string? assemblyPath = _resolver.ResolveAssemblyToPath(assemblyName); + if (assemblyPath is not null) + { + return LoadFromAssemblyPath(assemblyPath); + } + } + return null; + } + + protected override IntPtr LoadUnmanagedDll(string unmanagedDllName) + { + if (_resolver is not null) { - return LoadFromStream(ms); + string? libraryPath = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName); + if (libraryPath is not null) + { + return LoadUnmanagedDllFromPath(libraryPath); + } } + return IntPtr.Zero; } } \ No newline at end of file diff --git a/src/SharpSite.Plugins/PluginManifest.cs b/src/SharpSite.Plugins/PluginManifest.cs index 427a25d..05ab094 100644 --- a/src/SharpSite.Plugins/PluginManifest.cs +++ b/src/SharpSite.Plugins/PluginManifest.cs @@ -20,6 +20,7 @@ public class PluginManifest public string? KnownLicense { get; set; } public string[]? Tags { get; set; } public required PluginFeatures[] Features { get; set; } + public NuGetDependency[]? NuGetDependencies { get; set; } public string IdVersionToString() { @@ -34,3 +35,4 @@ public enum PluginFeatures FileStorage } +public record NuGetDependency(string Package, string Version); diff --git a/src/SharpSite.Web/ApplicatonState.cs b/src/SharpSite.Web/ApplicatonState.cs index c4f0a0f..4e86108 100644 --- a/src/SharpSite.Web/ApplicatonState.cs +++ b/src/SharpSite.Web/ApplicatonState.cs @@ -12,8 +12,6 @@ public class ApplicationState : ApplicationStateModel { public record CurrentThemeRecord(string IdVersion); - - public record LocalizationRecord(string? DefaultCulture, string[]? SupportedCultures); [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] @@ -24,7 +22,7 @@ public record LocalizationRecord(string? DefaultCulture, string[]? SupportedCult [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public LocalizationRecord? Localization { get; set; } - public Dictionary ConfigurationSections { get; private set; } = new(); + public Dictionary ConfigurationSections { get; private set; } = []; public event Func? ConfigurationSectionChanged; @@ -54,18 +52,24 @@ public Type? ThemeType /// List of the plugins that are currently loaded. /// [JsonIgnore] - public Dictionary Plugins { get; } = new(); + public Dictionary Plugins { get; } = []; public void AddPlugin(string pluginName, PluginManifest manifest) { - if (!Plugins.ContainsKey(pluginName)) + if (!Plugins.TryAdd(pluginName, manifest)) { - Plugins.Add(pluginName, manifest); + Plugins[pluginName] = manifest; } - else + } + + public PluginManifest? RemovePlugin(string pluginId) + { + if (Plugins.TryGetValue(pluginId, out var manifest)) { - Plugins[pluginName] = manifest; + Plugins.Remove(pluginId); + return manifest; } + return null; } public void SetTheme(PluginManifest manifest) @@ -77,7 +81,7 @@ public void SetTheme(PluginManifest manifest) if (themeType is not null) CurrentTheme = new(manifest.IdVersionToString()); } - private string GetApplicationStateFileContents() + private static string GetApplicationStateFileContents() { // read the applicationState.json file in the root of the plugins folder var appStateFile = Path.Combine("plugins", "applicationState.json"); @@ -119,13 +123,17 @@ public async Task Load(IServiceProvider services, Func? getApplicationSt Initialized = true; // This shouldn't be called while initializing - //if (ConfigurationSectionChanged is not null) - //{ - // foreach (var section in ConfigurationSections) - // { - // ConfigurationSectionChanged.Invoke(this, section.Value); - // } - //} + // If this is not called, settings in applicationState.json are not loaded for plugins. + // Should this be called somewhere else? + if (ConfigurationSectionChanged is not null) + { + // foreach (var section in ConfigurationSections) + // { + // ConfigurationSectionChanged.Invoke(this, section.Value); + // } + var tasks = ConfigurationSections.Select(section => ConfigurationSectionChanged.Invoke(this, section.Value)); + await Task.WhenAll(tasks); + } await PostLoadApplicationState(services); @@ -138,14 +146,10 @@ public async Task SetConfigurationSection(ISharpSiteConfigurationSection section // add a null check for the section argument ArgumentNullException.ThrowIfNull(section, nameof(section)); - if (ConfigurationSections.ContainsKey(section.SectionName)) + if (!ConfigurationSections.TryAdd(section.SectionName, section)) { ConfigurationSections[section.SectionName] = section; } - else - { - ConfigurationSections.Add(section.SectionName, section); - } if (ConfigurationSectionChanged is not null) { diff --git a/src/SharpSite.Web/Components/Admin/AddPlugin.razor b/src/SharpSite.Web/Components/Admin/AddPlugin.razor index 1b9638a..48b474b 100644 --- a/src/SharpSite.Web/Components/Admin/AddPlugin.razor +++ b/src/SharpSite.Web/Components/Admin/AddPlugin.razor @@ -57,7 +57,7 @@ var uploadedFileName = e.File.Name; PluginManager.ValidatePlugin(uploadedFileName); - using var stream = uploadedFile.OpenReadStream(); + using var stream = uploadedFile.OpenReadStream(maxAllowedSize: 1024 * 1026 * 10); // 10 MB. TODO: should this be configurable? var plugin = await Plugin.LoadFromStream(stream, uploadedFileName); PluginManager.HandleUploadedPlugin(plugin); @@ -84,4 +84,3 @@ NavigationManager.NavigateTo("/admin/plugins"); } } - diff --git a/src/SharpSite.Web/Components/Admin/PluginCard.razor b/src/SharpSite.Web/Components/Admin/PluginCard.razor index eb55b51..36340c7 100644 --- a/src/SharpSite.Web/Components/Admin/PluginCard.razor +++ b/src/SharpSite.Web/Components/Admin/PluginCard.razor @@ -1,4 +1,8 @@ -
+@inject PluginManager PluginManager +@inject NavigationManager NavigationManager +@rendermode InteractiveServer + +

@Plugin.DisplayName

@@ -6,20 +10,26 @@
@Localizer[SharedResource.sharpsite_plugin_icon] + alt="@Localizer[SharedResource.sharpsite_plugin_icon]" class="card-img-top" />

@Plugin.Description

@code { - private const string DefaultPluginIcon = "/img/plugin-icon.svg"; [Parameter, EditorRequired] public required PluginManifest Plugin { get; set; } + [Parameter] public string? AdditionalCssClass { get; set; } + + private async Task RemovePluginAsync() + { + await PluginManager.RemovePlugin(Plugin.Id); + NavigationManager.NavigateTo(NavigationManager.Uri, forceLoad: true); + } } diff --git a/src/SharpSite.Web/Components/Admin/PluginList.razor b/src/SharpSite.Web/Components/Admin/PluginList.razor index 770c743..548cbd0 100644 --- a/src/SharpSite.Web/Components/Admin/PluginList.razor +++ b/src/SharpSite.Web/Components/Admin/PluginList.razor @@ -9,9 +9,15 @@ @* Add a button that links to the add plugin page With the text add new plugin *@ +@if (!string.IsNullOrEmpty(ErrorMessage)) +{ +
+ @ErrorMessage +
+} @if (AppState.Plugins.Count == 0) { @@ -21,13 +27,35 @@ else {
- @foreach (var plugin in AppState.Plugins) + @foreach (var plugin in AppState.Plugins.Values) { - + var isFileStorage = plugin.Features is not null && plugin.Features.Any(f => f == PluginFeatures.FileStorage); + }
} @code { + private string ErrorMessage = string.Empty; + protected override void OnInitialized() + { + var fileStoragePlugins = AppState.Plugins.Values + .Where(p => p.Features is not null && p.Features.Any(f => f == PluginFeatures.FileStorage)) + .ToList(); + + if (fileStoragePlugins.Count > 1) + { + ErrorMessage = SharedResource.sharpsite_plugin_filestorage_error_multiple; + } + else if (fileStoragePlugins.Count == 0) + { + ErrorMessage = SharedResource.sharpsite_plugin_filestorage_error_none; + } + else + { + ErrorMessage = string.Empty; + } + } } diff --git a/src/SharpSite.Web/Locales/SharedResource.Designer.cs b/src/SharpSite.Web/Locales/SharedResource.Designer.cs index 301c6db..4d29f61 100644 --- a/src/SharpSite.Web/Locales/SharedResource.Designer.cs +++ b/src/SharpSite.Web/Locales/SharedResource.Designer.cs @@ -528,6 +528,24 @@ internal static string sharpsite_plugin_file { } } + /// + /// Looks up a localized string similar to More than one plugin provides the FileStorage feature. Please ensure only one FileStorage plugin is enabled at a time.. + /// + internal static string sharpsite_plugin_filestorage_error_multiple { + get { + return ResourceManager.GetString("sharpsite_plugin_filestorage_error_multiple", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to No plugin with the FileStorage feature is installed. It is required for file storage functionality.. + /// + internal static string sharpsite_plugin_filestorage_error_none { + get { + return ResourceManager.GetString("sharpsite_plugin_filestorage_error_none", resourceCulture); + } + } + /// /// Looks up a localized string similar to Icon of the plugin. /// diff --git a/src/SharpSite.Web/Locales/SharedResource.bg.resx b/src/SharpSite.Web/Locales/SharedResource.bg.resx index 5aa2f80..a5e478a 100644 --- a/src/SharpSite.Web/Locales/SharedResource.bg.resx +++ b/src/SharpSite.Web/Locales/SharedResource.bg.resx @@ -359,14 +359,6 @@ Това гарантира, че помощните технологии използват правилния език за съдържанието. AI generated translation - - Име на сайта: - AI generated translation - - - Върни се на уебсайта - AI generated translation - Персонализиране на съдържанието на страницата "Не е намерена" @@ -376,6 +368,14 @@ Промени Темата + + Име на сайта: + AI generated translation + + + Върни се на уебсайта + AI generated translation + @@ -391,4 +391,10 @@ + + + + + + \ No newline at end of file diff --git a/src/SharpSite.Web/Locales/SharedResource.ca.resx b/src/SharpSite.Web/Locales/SharedResource.ca.resx index b8f87ff..227287f 100644 --- a/src/SharpSite.Web/Locales/SharedResource.ca.resx +++ b/src/SharpSite.Web/Locales/SharedResource.ca.resx @@ -355,14 +355,6 @@ Això garanteix que les tecnologies d'assistència utilitzin el llenguatge correcte per al contingut. AI generated translation - - Nom del lloc: - AI generated translation - - - Tornar al lloc web - AI generated translation - Personalitza el contingut de Pàgina no trobada. @@ -372,6 +364,14 @@ Canvia el tema + + Nom del lloc: + AI generated translation + + + Tornar al lloc web + AI generated translation + @@ -387,4 +387,10 @@ + + + + + + \ No newline at end of file diff --git a/src/SharpSite.Web/Locales/SharedResource.de.resx b/src/SharpSite.Web/Locales/SharedResource.de.resx index 2948974..6deae95 100644 --- a/src/SharpSite.Web/Locales/SharedResource.de.resx +++ b/src/SharpSite.Web/Locales/SharedResource.de.resx @@ -355,14 +355,6 @@ Dies gewährleistet, dass assistive Technologien die richtige Sprache für den Inhalt verwenden. AI generated translation - - Seitenname: - AI generated translation - - - Zurück zur Website - AI generated translation - Individualisiere Inhalte für die Seite "Nicht gefunden" @@ -372,6 +364,14 @@ Thema ändern + + Seitenname: + AI generated translation + + + Zurück zur Website + AI generated translation + @@ -382,9 +382,15 @@ - + + + + + + + \ No newline at end of file diff --git a/src/SharpSite.Web/Locales/SharedResource.en.resx b/src/SharpSite.Web/Locales/SharedResource.en.resx index b982929..be2106c 100644 --- a/src/SharpSite.Web/Locales/SharedResource.en.resx +++ b/src/SharpSite.Web/Locales/SharedResource.en.resx @@ -341,6 +341,14 @@ Change Theme Text of the button used to change the theme of the website + + Site Name: + Label on admin pages that allows customization of the website name + + + Return to website + Link text on admin portal that returns the user to the public website + The markdown contains a script tag which will be executed once users load the page. Are you sure you want to proceed? @@ -356,12 +364,10 @@ Plugin '{0}' is already installed. - - Site Name: - Label on admin pages that allows customization of the website name + + More than one plugin provides the FileStorage feature. Please ensure only one FileStorage plugin is enabled at a time. - - Return to website - Link text on admin portal that returns the user to the public website + + No plugin with the FileStorage feature is installed. It is required for file storage functionality. \ No newline at end of file diff --git a/src/SharpSite.Web/Locales/SharedResource.es.resx b/src/SharpSite.Web/Locales/SharedResource.es.resx index 9d01100..0c33f4b 100644 --- a/src/SharpSite.Web/Locales/SharedResource.es.resx +++ b/src/SharpSite.Web/Locales/SharedResource.es.resx @@ -355,14 +355,6 @@ Esto asegura que las tecnologías de asistencia utilicen el idioma correcto para el contenido. AI generated translation - - Nombre del sitio: - AI generated translation - - - Volver al sitio web. - AI generated translation - Personalizar el contenido de la página no encontrada. @@ -372,6 +364,14 @@ Cambiar Tema + + Nombre del sitio: + AI generated translation + + + Volver al sitio web. + AI generated translation + @@ -382,9 +382,15 @@ - + + + + + + + \ No newline at end of file diff --git a/src/SharpSite.Web/Locales/SharedResource.fi.resx b/src/SharpSite.Web/Locales/SharedResource.fi.resx index faca17f..1341a4d 100644 --- a/src/SharpSite.Web/Locales/SharedResource.fi.resx +++ b/src/SharpSite.Web/Locales/SharedResource.fi.resx @@ -337,6 +337,14 @@ Vaihda teemaa + + Sivuston nimi: + AI generated translation + + + Palaa verkkosivustolle + AI generated translation + Markdown sisältää script -tagin, joka ajetaan, kun käyttäjät lataavat sivun. Oletko varma, että haluat jatkaa? @@ -352,12 +360,10 @@ Laajennus '{0}' on jo asennettu. - - Sivuston nimi: - AI generated translation + + Useampi laajennus tarjoaa FileStorage-ominaisuuden. Varmista, että vain yksi FileStorage-laajennus on käytössä kerrallaan. - - Palaa verkkosivustolle - AI generated translation + + Yhtään laajennusta, jossa on FileStorage-ominaisuus, ei ole asennettu. Se vaaditaan tiedostojen tallennustoimintoa varten. \ No newline at end of file diff --git a/src/SharpSite.Web/Locales/SharedResource.fr.resx b/src/SharpSite.Web/Locales/SharedResource.fr.resx index a458fc4..b38b8d0 100644 --- a/src/SharpSite.Web/Locales/SharedResource.fr.resx +++ b/src/SharpSite.Web/Locales/SharedResource.fr.resx @@ -355,14 +355,6 @@ Cela garantit que les technologies d'assistance utilisent la langue correcte pour le contenu. AI generated translation - - Nom du site : - AI generated translation - - - Retour au site web - AI generated translation - Personnaliser le contenu de la page introuvable @@ -372,6 +364,14 @@ Changer de thème + + Nom du site : + AI generated translation + + + Retour au site web + AI generated translation + @@ -382,9 +382,15 @@ - + + + + + + + \ No newline at end of file diff --git a/src/SharpSite.Web/Locales/SharedResource.it.resx b/src/SharpSite.Web/Locales/SharedResource.it.resx index 4330a5d..e235da7 100644 --- a/src/SharpSite.Web/Locales/SharedResource.it.resx +++ b/src/SharpSite.Web/Locales/SharedResource.it.resx @@ -386,14 +386,6 @@ Questo garantisce che le tecnologie assistive utilizzino la lingua corretta per il contenuto. AI generated translation - - Nome del sito: - AI generated translation - - - Torna al sito web. - AI generated translation - Personalizza il contenuto della pagina non trovata. @@ -403,6 +395,14 @@ Cambia tema + + Nome del sito: + AI generated translation + + + Torna al sito web. + AI generated translation + @@ -413,9 +413,15 @@ - + + + + + + + \ No newline at end of file diff --git a/src/SharpSite.Web/Locales/SharedResource.nl.resx b/src/SharpSite.Web/Locales/SharedResource.nl.resx index 81b16ce..c60bbcb 100644 --- a/src/SharpSite.Web/Locales/SharedResource.nl.resx +++ b/src/SharpSite.Web/Locales/SharedResource.nl.resx @@ -355,14 +355,6 @@ Dit zorgt ervoor dat hulpmiddelen voor toegankelijkheid de juiste taal gebruiken voor de inhoud. AI generated translation - - Website Naam: - AI generated translation - - - Terug naar website - AI generated translation - Aanpassen van Pagina Niet Gevonden inhoud. @@ -372,6 +364,14 @@ Verander thema + + Website Naam: + AI generated translation + + + Terug naar website + AI generated translation + @@ -382,9 +382,15 @@ - + + + + + + + \ No newline at end of file diff --git a/src/SharpSite.Web/Locales/SharedResource.pt.resx b/src/SharpSite.Web/Locales/SharedResource.pt.resx index 8301ad4..2c16413 100644 --- a/src/SharpSite.Web/Locales/SharedResource.pt.resx +++ b/src/SharpSite.Web/Locales/SharedResource.pt.resx @@ -355,14 +355,6 @@ Isso garante que as tecnologias assistivas usem o idioma correto para o conteúdo. AI generated translation - - Nome do Site: - AI generated translation - - - Voltar ao site - AI generated translation - Personalizar o conteúdo da página não encontrada. @@ -372,6 +364,14 @@ Alterar Tema + + Nome do Site: + AI generated translation + + + Voltar ao site + AI generated translation + @@ -382,9 +382,15 @@ - + + + + + + + \ No newline at end of file diff --git a/src/SharpSite.Web/Locales/SharedResource.resx b/src/SharpSite.Web/Locales/SharedResource.resx index 0e299a7..d8a9f70 100644 --- a/src/SharpSite.Web/Locales/SharedResource.resx +++ b/src/SharpSite.Web/Locales/SharedResource.resx @@ -359,14 +359,14 @@ Change Theme Text of the button used to change the theme of the website - + Site Name: Label on admin pages that allows customization of the website name - + Return to website Link text on admin portal that returns the user to the public website - + The markdown contains a script tag which will be executed once users load the page. Are you sure you want to proceed? Alert message that is showed when the markdown content for a page contains a script tag @@ -387,4 +387,12 @@ Plugin '{0}' is already installed. Error message to be desplayed when a plugin that already exists, is attempted to be uploaded. + + More than one plugin provides the FileStorage feature. Please ensure only one FileStorage plugin is enabled at a time. + Error message when multiple plugins with FileStorage feature are installed. + + + No plugin with the FileStorage feature is installed. It is required for file storage functionality. + Error message when no plugin with FileStorage features is installed. + \ No newline at end of file diff --git a/src/SharpSite.Web/Locales/SharedResource.sv.resx b/src/SharpSite.Web/Locales/SharedResource.sv.resx index 49aa8fb..c3aaadb 100644 --- a/src/SharpSite.Web/Locales/SharedResource.sv.resx +++ b/src/SharpSite.Web/Locales/SharedResource.sv.resx @@ -400,6 +400,14 @@ Byt tema + + Webbplatsnamn: + AI generated translation + + + Återgå till webbplatsen + AI generated translation + @@ -410,17 +418,15 @@ - + - - Webbplatsnamn: - AI generated translation + + - - Återgå till webbplatsen - AI generated translation + + \ No newline at end of file diff --git a/src/SharpSite.Web/Locales/SharedResource.sw.resx b/src/SharpSite.Web/Locales/SharedResource.sw.resx index 8dd3acf..16d2cf8 100644 --- a/src/SharpSite.Web/Locales/SharedResource.sw.resx +++ b/src/SharpSite.Web/Locales/SharedResource.sw.resx @@ -355,14 +355,6 @@ Hii hufanya teknolojia za msaada kutumia lugha sahihi kwa maudhui. AI generated translation - - Jina la Tovuti: - AI generated translation - - - Rudi kwenye tovuti - AI generated translation - Sawazisha Yaliyopatikana Ukurasa wa Yaliyopatikana maudhui kwa SharpSite ni mfumo wa usimamizi wa yaliyomo wa chanzo wazi uliojengwa na C# na Blazor. @@ -372,6 +364,14 @@ Badili Mandhari + + Jina la Tovuti: + AI generated translation + + + Rudi kwenye tovuti + AI generated translation + @@ -382,9 +382,15 @@ - + + + + + + + \ No newline at end of file diff --git a/src/SharpSite.Web/PluginManager.cs b/src/SharpSite.Web/PluginManager.cs index 0b294df..94428a8 100644 --- a/src/SharpSite.Web/PluginManager.cs +++ b/src/SharpSite.Web/PluginManager.cs @@ -21,6 +21,8 @@ public class PluginManager( private readonly static IServiceCollection _ServiceDescriptors = new ServiceCollection(); private static IServiceProvider? _ServiceProvider; + private readonly Dictionary _pluginAssemblies = []; + public static void Initialize() { Directory.CreateDirectory("plugins"); @@ -57,19 +59,47 @@ public void HandleUploadedPlugin(Plugin plugin) } - private PluginManifest? ReadManifest(string manifestPath) + private static PluginManifest? ReadManifest(string manifestPath) { using var manifestStream = File.OpenRead(manifestPath); return ReadManifest(manifestStream); } - private PluginManifest ReadManifest(Stream manifestStream) + private static readonly JsonSerializerOptions _jsonOpts = new() { Converters = { new JsonStringEnumConverter() } }; + private static PluginManifest ReadManifest(Stream manifestStream) + { + return JsonSerializer.Deserialize(manifestStream, _jsonOpts)!; + } + + private void LoadNuGetDependenciesInContext(PluginManifest manifest, DirectoryInfo pluginLibFolder, PluginAssemblyLoadContext? context) { - var options = new JsonSerializerOptions + if (context is null) { - Converters = { new JsonStringEnumConverter() } - }; - return JsonSerializer.Deserialize(manifestStream, options)!; + throw new ArgumentNullException(nameof(context), $"PluginAssemblyLoadContext cannot be null when loading NuGet dependencies. Plugin '{manifest.Id}'"); + } + if (manifest.NuGetDependencies is null || manifest.NuGetDependencies.Length == 0) + { + return; + } + + foreach (var dep in manifest.NuGetDependencies) + { + var depDll = Path.Combine(pluginLibFolder.FullName, dep.Package + ".dll"); + if (File.Exists(depDll)) + { + try + { + byte[] assemblyBytes = File.ReadAllBytes(depDll); + using var ms = new MemoryStream(assemblyBytes); + context.LoadFromStream(ms); + logger.LogInformation("Loaded dependency (from stream): {depDll}", depDll); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to load dependency: {depDll}", depDll); + } + } + } } public async Task SavePlugin() @@ -81,31 +111,26 @@ public async Task SavePlugin() throw exception; } - FileStream fileStream; DirectoryInfo pluginLibFolder; - ZipArchive archive; - (fileStream, pluginLibFolder, archive) = await ExtractAndInstallPlugin(logger, plugin, Manifest); + (_, pluginLibFolder, _) = await ExtractAndInstallPlugin(logger, plugin, Manifest); - // By convention it is a package_name of (@.(sspkg|.dll) var key = Manifest.Id; - // if there is a DLL in the pluginLibFolder with the same base name as the plugin file, reflection load that DLL var pluginDll = Directory.GetFiles(pluginLibFolder.FullName, $"{key}*.dll").FirstOrDefault(); - if (!string.IsNullOrEmpty(pluginDll)) - { - // Soft load of package without taking ownership for the process .dll - using var pluginAssemblyFileStream = File.OpenRead(pluginDll); - plugin = await Plugin.LoadFromStream(pluginAssemblyFileStream, key); - var pluginAssembly = new PluginAssembly(Manifest, plugin); - pluginAssemblyManager.AddAssembly(pluginAssembly); - await RegisterWithServiceLocator(pluginAssembly); - await AppState.Save(); - logger.LogInformation("Assembly {AssemblyName} loaded at runtime.", pluginDll); + if (string.IsNullOrEmpty(pluginDll)) + throw new Exception($"Plugin DLL not found for {key}"); - } + using var pluginAssemblyFileStream = File.OpenRead(pluginDll); + plugin = await Plugin.LoadFromStream(pluginAssemblyFileStream, key); + var pluginAssembly = new PluginAssembly(Manifest, plugin); + pluginAssembly.LoadContext(pluginDll); + LoadNuGetDependenciesInContext(Manifest, pluginLibFolder, pluginAssembly.LoadContextInstance); + _pluginAssemblies[key] = pluginAssembly; + pluginAssemblyManager.AddAssembly(pluginAssembly); + await RegisterWithServiceLocator(pluginAssembly); + logger.LogInformation("Assembly {AssemblyName} loaded at runtime.", pluginDll); - // Add plugin to the list of plugins in ApplicationState AppState.AddPlugin(Manifest.Id, Manifest); logger.LogInformation("Plugin {PluginName} loaded at runtime.", Manifest); @@ -113,6 +138,7 @@ public async Task SavePlugin() { AppState.SetTheme(Manifest); } + await AppState.Save(); logger.LogInformation("Plugin {PluginName} saved and registered.", plugin.Name); @@ -145,7 +171,7 @@ public async Task LoadPluginsAtStartup() foreach (var pluginFolder in Directory.GetDirectories("plugins")) { var pluginName = Path.GetFileName(pluginFolder); - if (pluginName.StartsWith("_")) continue; + if (pluginName.StartsWith('_')) continue; var manifestPath = Path.Combine(pluginFolder, "manifest.json"); if (!File.Exists(manifestPath)) continue; @@ -159,15 +185,17 @@ public async Task LoadPluginsAtStartup() var pluginDll = Directory.GetFiles(pluginFolder, $"{key}*.dll").FirstOrDefault(); if (!string.IsNullOrEmpty(pluginDll)) { - // Soft load of package without taking ownership for the process .dll using var pluginAssemblyFileStream = File.OpenRead(pluginDll); plugin = await Plugin.LoadFromStream(pluginAssemblyFileStream, key); var pluginAssembly = new PluginAssembly(manifest, plugin); + pluginAssembly.LoadContext(pluginDll); + LoadNuGetDependenciesInContext(manifest, new DirectoryInfo(pluginFolder), pluginAssembly.LoadContextInstance); + _pluginAssemblies[key] = pluginAssembly; + pluginAssemblyManager.AddAssembly(pluginAssembly); logger.LogInformation("Assembly {AssemblyName} loaded at startup.", pluginDll); await RegisterWithServiceLocator(pluginAssembly); - } AppState.AddPlugin(key, manifest!); @@ -179,6 +207,117 @@ public async Task LoadPluginsAtStartup() } + // Remove a plugin by its Id (uninstalls and unregisters services) + public async Task RemovePlugin(string pluginId) + { + + // Remove from AppState and get manifest + var manifest = AppState.RemovePlugin(pluginId); + if (manifest is null) + { + logger.LogInformation("Unable to remove plugin {pluginId}. Plugin not found.", pluginId); + return; + } + + // Remove from service descriptors BEFORE unloading assembly + var descriptorsToRemove = new List(); + + // Remove services registered by the plugin assembly + if (_pluginAssemblies.TryGetValue(pluginId, out var assembly)) + { + var pluginTypes = assembly.Assembly?.GetTypes() ?? []; + + // Find all service descriptors that use types from this plugin assembly + foreach (var descriptor in _ServiceDescriptors.ToList()) + { + bool shouldRemove = false; + + // Check if implementation type is from this plugin + if (descriptor.ImplementationType is not null && pluginTypes.Contains(descriptor.ImplementationType)) + { + shouldRemove = true; + } + // Check if implementation instance type is from this plugin + else if (descriptor.ImplementationInstance is not null && pluginTypes.Contains(descriptor.ImplementationInstance.GetType())) + { + shouldRemove = true; + } + + if (shouldRemove) + { + descriptorsToRemove.Add(descriptor); + } + } + } + + logger.LogInformation("Removing {count} service descriptors for plugin {pluginId}.", descriptorsToRemove.Count, pluginId); + foreach (var desc in descriptorsToRemove) + { + _ServiceDescriptors.Remove(desc); + } + + // Now unload plugin AssemblyLoadContext + if (assembly is not null) + { + // Remove configuration section. + var configSectionType = assembly.Assembly?.GetTypes() + .FirstOrDefault(t => typeof(ISharpSiteConfigurationSection).IsAssignableFrom(t) && !t.IsAbstract); + + if (configSectionType is not null) + { + var sectionInstance = (ISharpSiteConfigurationSection)Activator.CreateInstance(configSectionType)!; + AppState.ConfigurationSections.Remove(sectionInstance.SectionName); + } + + _pluginAssemblies.Remove(pluginId); + assembly.UnloadContext(); + } + + // Remove plugin files/folder + var pluginFolder = Path.Combine("plugins", manifest.IdVersionToString()); + if (Directory.Exists(pluginFolder)) + { + try + { + Directory.Delete(pluginFolder, true); + logger.LogInformation("Deleted plugin files at {pluginFolder}", pluginFolder); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to delete plugin at {pluginFolder}", pluginFolder); + } + } + else + { + logger.LogWarning("Plugin folder does not exist at {pluginFolder}", pluginFolder); + } + + // If this is a Theme plugin, remove related wwwroot folder in /plugins/_wwwroot/ + if (manifest.Features is not null && manifest.Features.Contains(PluginFeatures.Theme)) + { + var wwwrootThemeFolder = Path.Combine("plugins", "_wwwroot", manifest.IdVersionToString()); + if (Directory.Exists(wwwrootThemeFolder)) + { + try + { + Directory.Delete(wwwrootThemeFolder, true); + logger.LogInformation("Deleted theme wwwroot folder: {wwwrootThemeFolder}", wwwrootThemeFolder); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to delete theme wwwroot folder: {wwwrootThemeFolder}", wwwrootThemeFolder); + } + } + else + { + logger.LogWarning("Theme wwwroot folder not found: {wwwrootThemeFolder}", wwwrootThemeFolder); + } + } + + _ServiceProvider = _ServiceDescriptors.BuildServiceProvider(); + await AppState.Save(); + + } private async Task RegisterWithServiceLocator(PluginAssembly pluginAssembly) { @@ -204,24 +343,22 @@ private async Task RegisterWithServiceLocator(PluginAssembly pluginAssembly) _ => null }; - var serviceDescriptor = new ServiceDescriptor(knownInterface!, type, pluginAttribute.Scope switch + if (knownInterface is not null) { - PluginServiceLocatorScope.Singleton => ServiceLifetime.Singleton, - PluginServiceLocatorScope.Scoped => ServiceLifetime.Scoped, - _ => ServiceLifetime.Transient - }); - _ServiceDescriptors.Add(serviceDescriptor); + _ServiceDescriptors.Add(new ServiceDescriptor(knownInterface, type, pluginAttribute.Scope switch + { + PluginServiceLocatorScope.Singleton => ServiceLifetime.Singleton, + PluginServiceLocatorScope.Scoped => ServiceLifetime.Scoped, + _ => ServiceLifetime.Transient + })); + } } else if (typeof(ISharpSiteConfigurationSection).IsAssignableFrom(type)) { var configurationSection = (ISharpSiteConfigurationSection)Activator.CreateInstance(type)!; // we should only add the configuration section if it is not already present - if (!AppState.ConfigurationSections.ContainsKey(configurationSection.SectionName)) - { - AppState.ConfigurationSections.Add(configurationSection.SectionName, configurationSection); - } - + AppState.ConfigurationSections.TryAdd(configurationSection.SectionName, configurationSection); _ServiceDescriptors.Add(new ServiceDescriptor(type, configurationSection)); if (AppState.Initialized) @@ -297,7 +434,7 @@ public void CleanupCurrentUploadedPlugin() public void ValidatePlugin(string pluginName) { - if (pluginName.StartsWith("_")) + if (pluginName.StartsWith('_')) { var exception = new Exception("Plugin filenames are not allowed to start with an underscore '_'"); logger.LogError(exception, "Invalid plugin filename: {FileName}", pluginName); @@ -432,7 +569,7 @@ private static void EnsurePluginNotInstalled(PluginManifest? manifest, ILogger l public async Task InstallDefaultPlugins() { - + var defaultPluginFolder = new DirectoryInfo("defaultplugins"); if (!defaultPluginFolder.Exists) return; @@ -442,13 +579,18 @@ public async Task InstallDefaultPlugins() using var stream = File.OpenRead(file.FullName); var plugin = await Plugin.LoadFromStream(stream, file.Name); - try { + try + { HandleUploadedPlugin(plugin); logger.LogInformation("Plugin {0} loaded from default plugins.", file.Name); await SavePlugin(); - } catch (PluginException ex) { + } + catch (PluginException ex) + { logger.LogError(ex, "Plugin {0} failed to load from default plugins.", file.Name); - } finally { + } + finally + { // Cleanup the plugin after processing CleanupCurrentUploadedPlugin(); }