143 lines
4.9 KiB
C#
143 lines
4.9 KiB
C#
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('/')}";
|
|
}
|
|
}
|