feat(web): add local portfolio cover storage
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace GmRelay.Web.Services.Portfolio.Covers;
|
||||
|
||||
public sealed class LocalPortfolioCoverStorage : IPortfolioCoverStorage
|
||||
{
|
||||
public const long MaxBytes = 5 * 1024 * 1024;
|
||||
|
||||
private static readonly Regex SafeKeyPattern = new(
|
||||
"^[a-f0-9]{32}\\.(jpg|png|webp)$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private static readonly byte[] JpegSignature = [0xFF, 0xD8, 0xFF];
|
||||
private static readonly byte[] PngSignature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
private static readonly byte[] RiffMarker = "RIFF"u8.ToArray();
|
||||
private static readonly byte[] WebpMarker = "WEBP"u8.ToArray();
|
||||
|
||||
private readonly string _storagePath;
|
||||
private readonly ILogger<LocalPortfolioCoverStorage> _logger;
|
||||
|
||||
public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options)
|
||||
: this(options, logger: null)
|
||||
{
|
||||
}
|
||||
|
||||
public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options, ILogger<LocalPortfolioCoverStorage>? logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
if (string.IsNullOrWhiteSpace(options.StoragePath))
|
||||
{
|
||||
throw new InvalidOperationException("PortfolioCovers:StoragePath must be configured.");
|
||||
}
|
||||
|
||||
_storagePath = options.StoragePath;
|
||||
_logger = logger ?? NullLogger<LocalPortfolioCoverStorage>.Instance;
|
||||
}
|
||||
|
||||
public async Task<PortfolioCoverUploadResult> SaveAsync(
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
if (string.IsNullOrWhiteSpace(contentType))
|
||||
{
|
||||
throw new InvalidOperationException("Content type must be provided.");
|
||||
}
|
||||
|
||||
var extension = NormalizeExtension(contentType);
|
||||
|
||||
// Buffer the stream so we can reject oversize uploads before writing to disk
|
||||
// and so we have the bytes we need for signature validation.
|
||||
await using var buffer = new MemoryStream();
|
||||
await content.CopyToAsync(buffer, cancellationToken);
|
||||
if (buffer.Length > MaxBytes)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cover image exceeds the {MaxBytes}-byte size limit.");
|
||||
}
|
||||
|
||||
var signature = buffer.GetBuffer();
|
||||
var signatureLength = (int)buffer.Length;
|
||||
ValidateSignature(extension, signature, signatureLength);
|
||||
|
||||
Directory.CreateDirectory(_storagePath);
|
||||
var finalName = Guid.NewGuid().ToString("N") + extension;
|
||||
var finalPath = Path.Combine(_storagePath, finalName);
|
||||
var tempPath = finalPath + ".tmp";
|
||||
|
||||
try
|
||||
{
|
||||
await using (var tempStream = new FileStream(
|
||||
tempPath,
|
||||
FileMode.CreateNew,
|
||||
FileAccess.Write,
|
||||
FileShare.None))
|
||||
{
|
||||
buffer.Position = 0;
|
||||
await buffer.CopyToAsync(tempStream, cancellationToken);
|
||||
await tempStream.FlushAsync(cancellationToken);
|
||||
}
|
||||
|
||||
File.Move(tempPath, finalPath, overwrite: false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryDelete(tempPath);
|
||||
throw;
|
||||
}
|
||||
|
||||
return new PortfolioCoverUploadResult(finalName, ResolveContentType(extension));
|
||||
}
|
||||
|
||||
public Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
|
||||
EnsureSafeKey(storageKey);
|
||||
|
||||
var path = Path.Combine(_storagePath, storageKey);
|
||||
TryDelete(path);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public string GetPublicPath(string storageKey)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(storageKey);
|
||||
return "/portfolio-covers/" + Uri.EscapeDataString(storageKey);
|
||||
}
|
||||
|
||||
private static void ValidateSignature(string extension, byte[] data, int length)
|
||||
{
|
||||
var isValid = extension switch
|
||||
{
|
||||
".jpg" => StartsWith(data, length, JpegSignature),
|
||||
".png" => StartsWith(data, length, PngSignature),
|
||||
".webp" => StartsWith(data, length, RiffMarker)
|
||||
&& ContainsAt(data, RiffMarker.Length + 4, WebpMarker),
|
||||
_ => false
|
||||
};
|
||||
|
||||
if (!isValid)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Cover signature does not match the declared content type.");
|
||||
}
|
||||
}
|
||||
|
||||
private static bool StartsWith(byte[] data, int length, byte[] prefix)
|
||||
{
|
||||
if (length < prefix.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < prefix.Length; i++)
|
||||
{
|
||||
if (data[i] != prefix[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool ContainsAt(byte[] data, int offset, byte[] needle)
|
||||
{
|
||||
if (offset + needle.Length > data.Length)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
for (var i = 0; i < needle.Length; i++)
|
||||
{
|
||||
if (data[offset + i] != needle[i])
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string NormalizeExtension(string contentType)
|
||||
{
|
||||
var normalized = contentType.Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"image/jpeg" or "image/jpg" => ".jpg",
|
||||
"image/png" => ".png",
|
||||
"image/webp" => ".webp",
|
||||
_ => throw new InvalidOperationException(
|
||||
$"Unsupported cover content type: '{contentType}'.")
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveContentType(string extension) => extension switch
|
||||
{
|
||||
".jpg" => "image/jpeg",
|
||||
".png" => "image/png",
|
||||
".webp" => "image/webp",
|
||||
_ => "application/octet-stream"
|
||||
};
|
||||
|
||||
private static void EnsureSafeKey(string storageKey)
|
||||
{
|
||||
if (!SafeKeyPattern.IsMatch(storageKey))
|
||||
{
|
||||
throw new InvalidOperationException("Cover storage key is not in the expected format.");
|
||||
}
|
||||
}
|
||||
|
||||
private void TryDelete(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to delete cover file '{Path}'.", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user