Initial commit
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
using System.Diagnostics;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using PowderCoating.Application.Configuration;
|
||||
using PowderCoating.Application.Interfaces;
|
||||
|
||||
namespace PowderCoating.Application.Services;
|
||||
|
||||
/// <summary>
|
||||
/// One-time migration utility that copies files from the legacy local filesystem
|
||||
/// (<c>wwwroot/media/</c>) to Azure Blob Storage containers, then optionally
|
||||
/// deletes the originals.
|
||||
/// <para>
|
||||
/// The migration is <strong>idempotent</strong>: if a blob already exists in Azure
|
||||
/// it is skipped (not overwritten). This means the operation is safe to run
|
||||
/// multiple times — for example if a previous run was interrupted part-way through.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Container assignment is inferred from the relative path of each file
|
||||
/// (see <see cref="DetermineContainer"/>). Files that do not match any known
|
||||
/// path pattern are reported as failures but do not abort the overall run.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class StorageMigrationService : IStorageMigrationService
|
||||
{
|
||||
private readonly IAzureBlobStorageService _blobService;
|
||||
private readonly StorageSettings _settings;
|
||||
private readonly ILogger<StorageMigrationService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initialises the service with the blob storage provider, storage
|
||||
/// configuration, and a logger for per-file progress messages.
|
||||
/// </summary>
|
||||
public StorageMigrationService(
|
||||
IAzureBlobStorageService blobService,
|
||||
IOptions<StorageSettings> settings,
|
||||
ILogger<StorageMigrationService> logger)
|
||||
{
|
||||
_blobService = blobService;
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively enumerates all files under <paramref name="mediaBasePath"/>,
|
||||
/// determines the correct Azure Blob Storage container for each file, and
|
||||
/// uploads those that do not already exist in Azure.
|
||||
/// </summary>
|
||||
/// <param name="mediaBasePath">
|
||||
/// Absolute path to the root media directory on the local filesystem
|
||||
/// (e.g. <c>C:\app\wwwroot\media</c>).
|
||||
/// </param>
|
||||
/// <param name="deleteLocalAfterMigration">
|
||||
/// When <c>true</c>, the local file is deleted immediately after a successful
|
||||
/// upload. Defaults to <c>false</c> so a dry-run can be performed first.
|
||||
/// <strong>Warning:</strong> set to <c>true</c> only after verifying the
|
||||
/// migration result — deleted local files cannot be recovered without a backup.
|
||||
/// </param>
|
||||
/// <returns>
|
||||
/// A <see cref="StorageMigrationResult"/> summarising how many files were
|
||||
/// migrated, skipped (already in Azure), and failed, along with total bytes
|
||||
/// transferred and elapsed time.
|
||||
/// </returns>
|
||||
public async Task<StorageMigrationResult> MigrateFilesystemToAzureAsync(
|
||||
string mediaBasePath,
|
||||
bool deleteLocalAfterMigration = false)
|
||||
{
|
||||
var result = new StorageMigrationResult();
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
if (!Directory.Exists(mediaBasePath))
|
||||
{
|
||||
result.Errors.Add($"Media directory not found: {mediaBasePath}");
|
||||
result.Duration = stopwatch.Elapsed;
|
||||
return result;
|
||||
}
|
||||
|
||||
var allFiles = Directory.EnumerateFiles(mediaBasePath, "*.*", SearchOption.AllDirectories).ToList();
|
||||
_logger.LogInformation("Starting filesystem-to-Azure migration. Found {Count} files in {Path}", allFiles.Count, mediaBasePath);
|
||||
|
||||
foreach (var fullPath in allFiles)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(mediaBasePath, fullPath).Replace("\\", "/");
|
||||
var container = DetermineContainer(relativePath);
|
||||
|
||||
if (container is null)
|
||||
{
|
||||
_logger.LogWarning("Could not determine container for file: {RelativePath} — skipping", relativePath);
|
||||
result.Errors.Add($"Unknown file type (no matching container): {relativePath}");
|
||||
result.Failed++;
|
||||
result.Files.Add(new MigratedFileEntry
|
||||
{
|
||||
RelativePath = relativePath,
|
||||
Container = "unknown",
|
||||
FileSize = new FileInfo(fullPath).Length,
|
||||
Status = MigrationFileStatus.Failed
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var fileInfo = new FileInfo(fullPath);
|
||||
|
||||
// Skip if blob already exists in Azure
|
||||
var alreadyExists = await _blobService.ExistsAsync(container, relativePath);
|
||||
if (alreadyExists)
|
||||
{
|
||||
_logger.LogDebug("Blob already exists, skipping: {Container}/{BlobName}", container, relativePath);
|
||||
result.Skipped++;
|
||||
result.Files.Add(new MigratedFileEntry
|
||||
{
|
||||
RelativePath = relativePath,
|
||||
Container = container,
|
||||
FileSize = fileInfo.Length,
|
||||
Status = MigrationFileStatus.Skipped
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var contentType = GetContentType(Path.GetExtension(fullPath).ToLowerInvariant());
|
||||
|
||||
await using var stream = File.OpenRead(fullPath);
|
||||
var uploadResult = await _blobService.UploadAsync(container, relativePath, stream, contentType);
|
||||
|
||||
if (!uploadResult.Success)
|
||||
{
|
||||
result.Failed++;
|
||||
result.Errors.Add($"Upload failed for {relativePath}: {uploadResult.ErrorMessage}");
|
||||
result.Files.Add(new MigratedFileEntry
|
||||
{
|
||||
RelativePath = relativePath,
|
||||
Container = container,
|
||||
FileSize = fileInfo.Length,
|
||||
Status = MigrationFileStatus.Failed
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
result.Migrated++;
|
||||
result.BytesMigrated += fileInfo.Length;
|
||||
result.Files.Add(new MigratedFileEntry
|
||||
{
|
||||
RelativePath = relativePath,
|
||||
Container = container,
|
||||
FileSize = fileInfo.Length,
|
||||
Status = MigrationFileStatus.Migrated
|
||||
});
|
||||
|
||||
if (deleteLocalAfterMigration)
|
||||
{
|
||||
File.Delete(fullPath);
|
||||
_logger.LogInformation("Deleted local file after migration: {FullPath}", fullPath);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Migrated: {RelativePath} → {Container}", relativePath, container);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error migrating file: {RelativePath}", relativePath);
|
||||
result.Failed++;
|
||||
result.Errors.Add($"Exception for {relativePath}: {ex.Message}");
|
||||
result.Files.Add(new MigratedFileEntry
|
||||
{
|
||||
RelativePath = relativePath,
|
||||
Container = container,
|
||||
FileSize = 0,
|
||||
Status = MigrationFileStatus.Failed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
stopwatch.Stop();
|
||||
result.Duration = stopwatch.Elapsed;
|
||||
|
||||
_logger.LogInformation(
|
||||
"Migration complete. Migrated: {Migrated}, Skipped: {Skipped}, Failed: {Failed}, Duration: {Duration}",
|
||||
result.Migrated, result.Skipped, result.Failed, result.Duration);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines which Azure Blob Storage container a local file belongs in by
|
||||
/// inspecting its path segments. The mapping mirrors the blob naming
|
||||
/// conventions used by each file service:
|
||||
/// <list type="bullet">
|
||||
/// <item><c>/profile-photos/</c> → <c>profileimages</c> container</item>
|
||||
/// <item><c>/job-photos/</c> → <c>jobimages</c> container</item>
|
||||
/// <item><c>/equipment-manuals/</c> → <c>manuals</c> container</item>
|
||||
/// <item><c>company-logo</c> (anywhere in path) → <c>companylogos</c> container</item>
|
||||
/// </list>
|
||||
/// Returns <c>null</c> for files that do not match any known pattern; the
|
||||
/// caller treats these as failures and continues with the next file.
|
||||
/// </summary>
|
||||
/// <param name="relativePath">
|
||||
/// Path of the file relative to the media root, with forward slashes.
|
||||
/// </param>
|
||||
/// <returns>Container name, or <c>null</c> if no match is found.</returns>
|
||||
private string? DetermineContainer(string relativePath)
|
||||
{
|
||||
if (relativePath.Contains("/profile-photos/")) return _settings.Containers.ProfileImages;
|
||||
if (relativePath.Contains("/job-photos/")) return _settings.Containers.JobImages;
|
||||
if (relativePath.Contains("/equipment-manuals/"))return _settings.Containers.Manuals;
|
||||
if (relativePath.Contains("company-logo")) return _settings.Containers.CompanyLogos;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps a lowercase file extension to its canonical MIME content type for
|
||||
/// the <c>Content-Type</c> metadata stored with each Azure blob. Correct
|
||||
/// MIME types ensure browsers handle downloads appropriately (inline vs.
|
||||
/// prompt for application) when blobs are accessed via signed URLs.
|
||||
/// </summary>
|
||||
/// <param name="extension">Lowercase file extension including the leading dot.</param>
|
||||
/// <returns>MIME type string, or <c>application/octet-stream</c> as a safe fallback.</returns>
|
||||
private static string GetContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" or ".jpeg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".gif" => "image/gif",
|
||||
".webp" => "image/webp",
|
||||
".svg" => "image/svg+xml",
|
||||
".pdf" => "application/pdf",
|
||||
".doc" => "application/msword",
|
||||
".docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".txt" => "text/plain",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user