feat: use local blob storage
This commit is contained in:
@@ -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('/')}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user