feat(web): add local portfolio cover storage

This commit is contained in:
2026-06-02 12:35:00 +03:00
parent 7d1489445e
commit e5945288ac
11 changed files with 575 additions and 1 deletions
+2 -1
View File
@@ -20,7 +20,8 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS final
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 wget && rm -rf /var/lib/apt/lists/*
COPY --from=build /app/publish .
RUN mkdir -p /app/dataprotection-keys && chown -R $APP_UID:$APP_UID /app/dataprotection-keys
RUN mkdir -p /app/dataprotection-keys /app/portfolio-covers \
&& chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers
ENV ASPNETCORE_URLS=http://+:8080
EXPOSE 8080
USER $APP_UID
+4
View File
@@ -2,6 +2,7 @@ using GmRelay.Web;
using GmRelay.Web.Components;
using GmRelay.Web.Health;
using GmRelay.Web.Services;
using GmRelay.Web.Services.Portfolio.Covers;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.DataProtection;
@@ -37,6 +38,7 @@ builder.AddNpgsqlDataSource("gmrelaydb");
// Add Services
builder.Services.AddSingleton<TelegramAuthService>();
builder.Services.AddPortfolioCoverStorage(builder.Configuration);
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
builder.Services.AddSingleton<DiscordAuthService>();
builder.Services.AddSingleton<DiscordOAuthStateStore>();
@@ -94,6 +96,8 @@ app.Use(async (context, next) =>
await next();
});
app.UsePortfolioCoverFiles();
app.UseAuthentication();
app.UseAuthorization();
app.UseAntiforgery();
@@ -0,0 +1,15 @@
namespace GmRelay.Web.Services.Portfolio.Covers;
public sealed record PortfolioCoverUploadResult(string StorageKey, string ContentType);
public interface IPortfolioCoverStorage
{
Task<PortfolioCoverUploadResult> SaveAsync(
Stream content,
string contentType,
CancellationToken cancellationToken = default);
Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default);
string GetPublicPath(string storageKey);
}
@@ -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);
}
}
}
@@ -0,0 +1,82 @@
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
namespace GmRelay.Web.Services.Portfolio.Covers;
public static class PortfolioCoverStorageExtensions
{
public static IServiceCollection AddPortfolioCoverStorage(
this IServiceCollection services,
IConfiguration configuration)
{
ArgumentNullException.ThrowIfNull(services);
ArgumentNullException.ThrowIfNull(configuration);
services
.AddOptions<PortfolioCoverStorageOptions>()
.Bind(configuration.GetSection(PortfolioCoverStorageOptions.SectionName))
.Validate(
o => !string.IsNullOrWhiteSpace(o.StoragePath),
"PortfolioCovers:StoragePath must be configured.")
.ValidateOnStart();
services.AddSingleton<IPortfolioCoverStorage>(sp =>
{
var options = sp.GetRequiredService<
Microsoft.Extensions.Options.IOptions<PortfolioCoverStorageOptions>>().Value;
var logger = sp.GetService<ILoggerFactory>()?.CreateLogger<LocalPortfolioCoverStorage>()
?? NullLogger<LocalPortfolioCoverStorage>.Instance;
return new LocalPortfolioCoverStorage(options, logger);
});
return services;
}
public static WebApplication UsePortfolioCoverFiles(this WebApplication app)
{
ArgumentNullException.ThrowIfNull(app);
var options = app.Services.GetRequiredService<
Microsoft.Extensions.Options.IOptions<PortfolioCoverStorageOptions>>().Value;
var storagePath = Path.IsPathRooted(options.StoragePath)
? options.StoragePath
: Path.Combine(app.Environment.ContentRootPath, options.StoragePath);
Directory.CreateDirectory(storagePath);
var contentTypeProvider = new FileExtensionContentTypeProvider();
if (!contentTypeProvider.Mappings.ContainsKey(".jpg"))
{
contentTypeProvider.Mappings[".jpg"] = "image/jpeg";
}
if (!contentTypeProvider.Mappings.ContainsKey(".png"))
{
contentTypeProvider.Mappings[".png"] = "image/png";
}
if (!contentTypeProvider.Mappings.ContainsKey(".webp"))
{
contentTypeProvider.Mappings[".webp"] = "image/webp";
}
app.UseStaticFiles(new StaticFileOptions
{
FileProvider = new PhysicalFileProvider(storagePath),
RequestPath = "/portfolio-covers",
ContentTypeProvider = contentTypeProvider,
OnPrepareResponse = ctx =>
{
ctx.Context.Response.Headers["Cache-Control"] = "public, max-age=31536000, immutable";
}
});
return app;
}
}
@@ -0,0 +1,8 @@
namespace GmRelay.Web.Services.Portfolio.Covers;
public sealed class PortfolioCoverStorageOptions
{
public const string SectionName = "PortfolioCovers";
public string StoragePath { get; set; } = string.Empty;
}
@@ -4,5 +4,8 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"PortfolioCovers": {
"StoragePath": "../../artifacts/portfolio-covers"
}
}