diff --git a/.env.example b/.env.example index 47db44c..0edcd37 100644 --- a/.env.example +++ b/.env.example @@ -33,3 +33,6 @@ BACKUP_RETENTION_DAYS=7 # Имя Docker volume для резервных копий БД BACKUP_VOLUME_NAME=game_pgbackups + +# Имя Docker volume для обложек портфолио (загружаемых мастерами) +PORTFOLIO_COVERS_VOLUME_NAME=gmrelay_portfolio_covers diff --git a/compose.yaml b/compose.yaml index 0ed1924..bdaee9a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -101,10 +101,12 @@ services: - "Discord__ClientId=${DISCORD_CLIENT_ID:-}" - "Discord__ClientSecret=${DISCORD_CLIENT_SECRET:-}" - "Discord__RedirectUri=${DISCORD_REDIRECT_URI:-}" + - "PortfolioCovers__StoragePath=/app/portfolio-covers" ports: - "${GMRELAY_WEB_PORT:-8080}:8080" volumes: - web_keys:/app/dataprotection-keys + - portfolio_covers:/app/portfolio-covers networks: - gmrelay healthcheck: @@ -120,6 +122,8 @@ volumes: name: ${WEB_KEYS_VOLUME_NAME:-gmrelay_web_keys} pgbackups: name: ${BACKUP_VOLUME_NAME:-game_pgbackups} + portfolio_covers: + name: ${PORTFOLIO_COVERS_VOLUME_NAME:-gmrelay_portfolio_covers} networks: gmrelay: diff --git a/src/GmRelay.Web/Dockerfile b/src/GmRelay.Web/Dockerfile index 970cd35..9394d78 100644 --- a/src/GmRelay.Web/Dockerfile +++ b/src/GmRelay.Web/Dockerfile @@ -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 diff --git a/src/GmRelay.Web/Program.cs b/src/GmRelay.Web/Program.cs index f4308b0..790cf97 100644 --- a/src/GmRelay.Web/Program.cs +++ b/src/GmRelay.Web/Program.cs @@ -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(); +builder.Services.AddPortfolioCoverStorage(builder.Configuration); builder.Services.Configure(builder.Configuration.GetSection("Discord")); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -94,6 +96,8 @@ app.Use(async (context, next) => await next(); }); +app.UsePortfolioCoverFiles(); + app.UseAuthentication(); app.UseAuthorization(); app.UseAntiforgery(); diff --git a/src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs b/src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs new file mode 100644 index 0000000..bea14c0 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/Covers/IPortfolioCoverStorage.cs @@ -0,0 +1,15 @@ +namespace GmRelay.Web.Services.Portfolio.Covers; + +public sealed record PortfolioCoverUploadResult(string StorageKey, string ContentType); + +public interface IPortfolioCoverStorage +{ + Task SaveAsync( + Stream content, + string contentType, + CancellationToken cancellationToken = default); + + Task DeleteIfExistsAsync(string storageKey, CancellationToken cancellationToken = default); + + string GetPublicPath(string storageKey); +} diff --git a/src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs b/src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs new file mode 100644 index 0000000..1a95d36 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/Covers/LocalPortfolioCoverStorage.cs @@ -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 _logger; + + public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options) + : this(options, logger: null) + { + } + + public LocalPortfolioCoverStorage(PortfolioCoverStorageOptions options, ILogger? logger) + { + ArgumentNullException.ThrowIfNull(options); + if (string.IsNullOrWhiteSpace(options.StoragePath)) + { + throw new InvalidOperationException("PortfolioCovers:StoragePath must be configured."); + } + + _storagePath = options.StoragePath; + _logger = logger ?? NullLogger.Instance; + } + + public async Task 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); + } + } +} diff --git a/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs b/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs new file mode 100644 index 0000000..a99c5a3 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageExtensions.cs @@ -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() + .Bind(configuration.GetSection(PortfolioCoverStorageOptions.SectionName)) + .Validate( + o => !string.IsNullOrWhiteSpace(o.StoragePath), + "PortfolioCovers:StoragePath must be configured.") + .ValidateOnStart(); + + services.AddSingleton(sp => + { + var options = sp.GetRequiredService< + Microsoft.Extensions.Options.IOptions>().Value; + var logger = sp.GetService()?.CreateLogger() + ?? NullLogger.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>().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; + } +} diff --git a/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs b/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs new file mode 100644 index 0000000..79979a5 --- /dev/null +++ b/src/GmRelay.Web/Services/Portfolio/Covers/PortfolioCoverStorageOptions.cs @@ -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; +} diff --git a/src/GmRelay.Web/appsettings.Development.json b/src/GmRelay.Web/appsettings.Development.json index 0c208ae..25f234b 100644 --- a/src/GmRelay.Web/appsettings.Development.json +++ b/src/GmRelay.Web/appsettings.Development.json @@ -4,5 +4,8 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "PortfolioCovers": { + "StoragePath": "../../artifacts/portfolio-covers" } } diff --git a/tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs b/tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs new file mode 100644 index 0000000..bf7513d --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/LocalPortfolioCoverStorageTests.cs @@ -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( + () => _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( + () => _storage.SaveAsync(stream, "image/png")); + } + + [Fact] + public async Task SaveAsync_ShouldRejectUnknownContentType() + { + await using var stream = new MemoryStream([0x89, 0x50, 0x4E, 0x47]); + + await Assert.ThrowsAsync( + () => _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( + () => _storage.DeleteIfExistsAsync("../escape.png")); + } + + [Fact] + public async Task DeleteIfExistsAsync_ShouldRejectKeyWithInvalidExtension() + { + await Assert.ThrowsAsync( + () => _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); + } +} diff --git a/tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs b/tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs new file mode 100644 index 0000000..a7429e9 --- /dev/null +++ b/tests/GmRelay.Bot.Tests/Web/PortfolioCoverRuntimeWiringTests.cs @@ -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 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}'."); + } +}