feat(web): add local portfolio cover storage
This commit is contained in:
@@ -33,3 +33,6 @@ BACKUP_RETENTION_DAYS=7
|
|||||||
|
|
||||||
# Имя Docker volume для резервных копий БД
|
# Имя Docker volume для резервных копий БД
|
||||||
BACKUP_VOLUME_NAME=game_pgbackups
|
BACKUP_VOLUME_NAME=game_pgbackups
|
||||||
|
|
||||||
|
# Имя Docker volume для обложек портфолио (загружаемых мастерами)
|
||||||
|
PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers
|
||||||
|
|||||||
@@ -101,10 +101,12 @@ services:
|
|||||||
- "Discord__ClientId=${DISCORD_CLIENT_ID:-}"
|
- "Discord__ClientId=${DISCORD_CLIENT_ID:-}"
|
||||||
- "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}"
|
- "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}"
|
||||||
- "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}"
|
- "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}"
|
||||||
|
- "PortfolioCovers__StoragePath=/app/portfolio-covers"
|
||||||
ports:
|
ports:
|
||||||
- "${GMRELAY_WEB_PORT:-8080}:8080"
|
- "${GMRELAY_WEB_PORT:-8080}:8080"
|
||||||
volumes:
|
volumes:
|
||||||
- web_keys:/app/dataprotection-keys
|
- web_keys:/app/dataprotection-keys
|
||||||
|
- portfolio_covers:/app/portfolio-covers
|
||||||
networks:
|
networks:
|
||||||
- gmrelay
|
- gmrelay
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -120,6 +122,8 @@ volumes:
|
|||||||
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
|
name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys}
|
||||||
pgbackups:
|
pgbackups:
|
||||||
name: ${BACKUP_VOLUME_NAME:-game_pgbackups}
|
name: ${BACKUP_VOLUME_NAME:-game_pgbackups}
|
||||||
|
portfolio_covers:
|
||||||
|
name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers}
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
gmrelay:
|
gmrelay:
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ FROM mcr.microsoft.com/dotnet/aspnet:10.0-noble AS final
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends libgssapi-krb5-2 wget && rm -rf /var/lib/apt/lists/*
|
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 .
|
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
|
ENV ASPNETCORE_URLS=http://+:8080
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
USER $APP_UID
|
USER $APP_UID
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using GmRelay.Web;
|
|||||||
using GmRelay.Web.Components;
|
using GmRelay.Web.Components;
|
||||||
using GmRelay.Web.Health;
|
using GmRelay.Web.Health;
|
||||||
using GmRelay.Web.Services;
|
using GmRelay.Web.Services;
|
||||||
|
using GmRelay.Web.Services.Portfolio.Covers;
|
||||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||||
using Microsoft.AspNetCore.Authentication;
|
using Microsoft.AspNetCore.Authentication;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
@@ -37,6 +38,7 @@ builder.AddNpgsqlDataSource("gmrelaydb");
|
|||||||
|
|
||||||
// Add Services
|
// Add Services
|
||||||
builder.Services.AddSingleton<TelegramAuthService>();
|
builder.Services.AddSingleton<TelegramAuthService>();
|
||||||
|
builder.Services.AddPortfolioCoverStorage(builder.Configuration);
|
||||||
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
|
builder.Services.Configure<DiscordOAuthOptions>(builder.Configuration.GetSection("Discord"));
|
||||||
builder.Services.AddSingleton<DiscordAuthService>();
|
builder.Services.AddSingleton<DiscordAuthService>();
|
||||||
builder.Services.AddSingleton<DiscordOAuthStateStore>();
|
builder.Services.AddSingleton<DiscordOAuthStateStore>();
|
||||||
@@ -94,6 +96,8 @@ app.Use(async (context, next) =>
|
|||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.UsePortfolioCoverFiles();
|
||||||
|
|
||||||
app.UseAuthentication();
|
app.UseAuthentication();
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
app.UseAntiforgery();
|
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",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"Microsoft.AspNetCore": "Warning"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"PortfolioCovers": {
|
||||||
|
"StoragePath": "../../artifacts/portfolio-covers"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,172 @@
|
|||||||
|
using GmRelay.Web.Services.Portfolio.Covers;
|
||||||
|
|
||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public sealed class LocalPortfolioCoverStorageTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _storagePath;
|
||||||
|
private readonly LocalPortfolioCoverStorage _storage;
|
||||||
|
|
||||||
|
public LocalPortfolioCoverStorageTests()
|
||||||
|
{
|
||||||
|
_storagePath = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
"gmrelay-portfolio-covers-tests-" + Guid.NewGuid().ToString("N"));
|
||||||
|
Directory.CreateDirectory(_storagePath);
|
||||||
|
_storage = new LocalPortfolioCoverStorage(new PortfolioCoverStorageOptions
|
||||||
|
{
|
||||||
|
StoragePath = _storagePath
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_storagePath))
|
||||||
|
{
|
||||||
|
Directory.Delete(_storagePath, recursive: true);
|
||||||
|
}
|
||||||
|
GC.SuppressFinalize(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_ShouldPersistPngWithRandomProviderNeutralKey()
|
||||||
|
{
|
||||||
|
await using var stream = new MemoryStream(
|
||||||
|
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00]);
|
||||||
|
|
||||||
|
var result = await _storage.SaveAsync(stream, "image/png");
|
||||||
|
|
||||||
|
Assert.EndsWith(".png", result.StorageKey, StringComparison.Ordinal);
|
||||||
|
Assert.StartsWith("/portfolio-covers/", _storage.GetPublicPath(result.StorageKey), StringComparison.Ordinal);
|
||||||
|
Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("image/jpeg")]
|
||||||
|
[InlineData("image/png")]
|
||||||
|
[InlineData("image/webp")]
|
||||||
|
public async Task SaveAsync_ShouldRejectMismatchedSignature(string contentType)
|
||||||
|
{
|
||||||
|
await using var stream = new MemoryStream([0x00, 0x01, 0x02, 0x03]);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => _storage.SaveAsync(stream, contentType));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_ShouldPersistJpegWithCorrectSignature()
|
||||||
|
{
|
||||||
|
await using var stream = new MemoryStream(
|
||||||
|
[0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 0x4A, 0x46, 0x49, 0x46]);
|
||||||
|
|
||||||
|
var result = await _storage.SaveAsync(stream, "image/jpeg");
|
||||||
|
|
||||||
|
Assert.EndsWith(".jpg", result.StorageKey, StringComparison.Ordinal);
|
||||||
|
Assert.Equal("image/jpeg", result.ContentType);
|
||||||
|
Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_ShouldPersistWebpWithRiffWebpSignature()
|
||||||
|
{
|
||||||
|
await using var stream = new MemoryStream(
|
||||||
|
[0x52, 0x49, 0x46, 0x46, 0x1A, 0x00, 0x00, 0x00, 0x57, 0x45, 0x42, 0x50, 0x56, 0x50, 0x38]);
|
||||||
|
|
||||||
|
var result = await _storage.SaveAsync(stream, "image/webp");
|
||||||
|
|
||||||
|
Assert.EndsWith(".webp", result.StorageKey, StringComparison.Ordinal);
|
||||||
|
Assert.Equal("image/webp", result.ContentType);
|
||||||
|
Assert.True(File.Exists(Path.Combine(_storagePath, result.StorageKey)));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_ShouldRejectStreamLargerThanMaxBytes()
|
||||||
|
{
|
||||||
|
var oversized = new byte[LocalPortfolioCoverStorage.MaxBytes + 1];
|
||||||
|
await using var stream = new MemoryStream(oversized);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => _storage.SaveAsync(stream, "image/png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_ShouldRejectUnknownContentType()
|
||||||
|
{
|
||||||
|
await using var stream = new MemoryStream([0x89, 0x50, 0x4E, 0x47]);
|
||||||
|
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => _storage.SaveAsync(stream, "application/octet-stream"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteIfExistsAsync_ShouldRemoveExistingKey()
|
||||||
|
{
|
||||||
|
await using var stream = new MemoryStream(
|
||||||
|
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||||
|
var result = await _storage.SaveAsync(stream, "image/png");
|
||||||
|
var path = Path.Combine(_storagePath, result.StorageKey);
|
||||||
|
Assert.True(File.Exists(path));
|
||||||
|
|
||||||
|
await _storage.DeleteIfExistsAsync(result.StorageKey);
|
||||||
|
|
||||||
|
Assert.False(File.Exists(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteIfExistsAsync_ShouldBeNoOpForMissingKey()
|
||||||
|
{
|
||||||
|
var key = Guid.NewGuid().ToString("N") + ".png";
|
||||||
|
|
||||||
|
await _storage.DeleteIfExistsAsync(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteIfExistsAsync_ShouldRejectPathTraversal()
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => _storage.DeleteIfExistsAsync("../escape.png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DeleteIfExistsAsync_ShouldRejectKeyWithInvalidExtension()
|
||||||
|
{
|
||||||
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
|
() => _storage.DeleteIfExistsAsync(Guid.NewGuid().ToString("N") + ".gif"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void GetPublicPath_ShouldEscapeSpecialCharacters()
|
||||||
|
{
|
||||||
|
var key = "0123456789abcdef0123456789abcdef" + ".png";
|
||||||
|
|
||||||
|
var path = _storage.GetPublicPath(key);
|
||||||
|
|
||||||
|
Assert.Equal("/portfolio-covers/" + Uri.EscapeDataString(key), path);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_ShouldGenerateUniqueKeys()
|
||||||
|
{
|
||||||
|
await using var stream1 = new MemoryStream(
|
||||||
|
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||||
|
await using var stream2 = new MemoryStream(
|
||||||
|
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||||
|
|
||||||
|
var first = await _storage.SaveAsync(stream1, "image/png");
|
||||||
|
var second = await _storage.SaveAsync(stream2, "image/png");
|
||||||
|
|
||||||
|
Assert.NotEqual(first.StorageKey, second.StorageKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SaveAsync_ShouldNotLeaveTempFileBehind()
|
||||||
|
{
|
||||||
|
await using var stream = new MemoryStream(
|
||||||
|
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]);
|
||||||
|
|
||||||
|
await _storage.SaveAsync(stream, "image/png");
|
||||||
|
|
||||||
|
var tempFiles = Directory.GetFiles(_storagePath, "*.tmp");
|
||||||
|
Assert.Empty(tempFiles);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
namespace GmRelay.Bot.Tests.Web;
|
||||||
|
|
||||||
|
public sealed class PortfolioCoverRuntimeWiringTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Program_ShouldRegisterPortfolioCoverStorage()
|
||||||
|
{
|
||||||
|
var program = await ReadRepositoryFileAsync("src/GmRelay.Web/Program.cs");
|
||||||
|
|
||||||
|
Assert.Contains("AddPortfolioCoverStorage", program, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("UsePortfolioCoverFiles", program, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Compose_ShouldMountPortfolioCoversVolumeAndPassStoragePath()
|
||||||
|
{
|
||||||
|
var compose = await ReadRepositoryFileAsync("compose.yaml");
|
||||||
|
|
||||||
|
Assert.Contains("PortfolioCovers__StoragePath=/app/portfolio-covers", compose, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("portfolio_covers:/app/portfolio-covers", compose, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Dockerfile_ShouldCreateAndChownPortfolioCoversDirectory()
|
||||||
|
{
|
||||||
|
var dockerfile = await ReadRepositoryFileAsync("src/GmRelay.Web/Dockerfile");
|
||||||
|
|
||||||
|
Assert.Contains("mkdir -p /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("chown -R $APP_UID:$APP_UID /app/dataprotection-keys /app/portfolio-covers", dockerfile, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task DevelopmentSettings_ShouldConfigurePortfolioCoversStoragePath()
|
||||||
|
{
|
||||||
|
var developmentSettings = await ReadRepositoryFileAsync("src/GmRelay.Web/appsettings.Development.json");
|
||||||
|
|
||||||
|
Assert.Contains("../../artifacts/portfolio-covers", developmentSettings, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnvExample_ShouldDocumentPortfolioCoversVolumeName()
|
||||||
|
{
|
||||||
|
var envExample = await ReadRepositoryFileAsync(".env.example");
|
||||||
|
|
||||||
|
Assert.Contains("PORTFOLIO_COVERS_VOLUME_NAME", envExample, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Compose_ShouldDeclarePortfolioCoversNamedVolume()
|
||||||
|
{
|
||||||
|
var compose = await ReadRepositoryFileAsync("compose.yaml");
|
||||||
|
|
||||||
|
Assert.Contains("portfolio_covers:", compose, StringComparison.Ordinal);
|
||||||
|
Assert.Contains("PORTFOLIO_COVERS_VOLUME_NAME", compose, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> ReadRepositoryFileAsync(string relativePath)
|
||||||
|
{
|
||||||
|
var directory = new DirectoryInfo(AppContext.BaseDirectory);
|
||||||
|
while (directory is not null)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(directory.FullName, relativePath);
|
||||||
|
if (File.Exists(candidate))
|
||||||
|
{
|
||||||
|
return await File.ReadAllTextAsync(candidate);
|
||||||
|
}
|
||||||
|
|
||||||
|
directory = directory.Parent;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new FileNotFoundException($"Could not locate repository file '{relativePath}'.");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user