using System.IO.Compression; using Elsa.Common.Contracts; using Elsa.Http.Contracts; using Elsa.Http.Models; using Elsa.Http.Options; using FluentStorage.Blobs; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Elsa.Http.Services; /// /// Provides a helper service for zipping downloadable content. /// internal class ZipManager { private readonly ISystemClock _clock; private readonly IFileCacheStorageProvider _fileCacheStorageProvider; private readonly IOptions _fileCacheOptions; private readonly ILogger _logger; /// /// Initializes a new instance of the class. /// public ZipManager(ISystemClock clock, IFileCacheStorageProvider fileCacheStorageProvider, IOptions fileCacheOptions, ILogger logger) { _clock = clock; _fileCacheStorageProvider = fileCacheStorageProvider; _fileCacheOptions = fileCacheOptions; _logger = logger; } public async Task<(Blob, Stream, Action)> CreateAsync( ICollection>> downloadables, bool enableResumableDownloads, string? downloadCorrelationId, string? downloadAsFilename = default, string? contentType = default, CancellationToken cancellationToken = default) { // Create a temporary file. var tempFilePath = Path.GetTempFileName(); // Create a zip archive from the downloadables. await CreateZipArchiveAsync(tempFilePath, downloadables, cancellationToken); // Create a blob with metadata for resuming the download. var zipBlob = CreateBlob(tempFilePath, downloadAsFilename, contentType); // If resumable downloads are enabled, cache the file. if (enableResumableDownloads && !string.IsNullOrWhiteSpace(downloadCorrelationId)) await CreateCachedZipBlobAsync(tempFilePath, downloadCorrelationId, downloadAsFilename, contentType, cancellationToken); var zipStream = File.OpenRead(tempFilePath); return (zipBlob, zipStream, () => Cleanup(tempFilePath)); } /// /// Loads a cached zip blob for the specified download correlation ID. /// /// The download correlation ID. /// An optional cancellation token. /// A tuple containing the blob and the stream. public async Task<(Blob, Stream)?> LoadAsync(string downloadCorrelationId, CancellationToken cancellationToken = default) { var fileCacheStorage = _fileCacheStorageProvider.GetStorage(); var fileCacheFilename = $"{downloadCorrelationId}.tmp"; var blob = await fileCacheStorage.GetBlobAsync(fileCacheFilename, cancellationToken); if (blob == null) return null; // Check if the blob has expired. var expiresAt = DateTimeOffset.Parse(blob.Metadata["ExpiresAt"]); if (_clock.UtcNow > expiresAt) { // File expired. Try to delete it. try { await fileCacheStorage.DeleteAsync(blob.FullPath, cancellationToken); } catch (Exception e) { _logger.LogWarning(e, "Failed to delete expired file {FullPath}", blob.FullPath); } return null; } var stream = await fileCacheStorage.OpenReadAsync(blob.FullPath, cancellationToken); return (blob, stream); } /// /// Creates a zip archive from the specified instances. /// private async Task CreateZipArchiveAsync(string filePath, IEnumerable>> downloadables, CancellationToken cancellationToken = default) { var currentFileIndex = 0; // Write the zip archive to the temporary file. await using var tempFileStream = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: 4096, useAsync: true); using var zipArchive = new ZipArchive(tempFileStream, ZipArchiveMode.Create, true); foreach (var downloadableFunc in downloadables) { var downloadable = await downloadableFunc(); var entryName = !string.IsNullOrWhiteSpace(downloadable.Filename) ? downloadable.Filename : $"file-{currentFileIndex}.bin"; var entry = zipArchive.CreateEntry(entryName); var fileStream = downloadable.Stream; await using var entryStream = entry.Open(); await fileStream.CopyToAsync(entryStream, cancellationToken); await entryStream.FlushAsync(cancellationToken); entryStream.Close(); currentFileIndex++; } } /// /// Creates a cached zip blob for the specified file. /// /// The full path of the file to upload. /// The download correlation ID. /// The filename to use when downloading the file. /// The content type of the file. /// An optional cancellation token. private async Task CreateCachedZipBlobAsync(string localPath, string downloadCorrelationId, string? downloadAsFilename = default, string? contentType = default, CancellationToken cancellationToken = default) { var fileCacheStorage = _fileCacheStorageProvider.GetStorage(); var fileCacheFilename = $"{downloadCorrelationId}.tmp"; var expiresAt = _clock.UtcNow.Add(_fileCacheOptions.Value.TimeToLive); var cachedBlob = CreateBlob(fileCacheFilename, downloadAsFilename, contentType, expiresAt); await fileCacheStorage.WriteFileAsync(fileCacheFilename, localPath, cancellationToken); await fileCacheStorage.SetBlobAsync(cachedBlob, cancellationToken: cancellationToken); } /// /// Creates a blob for the specified file. /// /// The full path of the file. /// The filename to use when downloading the file. /// The content type of the file. /// The date and time at which the file expires. /// The blob. private Blob CreateBlob(string fullPath, string? downloadAsFilename, string? contentType, DateTimeOffset? expiresAt = default) { (downloadAsFilename, contentType) = GetDownloadableMetadata(downloadAsFilename, contentType); var now = _clock.UtcNow; var blob = new Blob(fullPath) { Metadata = { ["ContentType"] = contentType, ["Filename"] = downloadAsFilename }, CreatedTime = now, LastModificationTime = now }; if(expiresAt.HasValue) blob.Metadata["ExpiresAt"] = expiresAt.Value.ToString("O"); return blob; } private (string downloadAsFilename, string contentType) GetDownloadableMetadata(string? contentType, string? downloadAsFilename) { contentType = !string.IsNullOrWhiteSpace(contentType) ? contentType : System.Net.Mime.MediaTypeNames.Application.Zip; downloadAsFilename = !string.IsNullOrWhiteSpace(downloadAsFilename) ? downloadAsFilename : "download.zip"; return (downloadAsFilename, contentType); } private void Cleanup(string filePath) { try { File.Delete(filePath); } catch (Exception e) { _logger.LogWarning(e, "Failed to delete temporary file {TempFilePath}", filePath); } } }