using Microsoft.AspNetCore.Components.Forms; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using OpenArchival.Blazor.CustomComponents; namespace OpenArchival.DataAccess.FileAccessManager; public class FileAccessManager : IFileAccessManager { private IDbContextFactory _contextFactory { get; set; } IOptions _fileUploadOptions { get; set; } ILogger _logger { get; set; } private readonly IImageThumbnailer _thumbnailer; public FileAccessManager( IDbContextFactory contextFactory, IOptions fileOptions, ILogger logger, IImageThumbnailer thumbnailer ) { _contextFactory = contextFactory; _fileUploadOptions = fileOptions; _logger = logger; _thumbnailer = thumbnailer; } /// /// Queries the file listing from the database based on the id, then deletes the file from the disk and /// the file listing from the database. /// /// /// /// /// Returns true if the file is deleted successfully /// returns false if the file cannot be found in the database /// /// Thrown if there is an IOException while trying to delete the file. public async Task DeleteFileAsync(int id) { await using var context = await _contextFactory.CreateDbContextAsync(); FilePathListing? existingListing = await context.ArtifactFilePaths .Where(listing=>listing.Id == id) .SingleOrDefaultAsync(); if (existingListing == null) { return false; } var filename = existingListing.Path; var smallThumb = existingListing.SmallThumbnailPath; var largeThumb = existingListing.LargeThumbnailPath; // Delete from the database before we delete from the disk to avoid someone trying to read it // before it deletes await context.ArtifactFilePaths.Where(listing => listing.Id == id).ExecuteDeleteAsync(); try { if (File.Exists(filename)) { File.Delete(filename); } if (!string.IsNullOrEmpty(smallThumb) && File.Exists(smallThumb)) { File.Delete(smallThumb); } if (!string.IsNullOrEmpty(largeThumb) && File.Exists(largeThumb)) { File.Delete(largeThumb); } } catch (IOException e) { throw new IOException("Could not delete file(s) from the disk!", e); } return true; } /// /// Uploads the given browser file to the disk and creates a corresponding database entry. /// /// /// Returns the FilePathListing object that was inserted to the database /// Throws wrapped IOException if the FileStream cannot be constructed. public async Task UploadFileAsync(IBrowserFile browserFile) { var diskFileName = $"{Guid.NewGuid()}{Path.GetExtension(browserFile.Name)}"; var destinationPath = Path.Combine(_fileUploadOptions.Value.UploadFolderPath, diskFileName); await using var browserUploadStream = browserFile.OpenReadStream(maxAllowedSize: _fileUploadOptions.Value.MaxUploadSizeBytes); try { await using var outFileStream = new FileStream(destinationPath, FileMode.Create); await browserUploadStream.CopyToAsync(outFileStream); } catch (IOException e) { throw new IOException("Failed to open file stream to write file to disk", e); } // Create the new entity var newFile = new FilePathListing() { Path = destinationPath, OriginalName = Path.GetFileName(browserFile.Name) }; // Generate thumbnails if it is a supported image if (_thumbnailer.IsSupportedImage(destinationPath)) { try { var result = await _thumbnailer.GenerateThumbnailsAsync(destinationPath); newFile.SmallThumbnailPath = result.SmallThumbnailPath; newFile.LargeThumbnailPath = result.LargeThumbnailPath; } catch (Exception ex) { _logger.LogError(ex, "Failed to generate thumbnails for {Path}", destinationPath); // We don't throw here, as we still want to save the original file } } await using var context = await _contextFactory.CreateDbContextAsync(); context.ArtifactFilePaths.Add(newFile); // Save the file entry await context.SaveChangesAsync(); return newFile; } public async Task GetFile(int id) { await using var context = await _contextFactory.CreateDbContextAsync(); FilePathListing? existingListing = await context.ArtifactFilePaths.FirstOrDefaultAsync(a => a.Id == id); return existingListing; } public async Task<(int Processed, int Failed)> BackfillThumbnailsAsync() { await using var context = await _contextFactory.CreateDbContextAsync(); var listingsToProcess = await context.ArtifactFilePaths .Where(f => string.IsNullOrEmpty(f.SmallThumbnailPath) || string.IsNullOrEmpty(f.LargeThumbnailPath)) .ToListAsync(); int processed = 0; int failed = 0; foreach (var listing in listingsToProcess) { if (!File.Exists(listing.Path)) { _logger.LogWarning("File not found on disk during backfill: {Path} (ID: {Id})", listing.Path, listing.Id); failed++; continue; } if (_thumbnailer.IsSupportedImage(listing.Path)) { try { var result = await _thumbnailer.GenerateThumbnailsAsync(listing.Path); listing.SmallThumbnailPath = result.SmallThumbnailPath; listing.LargeThumbnailPath = result.LargeThumbnailPath; processed++; } catch (Exception ex) { _logger.LogError(ex, "Failed to generate thumbnails during backfill for {Path} (ID: {Id})", listing.Path, listing.Id); failed++; } } } if (processed > 0) { await context.SaveChangesAsync(); } return (processed, failed); } }