using System.Diagnostics; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using PowderCoating.Application.Configuration; using PowderCoating.Application.Interfaces; namespace PowderCoating.Application.Services; /// /// One-time migration utility that copies files from the legacy local filesystem /// (wwwroot/media/) to Azure Blob Storage containers, then optionally /// deletes the originals. /// /// The migration is idempotent: 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. /// /// /// Container assignment is inferred from the relative path of each file /// (see ). Files that do not match any known /// path pattern are reported as failures but do not abort the overall run. /// /// public class StorageMigrationService : IStorageMigrationService { private readonly IAzureBlobStorageService _blobService; private readonly StorageSettings _settings; private readonly ILogger _logger; /// /// Initialises the service with the blob storage provider, storage /// configuration, and a logger for per-file progress messages. /// public StorageMigrationService( IAzureBlobStorageService blobService, IOptions settings, ILogger logger) { _blobService = blobService; _settings = settings.Value; _logger = logger; } /// /// Recursively enumerates all files under , /// determines the correct Azure Blob Storage container for each file, and /// uploads those that do not already exist in Azure. /// /// /// Absolute path to the root media directory on the local filesystem /// (e.g. C:\app\wwwroot\media). /// /// /// When true, the local file is deleted immediately after a successful /// upload. Defaults to false so a dry-run can be performed first. /// Warning: set to true only after verifying the /// migration result — deleted local files cannot be recovered without a backup. /// /// /// A summarising how many files were /// migrated, skipped (already in Azure), and failed, along with total bytes /// transferred and elapsed time. /// public async Task 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; } /// /// 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: /// /// /profile-photos/profileimages container /// /job-photos/jobimages container /// /equipment-manuals/manuals container /// company-logo (anywhere in path) → companylogos container /// /// Returns null for files that do not match any known pattern; the /// caller treats these as failures and continues with the next file. /// /// /// Path of the file relative to the media root, with forward slashes. /// /// Container name, or null if no match is found. 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; } /// /// Maps a lowercase file extension to its canonical MIME content type for /// the Content-Type 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. /// /// Lowercase file extension including the leading dot. /// MIME type string, or application/octet-stream as a safe fallback. 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" }; }