From b51b8b4185c31fd22a47e40b0a39683dee1e764d Mon Sep 17 00:00:00 2001 From: Jonathan Bourdon Date: Thu, 30 Apr 2026 01:57:37 -0400 Subject: [PATCH] feat: use local blob storage --- .gitignore | 1 + .../Configuration/LocalBlobStorageOptions.cs | 10 ++ .../BlobStorage/Services/AzureBlobStorage.cs | 154 ------------------ .../BlobStorage/Services/LocalBlobStorage.cs | 142 ++++++++++++++++ .../Infrastructure/DependencyInjection.cs | 6 +- backend/src/Socialize.Api/Program.cs | 35 ++++ .../src/Socialize.Api/Socialize.Api.csproj | 1 - .../appsettings.Development.json | 4 + docker-compose.yml | 5 + .../003-use-local-blob-storage.md | 40 +++++ 10 files changed, 242 insertions(+), 156 deletions(-) create mode 100644 backend/src/Socialize.Api/Infrastructure/BlobStorage/Configuration/LocalBlobStorageOptions.cs delete mode 100644 backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/AzureBlobStorage.cs create mode 100644 backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/LocalBlobStorage.cs create mode 100644 docs/TASKS/platform-scaffold/003-use-local-blob-storage.md diff --git a/.gitignore b/.gitignore index d02de66..23dc7a3 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ dist/ *.local .env.local .env.*.local +App_Data/ # Local SSL certificates *.pem diff --git a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Configuration/LocalBlobStorageOptions.cs b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Configuration/LocalBlobStorageOptions.cs new file mode 100644 index 0000000..fc2490b --- /dev/null +++ b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Configuration/LocalBlobStorageOptions.cs @@ -0,0 +1,10 @@ +namespace Socialize.Api.Infrastructure.BlobStorage.Configuration; + +public sealed class LocalBlobStorageOptions +{ + public const string SectionName = "LocalBlobStorage"; + + public string RootPath { get; set; } = "App_Data/blob-storage"; + + public string RequestPath { get; set; } = "/api/storage"; +} diff --git a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/AzureBlobStorage.cs b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/AzureBlobStorage.cs deleted file mode 100644 index 61dc279..0000000 --- a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/AzureBlobStorage.cs +++ /dev/null @@ -1,154 +0,0 @@ -using Azure; -using Azure.Storage.Blobs; -using Azure.Storage.Blobs.Models; -using Socialize.Api.Infrastructure.BlobStorage.Contracts; - -namespace Socialize.Api.Infrastructure.BlobStorage.Services; - -public class AzureBlobStorage : IBlobStorage -{ - private const long MaxUploadSize = 10 * 1024 * 1024; // 10 MB in bytes - - private readonly BlobServiceClient _blobServiceClient; - private readonly ILogger _logger; - - public AzureBlobStorage(IConfiguration configuration, ILogger logger) - { - _logger = logger; - string? connectionString = configuration.GetConnectionString("AzureBlob"); - _blobServiceClient = new BlobServiceClient(connectionString); - } - - /// - /// Upload a file to microsoft azure blob storage. - /// - /// The name of the container where the file is stored. - /// The blob name (path within the container, include the file name). - /// - /// The content type. - /// The cancellation token - /// - public async Task UploadFileAsync( - string containerName, - string blobName, - Stream stream, - string contentType, - CancellationToken ct = default) - { - // Read the file stream into a memory stream to determine the length - // WATCH FOR MEMORY USAGE USING THE MEMORY STREAM. - stream.Position = 0; - - // Check if the file size exceeds the maximum upload size - if (stream.Length > MaxUploadSize) - { - _logger.LogError( - $"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes."); - throw new InvalidOperationException( - $"Blob storage: File size exceeds the maximum allowed size of {MaxUploadSize} bytes."); - } - - // Validate content type - if (!ContentTypes.IsAllowed(contentType, stream)) - { - _logger.LogError( - $"Blob storage: Unsupported file type {contentType}."); - throw new InvalidOperationException("Unsupported file type."); - } - - try - { - // Get a reference to a container - BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName); - - // Create the container if it does not exist - await containerClient.CreateIfNotExistsAsync( - PublicAccessType.Blob, - cancellationToken: ct); - - // Get a reference to a blob - BlobClient? blobClient = containerClient.GetBlobClient(blobName); - - // Define the BlobHttpHeaders to include the content type - BlobHttpHeaders blobHttpHeaders = new() { ContentType = contentType }; - - // Upload the file - Response? response = await blobClient.UploadAsync( - stream, - new BlobUploadOptions { HttpHeaders = blobHttpHeaders }, - ct); - - string fileUri = blobClient.Uri.ToString(); - - _logger.LogInformation( - """ - Blob storage: Status [ {ResponseStatus} ] - Uploaded [ {BlobName} ] to the container [ {ContainerName} ] - with contentType [ {ContentType} ] - with a length of [ {StreamLength} bytes ] - with the uri [ {FileUri} ] - """, - response.GetRawResponse().Status.ToString(), - blobName, - containerName, - contentType, - stream.Length, - fileUri - ); - - // Return the URI of the uploaded blob - return fileUri; - } - catch (RequestFailedException ex) - { - _logger.LogError($"Blob storage: Azure Storage request failed: {ex.Message}"); - throw; - } - catch (Exception ex) - { - _logger.LogError($"Blob storage: An error occurred: {ex.Message}"); - throw; - } - } - - /// - /// Download a file to microsoft's azure blob storage. - /// - /// The blob name (path within the container). - /// The name of the container where the file is stored. (users) - /// The cancellation token for the request - /// - public async Task DownloadFileAsync( - string containerName, - string blobName, - CancellationToken ct = default) - { - try - { - // Get a reference to a container - BlobContainerClient? containerClient = _blobServiceClient.GetBlobContainerClient(containerName); - - // Get a reference to a blob - BlobClient? blobClient = containerClient.GetBlobClient(blobName); - - // Download the blob to a stream - BlobDownloadInfo download = await blobClient.DownloadAsync(ct); - - MemoryStream memoryStream = new(); - await download.Content.CopyToAsync(memoryStream, ct); - memoryStream.Position = 0; // Ensure the stream is at the beginning - - return memoryStream; - } - catch (RequestFailedException ex) - { - _logger.LogError($"Azure Storage request failed: {ex.Message}"); - throw; - } - catch (Exception ex) - { - _logger.LogError($"An error occurred: {ex.Message}"); - throw; - } - } -} diff --git a/backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/LocalBlobStorage.cs b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/LocalBlobStorage.cs new file mode 100644 index 0000000..e492653 --- /dev/null +++ b/backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/LocalBlobStorage.cs @@ -0,0 +1,142 @@ +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('/')}"; + } +} diff --git a/backend/src/Socialize.Api/Infrastructure/DependencyInjection.cs b/backend/src/Socialize.Api/Infrastructure/DependencyInjection.cs index e4b7109..586d3ef 100644 --- a/backend/src/Socialize.Api/Infrastructure/DependencyInjection.cs +++ b/backend/src/Socialize.Api/Infrastructure/DependencyInjection.cs @@ -1,5 +1,6 @@ using Socialize.Api.Infrastructure.BlobStorage.Contracts; using Socialize.Api.Infrastructure.BlobStorage.Services; +using Socialize.Api.Infrastructure.BlobStorage.Configuration; using Socialize.Api.Infrastructure.Configuration; using Socialize.Api.Infrastructure.Emailer.Configuration; using Socialize.Api.Infrastructure.Emailer.Contracts; @@ -16,7 +17,10 @@ public static class DependencyInjection builder.Services.Configure( builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName)); - builder.Services.AddTransient(); + builder.Services.Configure( + builder.Configuration.GetSection(LocalBlobStorageOptions.SectionName)); + builder.Services.AddTransient(); + builder.Services.AddTransient(services => services.GetRequiredService()); builder.Services.Configure( builder.Configuration.GetSection(StripeOptions.ConfigurationSection)); diff --git a/backend/src/Socialize.Api/Program.cs b/backend/src/Socialize.Api/Program.cs index 5fd5533..5d7b2b0 100644 --- a/backend/src/Socialize.Api/Program.cs +++ b/backend/src/Socialize.Api/Program.cs @@ -2,7 +2,10 @@ using Azure.Identity; using FastEndpoints; using FastEndpoints.Swagger; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Options; using Socialize; +using Socialize.Api.Infrastructure.BlobStorage.Configuration; +using Socialize.Api.Infrastructure.BlobStorage.Services; using Socialize.Api.Infrastructure; using Socialize.Api.Infrastructure.Development; using Socialize.Api.Modules.Approvals; @@ -92,6 +95,38 @@ if (!app.Environment.IsDevelopment()) app.UseHealthChecks("/health"); +LocalBlobStorageOptions localBlobStorageOptions = app.Services + .GetRequiredService>() + .Value; + +string localBlobStorageRoot = app.Services + .GetRequiredService() + .GetRootPath(); +string localBlobStorageRootWithSeparator = Path.EndsInDirectorySeparator(localBlobStorageRoot) + ? localBlobStorageRoot + : $"{localBlobStorageRoot}{Path.DirectorySeparatorChar}"; + +Directory.CreateDirectory(localBlobStorageRoot); + +app.MapGet( + $"{LocalBlobStorage.NormalizeRequestPath(localBlobStorageOptions.RequestPath)}/{{**blobPath}}", + async ( + string blobPath, + CancellationToken ct) => + { + string filePath = Path.GetFullPath(Path.Combine(localBlobStorageRoot, blobPath)); + if (!filePath.StartsWith(localBlobStorageRootWithSeparator, StringComparison.Ordinal) || + filePath.EndsWith(".content-type", StringComparison.OrdinalIgnoreCase) || + !File.Exists(filePath)) + { + return Results.NotFound(); + } + + string contentType = LocalBlobStorage.ReadContentType(filePath) ?? "application/octet-stream"; + byte[] bytes = await File.ReadAllBytesAsync(filePath, ct); + return Results.File(bytes, contentType); + }); + if (!app.Environment.IsDevelopment()) { app.UseHttpsRedirection(); diff --git a/backend/src/Socialize.Api/Socialize.Api.csproj b/backend/src/Socialize.Api/Socialize.Api.csproj index c310501..6b4882b 100644 --- a/backend/src/Socialize.Api/Socialize.Api.csproj +++ b/backend/src/Socialize.Api/Socialize.Api.csproj @@ -15,7 +15,6 @@ - diff --git a/backend/src/Socialize.Api/appsettings.Development.json b/backend/src/Socialize.Api/appsettings.Development.json index 44b146a..9af68fe 100644 --- a/backend/src/Socialize.Api/appsettings.Development.json +++ b/backend/src/Socialize.Api/appsettings.Development.json @@ -10,6 +10,10 @@ "Website": { "FrontendBaseUrl": "http://localhost:5173" }, + "LocalBlobStorage": { + "RootPath": "App_Data/blob-storage", + "RequestPath": "/api/storage" + }, "Authentication": { "Jwt": { "Issuer": "http://localhost:5080", diff --git a/docker-compose.yml b/docker-compose.yml index 3967385..8624381 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -26,6 +26,8 @@ services: condition: service_healthy expose: - "8080" + volumes: + - api-blob-storage:/app/App_Data/blob-storage web: build: @@ -37,3 +39,6 @@ services: - "8080:80" volumes: - ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro + +volumes: + api-blob-storage: diff --git a/docs/TASKS/platform-scaffold/003-use-local-blob-storage.md b/docs/TASKS/platform-scaffold/003-use-local-blob-storage.md new file mode 100644 index 0000000..faee250 --- /dev/null +++ b/docs/TASKS/platform-scaffold/003-use-local-blob-storage.md @@ -0,0 +1,40 @@ +# Task: Use local blob storage + +## Feature + +`docs/FEATURES/platform-scaffold.md` + +## Goal + +Store uploaded portraits and logos on the API server filesystem instead of Azure Blob Storage. + +## Context + +User, client, and workspace portrait uploads already flow through `IBlobStorage`. The implementation can change without altering endpoint contracts or frontend behavior. + +## Files Likely To Change + +- `backend/src/Socialize.Api/Infrastructure/DependencyInjection.cs` +- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Services/*` +- `backend/src/Socialize.Api/Infrastructure/BlobStorage/Configuration/*` +- `backend/src/Socialize.Api/Program.cs` +- `backend/src/Socialize.Api/appsettings.Development.json` + +## Constraints + +- Do not change API request or response contracts. +- Keep upload validation behavior consistent with the existing blob storage implementation. +- Serve returned blob URLs from the API host so the existing frontend can keep using `portraitUrl` and `logoUrl`. + +## Done When + +- [x] `IBlobStorage` resolves to local filesystem storage by default. +- [x] Uploaded files are served back from the API host. +- [x] Backend build passes. + +## Validation Commands + +```bash +dotnet build backend/Socialize.slnx +dotnet test backend/Socialize.slnx +```