feat: use local blob storage

This commit is contained in:
2026-04-30 01:57:37 -04:00
parent d222e33667
commit b51b8b4185
10 changed files with 242 additions and 156 deletions

1
.gitignore vendored
View File

@@ -33,6 +33,7 @@ dist/
*.local
.env.local
.env.*.local
App_Data/
# Local SSL certificates
*.pem

View File

@@ -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";
}

View File

@@ -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<AzureBlobStorage> _logger;
public AzureBlobStorage(IConfiguration configuration, ILogger<AzureBlobStorage> logger)
{
_logger = logger;
string? connectionString = configuration.GetConnectionString("AzureBlob");
_blobServiceClient = new BlobServiceClient(connectionString);
}
/// <summary>
/// Upload a file to microsoft azure blob storage.
/// </summary>
/// <param name="containerName">The name of the container where the file is stored.</param>
/// <param name="blobName">The blob name (path within the container, include the file name).</param>
/// <param name="stream"></param>
/// <param name="contentType">The content type.</param>
/// <param name="ct">The cancellation token</param>
/// <returns></returns>
public async Task<string> 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<BlobContentInfo>? 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;
}
}
/// <summary>
/// Download a file to microsoft's azure blob storage.
/// </summary>
/// <param name="blobName">The blob name (path within the container).</param>
/// <param name="containerName">The name of the container where the file is stored. (users)</param>
/// <param name="ct">The cancellation token for the request</param>
/// <returns></returns>
public async Task<MemoryStream> 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;
}
}
}

View File

@@ -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<LocalBlobStorageOptions> options,
ILogger<LocalBlobStorage> logger)
: IBlobStorage
{
private const long MaxUploadSize = 10 * 1024 * 1024;
private const string ContentTypeMetadataSuffix = ".content-type";
private readonly LocalBlobStorageOptions _options = options.Value;
public async Task<string> 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<MemoryStream> 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('/')}";
}
}

View File

@@ -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<WebsiteOptions>(
builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
builder.Services.AddTransient<IBlobStorage, AzureBlobStorage>();
builder.Services.Configure<LocalBlobStorageOptions>(
builder.Configuration.GetSection(LocalBlobStorageOptions.SectionName));
builder.Services.AddTransient<LocalBlobStorage>();
builder.Services.AddTransient<IBlobStorage>(services => services.GetRequiredService<LocalBlobStorage>());
builder.Services.Configure<StripeOptions>(
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));

View File

@@ -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<IOptions<LocalBlobStorageOptions>>()
.Value;
string localBlobStorageRoot = app.Services
.GetRequiredService<LocalBlobStorage>()
.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();

View File

@@ -15,7 +15,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Azure.Storage.Blobs" Version="12.26.0" />
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.0" />
<PackageReference Include="Azure.Identity" Version="1.18.0" />
<PackageReference Include="FastEndpoints" Version="5.35.0" />

View File

@@ -10,6 +10,10 @@
"Website": {
"FrontendBaseUrl": "http://localhost:5173"
},
"LocalBlobStorage": {
"RootPath": "App_Data/blob-storage",
"RequestPath": "/api/storage"
},
"Authentication": {
"Jwt": {
"Issuer": "http://localhost:5080",

View File

@@ -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:

View File

@@ -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
```