feat: use local blob storage
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ dist/
|
|||||||
*.local
|
*.local
|
||||||
.env.local
|
.env.local
|
||||||
.env.*.local
|
.env.*.local
|
||||||
|
App_Data/
|
||||||
|
|
||||||
# Local SSL certificates
|
# Local SSL certificates
|
||||||
*.pem
|
*.pem
|
||||||
|
|||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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('/')}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
using Socialize.Api.Infrastructure.BlobStorage.Contracts;
|
||||||
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||||
using Socialize.Api.Infrastructure.Configuration;
|
using Socialize.Api.Infrastructure.Configuration;
|
||||||
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
using Socialize.Api.Infrastructure.Emailer.Configuration;
|
||||||
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
using Socialize.Api.Infrastructure.Emailer.Contracts;
|
||||||
@@ -16,7 +17,10 @@ public static class DependencyInjection
|
|||||||
builder.Services.Configure<WebsiteOptions>(
|
builder.Services.Configure<WebsiteOptions>(
|
||||||
builder.Configuration.GetRequiredSection(WebsiteOptions.SectionName));
|
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.Services.Configure<StripeOptions>(
|
||||||
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));
|
builder.Configuration.GetSection(StripeOptions.ConfigurationSection));
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ using Azure.Identity;
|
|||||||
using FastEndpoints;
|
using FastEndpoints;
|
||||||
using FastEndpoints.Swagger;
|
using FastEndpoints.Swagger;
|
||||||
using Microsoft.AspNetCore.HttpOverrides;
|
using Microsoft.AspNetCore.HttpOverrides;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using Socialize;
|
using Socialize;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Configuration;
|
||||||
|
using Socialize.Api.Infrastructure.BlobStorage.Services;
|
||||||
using Socialize.Api.Infrastructure;
|
using Socialize.Api.Infrastructure;
|
||||||
using Socialize.Api.Infrastructure.Development;
|
using Socialize.Api.Infrastructure.Development;
|
||||||
using Socialize.Api.Modules.Approvals;
|
using Socialize.Api.Modules.Approvals;
|
||||||
@@ -92,6 +95,38 @@ if (!app.Environment.IsDevelopment())
|
|||||||
|
|
||||||
app.UseHealthChecks("/health");
|
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())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|||||||
@@ -15,7 +15,6 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Azure.Storage.Blobs" Version="12.26.0" />
|
|
||||||
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.0" />
|
<PackageReference Include="Azure.Extensions.AspNetCore.Configuration.Secrets" Version="1.5.0" />
|
||||||
<PackageReference Include="Azure.Identity" Version="1.18.0" />
|
<PackageReference Include="Azure.Identity" Version="1.18.0" />
|
||||||
<PackageReference Include="FastEndpoints" Version="5.35.0" />
|
<PackageReference Include="FastEndpoints" Version="5.35.0" />
|
||||||
|
|||||||
@@ -10,6 +10,10 @@
|
|||||||
"Website": {
|
"Website": {
|
||||||
"FrontendBaseUrl": "http://localhost:5173"
|
"FrontendBaseUrl": "http://localhost:5173"
|
||||||
},
|
},
|
||||||
|
"LocalBlobStorage": {
|
||||||
|
"RootPath": "App_Data/blob-storage",
|
||||||
|
"RequestPath": "/api/storage"
|
||||||
|
},
|
||||||
"Authentication": {
|
"Authentication": {
|
||||||
"Jwt": {
|
"Jwt": {
|
||||||
"Issuer": "http://localhost:5080",
|
"Issuer": "http://localhost:5080",
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
expose:
|
expose:
|
||||||
- "8080"
|
- "8080"
|
||||||
|
volumes:
|
||||||
|
- api-blob-storage:/app/App_Data/blob-storage
|
||||||
|
|
||||||
web:
|
web:
|
||||||
build:
|
build:
|
||||||
@@ -37,3 +39,6 @@ services:
|
|||||||
- "8080:80"
|
- "8080:80"
|
||||||
volumes:
|
volumes:
|
||||||
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
- ./deploy/caddy/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
api-blob-storage:
|
||||||
|
|||||||
40
docs/TASKS/platform-scaffold/003-use-local-blob-storage.md
Normal file
40
docs/TASKS/platform-scaffold/003-use-local-blob-storage.md
Normal 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
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user