From 28c08a479f82ffca14200731147e48cb498a21cb Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 10:43:17 -0500 Subject: [PATCH 1/3] feat: upload current member avatars with native media APIs --- SgfDevs.Tests/MemberAvatarTests.cs | 214 ++++++++++++++++++ SgfDevs/Controllers/MemberAvatarController.cs | 61 +++++ .../Controllers/MemberProfileController.cs | 2 +- SgfDevs/Dev/MemberAvatarImage.cs | 74 ++++++ SgfDevs/Dev/MemberAvatarService.cs | 61 +++++ SgfDevs/Program.cs | 8 +- SgfDevs/ViewModels/MemberAvatarDto.cs | 13 ++ 7 files changed, 429 insertions(+), 4 deletions(-) create mode 100644 SgfDevs.Tests/MemberAvatarTests.cs create mode 100644 SgfDevs/Controllers/MemberAvatarController.cs create mode 100644 SgfDevs/Dev/MemberAvatarImage.cs create mode 100644 SgfDevs/Dev/MemberAvatarService.cs create mode 100644 SgfDevs/ViewModels/MemberAvatarDto.cs diff --git a/SgfDevs.Tests/MemberAvatarTests.cs b/SgfDevs.Tests/MemberAvatarTests.cs new file mode 100644 index 0000000..5d52491 --- /dev/null +++ b/SgfDevs.Tests/MemberAvatarTests.cs @@ -0,0 +1,214 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using System.Reflection; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using SGFDevs.Controllers; +using SGFDevs.ViewModels; +using SgfDevs.Dev; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.PixelFormats; +using SixLabors.ImageSharp.Metadata.Profiles.Exif; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Security; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; +using Xunit; + +namespace SgfDevs.Tests; + +public class MemberAvatarTests +{ + [Fact] + public async Task CurrentMemberOnlyAndNoCallerSelections() + { + var f = new Fixture { Authenticated = false }; + Assert.IsType((await f.Upload(await ImageBytes())).Result); + f.Authenticated = true; f.Current = false; + Assert.IsType((await f.Upload(await ImageBytes())).Result); + f.Current = true; + Assert.IsType((await f.Upload(await ImageBytes(), extra: true)).Result); + Assert.Empty(f.Writes); + Assert.Equal("old avatar", f.Member.GetValue("profileImage")); + } + + [Fact] + public async Task RejectsMalformedOversizedAndNonImagesWithoutWrites() + { + var f = new Fixture(); + foreach (var bytes in new[] { Array.Empty(), Encoding.UTF8.GetBytes(""), new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 } }) + Assert.IsType((await f.Upload(bytes)).Result); + Assert.Equal(413, Assert.IsType((await f.Upload(new byte[8 * 1024 * 1024 + 1])).Result).StatusCode); + Assert.Empty(f.Writes); + } + + [Theory] + [InlineData(8193, 1, false)] + [InlineData(4001, 4000, false)] + [InlineData(20, 20, true)] + public async Task RejectsDimensionsPixelsAndAnimatedPngBeforeWriting(int width, int height, bool animate) + { + var f = new Fixture(); + Assert.IsType((await f.Upload(await ImageBytes(width, height, animate: animate))).Result); + Assert.Empty(f.Writes); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ReencodesSquareWithoutMetadataAndAssignsOnlyNewImage(bool jpeg) + { + var f = new Fixture(); + var result = Assert.IsType(Assert.IsType((await f.Upload(await ImageBytes(800, 600, jpeg))).Result).Value); + Assert.Equal(new[] { "file", "media", "member", "complete" }, f.Writes); + Assert.Equal("/media/synthetic/" + f.Filename + "?width=200", result.ProfileImageUrl); + Assert.EndsWith(jpeg ? ".jpg" : ".png", f.Filename); + Assert.DoesNotContain("caller", f.Filename); + using var image = Image.Load(f.Stored!); + Assert.Equal(512, image.Width); Assert.Equal(512, image.Height); Assert.Single(image.Frames); + Assert.Null(image.Metadata.ExifProfile); + Assert.Null(image.Metadata.IccProfile); + var selection = JsonDocument.Parse(f.Member.GetValue("profileImage")!).RootElement; + Assert.Equal(f.NewMedia.Key, selection[0].GetProperty("mediaKey").GetGuid()); + Assert.Single(selection.EnumerateArray()); + Assert.Equal("unchanged", f.Member.GetValue("aboutText")); + } + + [Fact] + public async Task MissingFolderSchemaOrStorageAndCancelledSaveFailSafely() + { + foreach (var mode in new[] { "folder", "schema", "storage", "cancel" }) + { + var f = new Fixture { Failure = mode }; + Assert.Equal(503, Assert.IsType((await f.Upload(await ImageBytes())).Result).StatusCode); + Assert.DoesNotContain("complete", f.Writes); + if (mode != "cancel") Assert.Equal("old avatar", f.Member.GetValue("profileImage")); + } + } + + private static async Task ImageBytes(int width = 32, int height = 16, bool jpeg = false, bool animate = false) + { + using var image = new Image(width, height, new Rgba32(40, 80, 120)); + image.Metadata.ExifProfile = new ExifProfile(); + image.Metadata.ExifProfile.SetValue(ExifTag.Artist, "strip this metadata"); + if (animate) image.Frames.AddFrame(image.Frames.RootFrame); + using var stream = new MemoryStream(); + if (jpeg) await image.SaveAsJpegAsync(stream); else await image.SaveAsPngAsync(stream); + return stream.ToArray(); + } + + private sealed class Fixture + { + public bool Authenticated = true, Current = true; + public string Failure = "", Filename = ""; + public byte[]? Stored; + public Member Member; + public Media NewMedia; + public List Writes = []; + private readonly MemberAvatarController controller; + public Fixture() + { + var helper = new DefaultShortStringHelper(new DefaultShortStringHelperConfig()); + var type = new MemberType(helper, -1) { Alias = "Member" }; + type.AddPropertyType(new PropertyType(helper, "Umbraco.MediaPicker3", ValueStorageType.Ntext, "profileImage")); + type.AddPropertyType(new PropertyType(helper, "Umbraco.TextBox", ValueStorageType.Nvarchar, "aboutText")); + Member = new Member("Synthetic", "synthetic@example.test", "Synthetic", type); + Member.SetValue("profileImage", "old avatar"); Member.SetValue("aboutText", "unchanged"); + var folderType = new MediaType(helper, -1) { Alias = "Folder" }; + var folder = new Media("Members", -1, folderType) { Id = 321 }; + var imageType = new MediaType(helper, -1) { Alias = "Image" }; + imageType.AddPropertyType(new PropertyType(helper, "Umbraco.ImageCropper", ValueStorageType.Ntext, "umbracoFile")); + NewMedia = new Media("new", folder.Id, imageType); + var media = Proxy((method, args) => method.Name switch + { + "GetRootMedia" => Failure == "folder" ? Array.Empty() : new IMedia[] { folder }, + "CreateMedia" => Create(args!), + "Save" => SaveMedia(args!), + _ => throw new Exception("Unexpected media operation " + method.Name) + }); + var members = Proxy((method, args) => method.Name switch + { + "GetById" => Read(args!), + "Save" => SaveMember(args!), + _ => throw new Exception(method.Name) + }); + var scope = Proxy((method, _) => + { + if (method.Name == "Complete") { Writes.Add("complete"); return true; } + if (method.Name == "Dispose") return null; + throw new Exception(method.Name); + }); + var scopes = Proxy((_, _) => scope); + var fs = Proxy((method, args) => method.Name == "GetUrl" ? "/media/" + args![0] : throw new Exception(method.Name)); + var files = new MediaFileManager(fs, null!, NullLogger.Instance, helper, new ServiceCollection().BuildServiceProvider(), new Lazy(() => scopes)); + var generator = Proxy((method, args) => + { + Assert.Equal("TryGetMediaPath", method.Name); args![2] = "synthetic/" + Filename; return true; + }); + var generators = new MediaUrlGeneratorCollection(() => new[] { generator }); + var json = Proxy((_, args) => JsonSerializer.Serialize(args![0])); + var dataType = Proxy((_, _) => null); + var dataTypes = Proxy((_, _) => Task.FromResult(Failure == "schema" ? null : dataType)); + var valueEditor = Proxy((_, _) => Array.Empty()); + var editors = new PropertyEditorCollection(new DataEditorCollection(() => new[] { + Proxy((method, _) => method.Name == "get_Alias" ? "Umbraco.MediaPicker3" : valueEditor) })); + var service = new FakeFiles(this, members, media, files, generators, helper, scopes, json, dataTypes, editors); + var identity = new MemberIdentityUser { Key = Member.Key, UserName = "Synthetic" }; + var manager = Proxy((_, _) => Task.FromResult(Current ? identity : null)); + var auth = Proxy((_, args) => + { + Assert.Equal(IdentityConstants.ApplicationScheme, args![1]); + return Task.FromResult(Authenticated ? AuthenticateResult.Success(new AuthenticationTicket( + new ClaimsPrincipal(new ClaimsIdentity(IdentityConstants.ApplicationScheme)), IdentityConstants.ApplicationScheme)) : AuthenticateResult.NoResult()); + }); + controller = new(manager, service, NullLogger.Instance) + { ControllerContext = new() { HttpContext = new DefaultHttpContext { RequestServices = new ServiceCollection().AddSingleton(auth).BuildServiceProvider() } } }; + } + private IMedia Create(object?[] args) + { + Assert.Equal(321, args[1]); Assert.Equal("Image", args[2]); return NewMedia; + } + private IMember Read(object?[] args) { Assert.Equal(Member.Key, args[0]); return Member; } + private Attempt SaveMedia(object?[] args) + { Assert.Same(NewMedia, args[0]); Writes.Add("media"); return Attempt.Succeed(null); } + private Attempt SaveMember(object?[] args) + { Assert.Same(Member, args[0]); Writes.Add("member"); return Failure == "cancel" ? Attempt.Fail(null) : Attempt.Succeed(null); } + public Task> Upload(byte[] bytes, bool extra = false) + { + var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "caller.svg"); + controller.Request.Form = new FormCollection(extra ? new() { ["memberId"] = "foreign" } : new(), new FormFileCollection { file }); + return controller.Upload(new() { File = file }); + } + private sealed class FakeFiles(Fixture fixture, IMemberService members, IMediaService media, MediaFileManager files, + MediaUrlGeneratorCollection generators, IShortStringHelper strings, ICoreScopeProvider scopes, IJsonSerializer json, + IDataTypeService dataTypes, PropertyEditorCollection editors) + : MemberAvatarService(members, media, files, generators, strings, null!, scopes, json, dataTypes, editors) + { + protected override void WriteFile(IMedia media, string filename, Stream content) + { + if (fixture.Failure == "storage") throw new IOException("synthetic storage failure"); + fixture.Filename = filename; + using var memory = new MemoryStream(); content.CopyTo(memory); fixture.Stored = memory.ToArray(); + fixture.Writes.Add("file"); media.SetValue("umbracoFile", "synthetic/" + filename); + } + } + } + private static T Proxy(Func handler) where T : class + { + var proxy = DispatchProxy.Create(); + ((MemberLoginTests.InterfaceProxy)(object)proxy).Handler = handler; return proxy; + } +} diff --git a/SgfDevs/Controllers/MemberAvatarController.cs b/SgfDevs/Controllers/MemberAvatarController.cs new file mode 100644 index 0000000..fb4bff7 --- /dev/null +++ b/SgfDevs/Controllers/MemberAvatarController.cs @@ -0,0 +1,61 @@ +#nullable enable +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using SGFDevs.ViewModels; +using SgfDevs.Dev; +using SixLabors.ImageSharp; +using Umbraco.Cms.Core.Security; + +namespace SGFDevs.Controllers; + +[ApiController] +[AllowAnonymous] +[DisableCors] +public sealed class MemberAvatarController( + IMemberManager manager, MemberAvatarService avatars, ILogger logger) : ControllerBase +{ + [HttpPost("api/v1/member/avatar", Name = "Member_AvatarUpload")] + [Consumes("multipart/form-data")] + [RequestSizeLimit(MemberAvatarImage.RequestBytes)] + [RequestFormLimits(MultipartBodyLengthLimit = MemberAvatarImage.MaxBytes, ValueCountLimit = 0)] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status401Unauthorized)] + [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] + [ProducesResponseType(StatusCodes.Status429TooManyRequests)] + [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] + public async Task> Upload([FromForm] MemberAvatarRequest request) + { + var auth = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); + HttpContext.User = auth.Succeeded && auth.Principal is not null ? auth.Principal : new(); + var member = auth.Succeeded ? await manager.GetCurrentMemberAsync() : null; + if (member is null) return Unauthorized(); + var form = await Request.ReadFormAsync(HttpContext.RequestAborted); + if (Request.Query.Count != 0 || form.Count != 0 || form.Files.Count != 1 || form.Files[0].Name != "file" || request.File is null || request.File.Length == 0) + return BadRequest(); + if (request.File.Length > MemberAvatarImage.MaxBytes) return StatusCode(413); + try + { + using var source = request.File.OpenReadStream(); + var prepared = await MemberAvatarImage.PrepareAsync(source, HttpContext.RequestAborted); + using var content = prepared.Content; + var url = await avatars.SaveAsync(member.Key, content, prepared.Extension); + return url is null ? StatusCode(503) : Ok(new MemberAvatarResult(url)); + } + catch (UnknownImageFormatException) { return BadRequest(); } + catch (InvalidImageContentException) { return BadRequest(); } + catch (NotSupportedException) { return BadRequest(); } + catch (Exception) + { + logger.LogWarning("Member avatar could not be uploaded."); + return StatusCode(503); + } + } +} diff --git a/SgfDevs/Controllers/MemberProfileController.cs b/SgfDevs/Controllers/MemberProfileController.cs index 695263d..3f5267f 100644 --- a/SgfDevs/Controllers/MemberProfileController.cs +++ b/SgfDevs/Controllers/MemberProfileController.cs @@ -47,7 +47,7 @@ public async Task> Profile() var member = members.GetById(user.Key); var options = choices.Get(); if (member is null || !HasSchema(member) || options is null) return StatusCode(503); - var image = manager.AsPublishedMember(user)?.Value("profileImage")?.GetCropUrl(width: 200, height: 200); + var image = manager.AsPublishedMember(user)?.Value("profileImage")?.GetCropUrl(width: 200); return Ok(new MemberProfileEditDto(Read(member), PublicHomeBuilder.GetSafePathOrHttpUrl(image), options.Value.Skills, options.Value.Groups)); } diff --git a/SgfDevs/Dev/MemberAvatarImage.cs b/SgfDevs/Dev/MemberAvatarImage.cs new file mode 100644 index 0000000..2436f6f --- /dev/null +++ b/SgfDevs/Dev/MemberAvatarImage.cs @@ -0,0 +1,74 @@ +#nullable enable +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SixLabors.ImageSharp; +using SixLabors.ImageSharp.Formats; +using SixLabors.ImageSharp.Formats.Jpeg; +using SixLabors.ImageSharp.Formats.Png; +using SixLabors.ImageSharp.Processing; + +namespace SgfDevs.Dev; + +internal static class MemberAvatarImage +{ + public const int MaxBytes = 8 * 1024 * 1024; + public const int RequestBytes = MaxBytes + 64 * 1024; + public const int MaxDimension = 8192; + public const long MaxPixels = 16_000_000; + + public static async Task<(MemoryStream Content, string Extension)> PrepareAsync(Stream source, CancellationToken cancellation) + { + using var input = new MemoryStream(); + var buffer = new byte[81920]; + int count; + while ((count = await source.ReadAsync(buffer, cancellation)) > 0) + { + if (input.Length + count > MaxBytes) throw new InvalidImageContentException("Image exceeds 8 MiB."); + await input.WriteAsync(buffer.AsMemory(0, count), cancellation); + } + input.Position = 0; + // Identify bounds dimensions before allocating full-size pixels. + // PNG's frame counter includes the initial frame-control chunk. Three detects a second frame. + var options = new DecoderOptions { MaxFrames = 3 }; + var info = await Image.IdentifyAsync(options, input, cancellation); + var format = info.Metadata.DecodedImageFormat?.Name; + if (format is not ("JPEG" or "PNG") || info.Width < 1 || info.Height < 1 || + info.Width > MaxDimension || info.Height > MaxDimension || (long)info.Width * info.Height > MaxPixels || + info.FrameMetadataCollection.Count > 1) + throw new InvalidImageContentException("Use a single-frame JPEG or PNG within the image limits."); + // ImageSharp 3's JPEG/PNG Identify does not populate frame metadata. + // Use its bounded thumbnail probe to reject additional frames before the final decode. + input.Position = 0; + using (var probe = await Image.LoadAsync(new DecoderOptions + { + MaxFrames = 3, TargetSize = new Size(1, 1), SkipMetadata = true + }, input, cancellation)) + { + if (probe.Frames.Count != 1 || (format == "PNG" && probe.Metadata.GetPngMetadata().AnimateRootFrame)) throw new InvalidImageContentException("Animation is not supported."); + } + input.Position = 0; + using var image = await Image.LoadAsync(options, input, cancellation); + if (image.Frames.Count != 1) throw new InvalidImageContentException("Animation is not supported."); + var side = Math.Min(512, Math.Min(image.Width, image.Height)); + image.Mutate(x => x.AutoOrient().Resize(new ResizeOptions + { + Size = new Size(side, side), Mode = ResizeMode.Crop, Position = AnchorPositionMode.Center + })); + image.Metadata.ExifProfile = null; + image.Metadata.IccProfile = null; + image.Metadata.IptcProfile = null; + image.Metadata.XmpProfile = null; + image.Metadata.GetPngMetadata().TextData.Clear(); + var output = new MemoryStream(); + try + { + if (format == "JPEG") await image.SaveAsJpegAsync(output, new JpegEncoder { Quality = 85, SkipMetadata = true }, cancellation); + else await image.SaveAsPngAsync(output, new PngEncoder { SkipMetadata = true }, cancellation); + output.Position = 0; + return (output, format == "JPEG" ? ".jpg" : ".png"); + } + catch { output.Dispose(); throw; } + } +} diff --git a/SgfDevs/Dev/MemberAvatarService.cs b/SgfDevs/Dev/MemberAvatarService.cs new file mode 100644 index 0000000..3eb2908 --- /dev/null +++ b/SgfDevs/Dev/MemberAvatarService.cs @@ -0,0 +1,61 @@ +#nullable enable +using System; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using Umbraco.Cms.Core.Models.Validation; +using Umbraco.Cms.Core; +using Umbraco.Cms.Core.IO; +using Umbraco.Cms.Core.Models; +using Umbraco.Cms.Core.PropertyEditors; +using Umbraco.Cms.Core.Scoping; +using Umbraco.Cms.Core.Serialization; +using Umbraco.Cms.Core.Services; +using Umbraco.Cms.Core.Strings; +using Umbraco.Extensions; + +namespace SgfDevs.Dev; + +public class MemberAvatarService( + IMemberService members, IMediaService mediaService, MediaFileManager files, + MediaUrlGeneratorCollection generators, IShortStringHelper strings, + IContentTypeBaseServiceProvider contentTypes, ICoreScopeProvider scopes, IJsonSerializer json, + IDataTypeService dataTypes, PropertyEditorCollection editors) +{ + public async Task SaveAsync(Guid memberKey, Stream content, string extension) + { + using var scope = scopes.CreateCoreScope(); + var member = members.GetById(memberKey); + var property = member?.Properties.FirstOrDefault(x => x.Alias == "profileImage"); + var folder = mediaService.GetRootMedia().FirstOrDefault(x => + x.Name.InvariantEquals("Members") && x.ContentType.Alias == Constants.Conventions.MediaTypes.Folder && !x.Trashed); + if (member?.ContentType.Alias != "Member" || property?.PropertyType.PropertyEditorAlias != "Umbraco.MediaPicker3" || folder is null) + return null; + var dataType = await dataTypes.GetAsync(property.PropertyType.DataTypeKey); + if (dataType is null || !editors.TryGet("Umbraco.MediaPicker3", out var editor)) return null; + var validator = editor.GetValueEditor(dataType.ConfigurationObject); + + // Never look up, reuse or delete the member's previous image or a caller-selected media item. + var media = mediaService.CreateMedia("Avatar " + Guid.NewGuid().ToString("N"), folder.Id, Constants.Conventions.MediaTypes.Image); + if (!media.Properties.Any(x => x.Alias == Constants.Conventions.Media.File && x.PropertyType.PropertyEditorAlias == "Umbraco.ImageCropper")) + return null; + WriteFile(media, Guid.NewGuid().ToString("N") + extension, content); + if (!mediaService.Save(media).Success || !media.TryGetMediaPath(Constants.Conventions.Media.File, generators, out var path)) + return null; + var url = PublicHomeBuilder.GetSafePathOrHttpUrl(files.FileSystem.GetUrl(path)); + if (url is null || url.Contains('?')) return null; + // MediaPicker3 stores an array of key/mediaKey/crops/focalPoint, not legacy UDI strings. + // Its installed MediaWithCropsDto is internal. Serialize the same shape with Umbraco. + var value = json.Serialize(new[] { new { key = Guid.NewGuid(), mediaKey = media.Key, crops = Array.Empty(), focalPoint = (object?)null } }); + if (validator.Validate(value, property.PropertyType.Mandatory, property.PropertyType.ValidationRegExp, + PropertyValidationContext.Empty()).Any()) return null; + member.SetValue("profileImage", value); + if (!members.Save(member).Success || members.GetById(memberKey)?.GetValue("profileImage") != value) + return null; + scope.Complete(); + return url + "?width=200"; + } + + protected virtual void WriteFile(IMedia media, string filename, Stream content) => media.SetValue( + files, generators, strings, contentTypes, Constants.Conventions.Media.File, filename, content); +} diff --git a/SgfDevs/Program.cs b/SgfDevs/Program.cs index a2ca9a4..0253195 100644 --- a/SgfDevs/Program.cs +++ b/SgfDevs/Program.cs @@ -95,7 +95,7 @@ serverRole is ServerRole.Unknown || builder.Services.AddOpenApi("sgf-member-v1", options => { options.ShouldInclude = description => description.ActionDescriptor is ControllerActionDescriptor action && - action.AttributeRouteInfo?.Name is "Member_Login" or "Member_Logout" or "Member_Session" or "Member_Register" or "Member_ForgotPassword" or "Member_ResetPassword" or "Member_Profile" or "Member_ProfileUpdate"; + action.AttributeRouteInfo?.Name is "Member_Login" or "Member_Logout" or "Member_Session" or "Member_Register" or "Member_ForgotPassword" or "Member_ResetPassword" or "Member_Profile" or "Member_ProfileUpdate" or "Member_AvatarUpload"; options.AddDocumentTransformer((document, _, _) => { document.Info = new OpenApiInfo { Title = "SGF private member bridge", Version = "1.0" }; @@ -109,10 +109,11 @@ serverRole is ServerRole.Unknown || (string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/login", StringComparison.OrdinalIgnoreCase) || string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/register", StringComparison.OrdinalIgnoreCase) || string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/forgot-password", StringComparison.OrdinalIgnoreCase) || - string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/reset-password", StringComparison.OrdinalIgnoreCase)) && HttpMethods.IsPost(context.Request.Method) + string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/reset-password", StringComparison.OrdinalIgnoreCase) || + string.Equals(context.Request.Path.Value?.TrimEnd('/'), "/api/v1/member/avatar", StringComparison.OrdinalIgnoreCase)) && HttpMethods.IsPost(context.Request.Method) ? RateLimitPartition.GetFixedWindowLimiter(context.Request.Path.Value!.TrimEnd('/').ToLowerInvariant(), _ => new FixedWindowRateLimiterOptions { - PermitLimit = 20, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true + PermitLimit = context.Request.Path.Value!.TrimEnd('/').Equals("/api/v1/member/avatar", StringComparison.OrdinalIgnoreCase) ? 5 : 20, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true }) : RateLimitPartition.GetNoLimiter("other")); }); @@ -133,6 +134,7 @@ serverRole is ServerRole.Unknown || builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/SgfDevs/ViewModels/MemberAvatarDto.cs b/SgfDevs/ViewModels/MemberAvatarDto.cs new file mode 100644 index 0000000..803ad8c --- /dev/null +++ b/SgfDevs/ViewModels/MemberAvatarDto.cs @@ -0,0 +1,13 @@ +#nullable enable +using System.ComponentModel.DataAnnotations; +using Microsoft.AspNetCore.Http; + +namespace SGFDevs.ViewModels; + +public sealed class MemberAvatarRequest +{ + [Required] + public IFormFile File { get; set; } = null!; +} + +public sealed record MemberAvatarResult(string ProfileImageUrl); From fc03b8ea03f94114a5476341ff63c2d8a52fe7ec Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 10:45:53 -0500 Subject: [PATCH 2/3] fix: expose the native multipart file contract --- SgfDevs.Tests/MemberAvatarTests.cs | 2 +- SgfDevs/Controllers/MemberAvatarController.cs | 9 +++++---- SgfDevs/ViewModels/MemberAvatarDto.cs | 9 --------- 3 files changed, 6 insertions(+), 14 deletions(-) diff --git a/SgfDevs.Tests/MemberAvatarTests.cs b/SgfDevs.Tests/MemberAvatarTests.cs index 5d52491..de4e1c5 100644 --- a/SgfDevs.Tests/MemberAvatarTests.cs +++ b/SgfDevs.Tests/MemberAvatarTests.cs @@ -190,7 +190,7 @@ public Task> Upload(byte[] bytes, bool extra = { var file = new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "caller.svg"); controller.Request.Form = new FormCollection(extra ? new() { ["memberId"] = "foreign" } : new(), new FormFileCollection { file }); - return controller.Upload(new() { File = file }); + return controller.Upload(file); } private sealed class FakeFiles(Fixture fixture, IMemberService members, IMediaService media, MediaFileManager files, MediaUrlGeneratorCollection generators, IShortStringHelper strings, ICoreScopeProvider scopes, IJsonSerializer json, diff --git a/SgfDevs/Controllers/MemberAvatarController.cs b/SgfDevs/Controllers/MemberAvatarController.cs index fb4bff7..dc31aaa 100644 --- a/SgfDevs/Controllers/MemberAvatarController.cs +++ b/SgfDevs/Controllers/MemberAvatarController.cs @@ -1,5 +1,6 @@ #nullable enable using System; +using System.ComponentModel.DataAnnotations; using System.Threading.Tasks; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authorization; @@ -31,19 +32,19 @@ public sealed class MemberAvatarController( [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] [ProducesResponseType(StatusCodes.Status429TooManyRequests)] [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - public async Task> Upload([FromForm] MemberAvatarRequest request) + public async Task> Upload([FromForm(Name = "file"), Required] IFormFile file) { var auth = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); HttpContext.User = auth.Succeeded && auth.Principal is not null ? auth.Principal : new(); var member = auth.Succeeded ? await manager.GetCurrentMemberAsync() : null; if (member is null) return Unauthorized(); var form = await Request.ReadFormAsync(HttpContext.RequestAborted); - if (Request.Query.Count != 0 || form.Count != 0 || form.Files.Count != 1 || form.Files[0].Name != "file" || request.File is null || request.File.Length == 0) + if (Request.Query.Count != 0 || form.Count != 0 || form.Files.Count != 1 || form.Files[0].Name != "file" || file is null || file.Length == 0) return BadRequest(); - if (request.File.Length > MemberAvatarImage.MaxBytes) return StatusCode(413); + if (file.Length > MemberAvatarImage.MaxBytes) return StatusCode(413); try { - using var source = request.File.OpenReadStream(); + using var source = file.OpenReadStream(); var prepared = await MemberAvatarImage.PrepareAsync(source, HttpContext.RequestAborted); using var content = prepared.Content; var url = await avatars.SaveAsync(member.Key, content, prepared.Extension); diff --git a/SgfDevs/ViewModels/MemberAvatarDto.cs b/SgfDevs/ViewModels/MemberAvatarDto.cs index 803ad8c..d98a6af 100644 --- a/SgfDevs/ViewModels/MemberAvatarDto.cs +++ b/SgfDevs/ViewModels/MemberAvatarDto.cs @@ -1,13 +1,4 @@ #nullable enable -using System.ComponentModel.DataAnnotations; -using Microsoft.AspNetCore.Http; - namespace SGFDevs.ViewModels; -public sealed class MemberAvatarRequest -{ - [Required] - public IFormFile File { get; set; } = null!; -} - public sealed record MemberAvatarResult(string ProfileImageUrl); From 6221692892e64fb61e59ebebba2cc1eaed95cd70 Mon Sep 17 00:00:00 2001 From: Levi Zitting Date: Sun, 4 Oct 2026 10:46:53 -0500 Subject: [PATCH 3/3] fix: let API explorer infer the native file binding --- SgfDevs/Controllers/MemberAvatarController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SgfDevs/Controllers/MemberAvatarController.cs b/SgfDevs/Controllers/MemberAvatarController.cs index dc31aaa..62196f8 100644 --- a/SgfDevs/Controllers/MemberAvatarController.cs +++ b/SgfDevs/Controllers/MemberAvatarController.cs @@ -32,7 +32,7 @@ public sealed class MemberAvatarController( [ProducesResponseType(StatusCodes.Status413PayloadTooLarge)] [ProducesResponseType(StatusCodes.Status429TooManyRequests)] [ProducesResponseType(StatusCodes.Status503ServiceUnavailable)] - public async Task> Upload([FromForm(Name = "file"), Required] IFormFile file) + public async Task> Upload([Required] IFormFile file) { var auth = await HttpContext.AuthenticateAsync(IdentityConstants.ApplicationScheme); HttpContext.User = auth.Succeeded && auth.Principal is not null ? auth.Principal : new();