Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
<PackageVersion Include="Aspire.Hosting.AppHost" Version="9.1.0" />
<PackageVersion Include="Aspire.Hosting.PostgreSQL" Version="9.1.0" />
<PackageVersion Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.1.0" />
<PackageVersion Include="Azure.Storage.Blobs" Version="12.23.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="markdig" Version="0.40.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.2" />
Expand Down
7 changes: 7 additions & 0 deletions SharpSite.sln
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string> 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<FileData> 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<IEnumerable<FileMetaData>> GetFiles(int page, int filesOnPage, out int totalFilesAvailable)
{
EnsureConfigured();
var blobs = new List<BlobItem>();

// 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"
};
}
}
}
Original file line number Diff line number Diff line change
@@ -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<BlobItem>();
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
});
}
}
Loading