dbe4170986
116 tests passing: JobPhotoService, MeasurementConversionService, PlatformSettingsService, QuoteApprovalController, QuotePhotoService, ShopCapabilityCalculator, StorageMigrationService, TenantContext, UsageQuotaController — plus expanded PricingCalculation, Registration, and Subscription tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
234 lines
10 KiB
C#
234 lines
10 KiB
C#
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());
|
|
|
|
(bool Success, string ErrorMessage) uploadResult;
|
|
await using (var stream = File.OpenRead(fullPath))
|
|
{
|
|
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"
|
|
};
|
|
}
|