feat: use local blob storage
This commit is contained in:
@@ -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.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));
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
"Website": {
|
||||
"FrontendBaseUrl": "http://localhost:5173"
|
||||
},
|
||||
"LocalBlobStorage": {
|
||||
"RootPath": "App_Data/blob-storage",
|
||||
"RequestPath": "/api/storage"
|
||||
},
|
||||
"Authentication": {
|
||||
"Jwt": {
|
||||
"Issuer": "http://localhost:5080",
|
||||
|
||||
Reference in New Issue
Block a user