using Microsoft.Extensions.Options; using Socialize.Api.Infrastructure.BlobStorage.Configuration; using Socialize.Api.Infrastructure.BlobStorage.Contracts; namespace Socialize.Api.Infrastructure.BlobStorage.Services; public sealed class LocalBlobStorage( IWebHostEnvironment environment, IHttpContextAccessor httpContextAccessor, IOptions options, ILogger logger) : IBlobStorage { private const long MaxUploadSize = 10 * 1024 * 1024; private const string ContentTypeMetadataSuffix = ".content-type"; private readonly LocalBlobStorageOptions _options = options.Value; public async Task UploadFileAsync( string containerName, string blobName, Stream stream, string contentType, CancellationToken ct = default) { stream.Position = 0; if (stream.Length > MaxUploadSize) { logger.LogError("Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes.", MaxUploadSize); throw new InvalidOperationException($"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes."); } if (!ContentTypes.IsAllowed(contentType, stream)) { logger.LogError("Blob storage: Unsupported file type {ContentType}.", contentType); throw new InvalidOperationException("Unsupported file type."); } string relativePath = GetSafeRelativePath(containerName, blobName); string filePath = Path.Combine(GetRootPath(), relativePath); Directory.CreateDirectory(Path.GetDirectoryName(filePath) ?? GetRootPath()); await using FileStream fileStream = File.Create(filePath); await stream.CopyToAsync(fileStream, ct); await File.WriteAllTextAsync(GetContentTypeMetadataPath(filePath), contentType, ct); string fileUri = BuildPublicUrl(relativePath); logger.LogInformation( "Blob storage: Uploaded [{BlobName}] to local container [{ContainerName}] with contentType [{ContentType}] and uri [{FileUri}]", blobName, containerName, contentType, fileUri); return fileUri; } public async Task DownloadFileAsync( string containerName, string blobName, CancellationToken ct = default) { string filePath = Path.Combine(GetRootPath(), GetSafeRelativePath(containerName, blobName)); if (!File.Exists(filePath)) { throw new FileNotFoundException("Blob storage: Local file was not found.", blobName); } MemoryStream memoryStream = new(); await using FileStream fileStream = File.OpenRead(filePath); await fileStream.CopyToAsync(memoryStream, ct); memoryStream.Position = 0; return memoryStream; } internal string GetRootPath() { if (Path.IsPathRooted(_options.RootPath)) { return Path.GetFullPath(_options.RootPath); } return Path.GetFullPath(Path.Combine(environment.ContentRootPath, _options.RootPath)); } internal static string? ReadContentType(string filePath) { string metadataPath = GetContentTypeMetadataPath(filePath); return File.Exists(metadataPath) ? File.ReadAllText(metadataPath) : null; } private static string GetContentTypeMetadataPath(string filePath) { return $"{filePath}{ContentTypeMetadataSuffix}"; } private static string GetSafeRelativePath(string containerName, string blobName) { if (Path.IsPathRooted(containerName) || Path.IsPathRooted(blobName)) { throw new InvalidOperationException("Blob storage: Blob paths must be relative."); } string[] pathParts = [containerName, .. blobName.Split([Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar])]; if (pathParts.Any(part => part is "" or "." or "..")) { throw new InvalidOperationException("Blob storage: Blob paths must not contain relative path segments."); } return Path.Combine(pathParts); } private string BuildPublicUrl(string relativePath) { HttpRequest? request = httpContextAccessor.HttpContext?.Request; string requestPath = NormalizeRequestPath(_options.RequestPath); string urlPath = $"{requestPath}/{relativePath.Replace(Path.DirectorySeparatorChar, '/')}"; if (request is null) { return urlPath; } return $"{request.Scheme}://{request.Host}{request.PathBase}{urlPath}"; } internal static string NormalizeRequestPath(string requestPath) { string normalized = string.IsNullOrWhiteSpace(requestPath) ? "/api/storage" : requestPath.Trim(); return normalized.StartsWith("/", StringComparison.Ordinal) ? normalized.TrimEnd('/') : $"/{normalized.TrimEnd('/')}"; } }