From e95e0a79ff74ebd636ca7b8ec638a0b660c356ec Mon Sep 17 00:00:00 2001 From: sam Date: Fri, 12 Jul 2024 17:12:24 +0200 Subject: [PATCH] feat: add PATCH request support, expand PATCH /users/@me, serialize enums correctly --- Foxnouns.Backend/Config.cs | 3 +- .../Authentication/DiscordAuthController.cs | 2 +- .../Controllers/MetaController.cs | 2 +- .../Controllers/UsersController.cs | 63 +++++++++++++-- .../Database/DatabaseQueryExtensions.cs | 2 +- Foxnouns.Backend/Database/Models/User.cs | 2 + Foxnouns.Backend/ExpectedError.cs | 46 ++++++++++- Foxnouns.Backend/Jobs/AvatarUpdateJob.cs | 67 ++++++++++++++-- .../Middleware/ErrorHandlerMiddleware.cs | 12 ++- Foxnouns.Backend/Program.cs | 35 ++++++--- Foxnouns.Backend/Services/AuthService.cs | 4 +- .../Services/MemberRendererService.cs | 8 +- .../Services/UserRendererService.cs | 59 ++++++++++++-- Foxnouns.Backend/Utils/AuthUtils.cs | 9 ++- Foxnouns.Backend/Utils/PatchRequest.cs | 35 +++++++++ .../Utils/ScreamingSnakeCaseEnumConverter.cs | 18 +++++ Foxnouns.Backend/Utils/ValidationUtils.cs | 76 +++++++++++++++++++ Foxnouns.Backend/config.example.ini | 2 + SCOPES.md | 18 +++++ STYLE.md | 12 +++ 20 files changed, 427 insertions(+), 48 deletions(-) create mode 100644 Foxnouns.Backend/Utils/PatchRequest.cs create mode 100644 Foxnouns.Backend/Utils/ScreamingSnakeCaseEnumConverter.cs create mode 100644 Foxnouns.Backend/Utils/ValidationUtils.cs create mode 100644 SCOPES.md create mode 100644 STYLE.md diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index 6db3888..1cb21c6 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -6,7 +6,8 @@ public class Config { public string Host { get; init; } = "localhost"; public int Port { get; init; } = 3000; - public string BaseUrl { get; init; } = null!; + public string BaseUrl { get; set; } = null!; + public string MediaBaseUrl { get; set; } = null!; public string Address => $"http://{Host}:{Port}"; diff --git a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs index 29700f2..35049ae 100644 --- a/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs +++ b/Foxnouns.Backend/Controllers/Authentication/DiscordAuthController.cs @@ -48,7 +48,7 @@ public class DiscordAuthController( public async Task RegisterAsync([FromBody] AuthController.OauthRegisterRequest req) { var remoteUser = await keyCacheSvc.GetKeyAsync($"discord:{req.Ticket}"); - if (remoteUser == null) throw new ApiError.BadRequest("Invalid ticket"); + if (remoteUser == null) throw new ApiError.BadRequest("Invalid ticket", "ticket"); if (await db.AuthMethods.AnyAsync(a => a.AuthType == AuthType.Discord && a.RemoteId == remoteUser.Id)) { logger.Error("Discord user {Id} has valid ticket but is already linked to an existing account", diff --git a/Foxnouns.Backend/Controllers/MetaController.cs b/Foxnouns.Backend/Controllers/MetaController.cs index f39810e..9d2c991 100644 --- a/Foxnouns.Backend/Controllers/MetaController.cs +++ b/Foxnouns.Backend/Controllers/MetaController.cs @@ -20,7 +20,7 @@ public class MetaController(DatabaseContext db) : ApiControllerBase ); } - [HttpGet("coffee")] + [HttpGet("/api/v2/coffee")] public IActionResult BrewCoffee() => Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot); private record MetaResponse(string Version, string Hash, int Members, UserInfo Users); diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index acd9439..28d7ea8 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -1,8 +1,11 @@ using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; +using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; namespace Foxnouns.Backend.Controllers; @@ -10,28 +13,76 @@ namespace Foxnouns.Backend.Controllers; public class UsersController(DatabaseContext db, UserRendererService userRendererService) : ApiControllerBase { [HttpGet("{userRef}")] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetUserAsync(string userRef) { var user = await db.ResolveUserAsync(userRef); - return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser)); + return await GetUserInnerAsync(user); } [HttpGet("@me")] [Authorize("identify")] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetMeAsync() { var user = await db.ResolveUserAsync(CurrentUser!.Id); - return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser)); + return await GetUserInnerAsync(user); + } + + private async Task GetUserInnerAsync(User user) + { + return Ok(await userRendererService.RenderUserAsync( + user, + selfUser: CurrentUser, + token: CurrentToken, + renderMembers: true, + renderAuthMethods: true + )); } [HttpPatch("@me")] + [Authorize("user.update")] + [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task UpdateUserAsync([FromBody] UpdateUserRequest req) { - if (req.Avatar != null) - AvatarUpdateJob.QueueUpdateUserAvatar(CurrentUser!.Id, req.Avatar); + await using var tx = await db.Database.BeginTransactionAsync(); + var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id); - return NoContent(); + if (req.Username != null && req.Username != user.Username) + { + ValidationUtils.ValidateUsername(req.Username); + user.Username = req.Username; + } + + if (req.HasProperty(nameof(req.DisplayName))) + { + ValidationUtils.ValidateDisplayName(req.DisplayName); + user.DisplayName = req.DisplayName; + } + + if (req.HasProperty(nameof(req.Bio))) + { + ValidationUtils.ValidateBio(req.Bio); + user.Bio = req.Bio; + } + + if (req.HasProperty(nameof(req.Avatar))) + { + ValidationUtils.ValidateAvatar(req.Avatar); + AvatarUpdateJob.QueueUpdateUserAvatar(CurrentUser!.Id, req.Avatar); + } + + await db.SaveChangesAsync(); + await tx.CommitAsync(); + return Ok(await userRendererService.RenderUserAsync(user, CurrentUser, renderMembers: false, + renderAuthMethods: false)); } - public record UpdateUserRequest(string? Username, string? DisplayName, string? Avatar); + public class UpdateUserRequest : PatchRequest + { + public string? Username { get; init; } + public string? DisplayName { get; init; } + public string? Bio { get; init; } + public string? Avatar { get; init; } + } } \ No newline at end of file diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs index fc63c05..9b357fa 100644 --- a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs +++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs @@ -110,7 +110,7 @@ public static class DatabaseQueryExtensions if (delete) { await context.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(); - await context.SaveChangesAsync(); + await context.SaveChangesAsync(); } return value.Value; diff --git a/Foxnouns.Backend/Database/Models/User.cs b/Foxnouns.Backend/Database/Models/User.cs index 238f306..986c779 100644 --- a/Foxnouns.Backend/Database/Models/User.cs +++ b/Foxnouns.Backend/Database/Models/User.cs @@ -1,3 +1,5 @@ +using System.Text.RegularExpressions; + namespace Foxnouns.Backend.Database.Models; public class User : BaseModel diff --git a/Foxnouns.Backend/ExpectedError.cs b/Foxnouns.Backend/ExpectedError.cs index d05571b..7dda1ad 100644 --- a/Foxnouns.Backend/ExpectedError.cs +++ b/Foxnouns.Backend/ExpectedError.cs @@ -1,3 +1,4 @@ +using System.Collections.ObjectModel; using System.Net; using Foxnouns.Backend.Middleware; using Microsoft.AspNetCore.Mvc.ModelBinding; @@ -21,7 +22,8 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo public readonly HttpStatusCode StatusCode = statusCode ?? HttpStatusCode.InternalServerError; public readonly ErrorCode ErrorCode = errorCode ?? ErrorCode.InternalServerError; - public class Unauthorized(string message) : ApiError(message, statusCode: HttpStatusCode.Unauthorized); + public class Unauthorized(string message) : ApiError(message, statusCode: HttpStatusCode.Unauthorized, + errorCode: ErrorCode.AuthenticationError); public class Forbidden(string message, IEnumerable? scopes = null) : ApiError(message, statusCode: HttpStatusCode.Forbidden) @@ -29,7 +31,45 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo public readonly string[] Scopes = scopes?.ToArray() ?? []; } - public class BadRequest(string message, ModelStateDictionary? modelState = null) + public class BadRequest(string message, IReadOnlyDictionary? errors = null) + : ApiError(message, statusCode: HttpStatusCode.BadRequest) + { + public BadRequest(string message, string field) : this(message, + new Dictionary { { field, message } }) + { + } + + public JObject ToJson() + { + var o = new JObject + { + { "status", (int)HttpStatusCode.BadRequest }, + { "message", Message }, + { "code", ErrorCode.BadRequest.ToString() } + }; + if (errors == null) return o; + + var a = new JArray(); + foreach (var error in errors) + { + var errorObj = new JObject + { + { "key", error.Key }, + { "errors", new JArray(new JObject { { "message", error.Value } }) } + }; + a.Add(errorObj); + } + + o.Add("errors", a); + return o; + } + } + + /// + /// A special version of BadRequest that ASP.NET generates when it encounters an invalid request. + /// Any other methods should use instead. + /// + public class AspBadRequest(string message, ModelStateDictionary? modelState = null) : ApiError(message, statusCode: HttpStatusCode.BadRequest) { public JObject ToJson() @@ -37,6 +77,7 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo var o = new JObject { { "status", (int)HttpStatusCode.BadRequest }, + { "message", Message }, { "code", ErrorCode.BadRequest.ToString() } }; if (modelState == null) return o; @@ -52,7 +93,6 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo new JArray(error.Value!.Errors.Select(e => new JObject { { "message", e.ErrorMessage } })) } }; - a.Add(errorObj); } diff --git a/Foxnouns.Backend/Jobs/AvatarUpdateJob.cs b/Foxnouns.Backend/Jobs/AvatarUpdateJob.cs index 56aad8a..6404591 100644 --- a/Foxnouns.Backend/Jobs/AvatarUpdateJob.cs +++ b/Foxnouns.Backend/Jobs/AvatarUpdateJob.cs @@ -12,7 +12,7 @@ using SixLabors.ImageSharp.Processing.Processors.Transforms; namespace Foxnouns.Backend.Jobs; -[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")] +[SuppressMessage("ReSharper", "MemberCanBePrivate.Global", Justification = "Hangfire jobs need to be public")] public class AvatarUpdateJob(DatabaseContext db, IMinioClient minio, Config config, ILogger logger) { private readonly string[] _validContentTypes = ["image/png", "image/webp", "image/jpeg"]; @@ -61,7 +61,7 @@ public class AvatarUpdateJob(DatabaseContext db, IMinioClient minio, Config conf .WithBucket(config.Storage.Bucket) .WithObject(UserAvatarPath(id, prevHash)) ); - + logger.Information("Updated avatar for user {UserId}", id); } catch (ArgumentException ae) @@ -94,14 +94,69 @@ public class AvatarUpdateJob(DatabaseContext db, IMinioClient minio, Config conf await db.SaveChangesAsync(); } - public Task UpdateMemberAvatar(Snowflake id, string newAvatar) + public async Task UpdateMemberAvatar(Snowflake id, string newAvatar) { - throw new NotImplementedException(); + var member = await db.Members.FindAsync(id); + if (member == null) + { + logger.Warning("Update avatar job queued for {MemberId} but no member with that ID exists", id); + return; + } + + try + { + var image = await ConvertAvatar(newAvatar); + var hash = Convert.ToHexString(await SHA256.HashDataAsync(image)).ToLower(); + image.Seek(0, SeekOrigin.Begin); + var prevHash = member.Avatar; + + await minio.PutObjectAsync(new PutObjectArgs() + .WithBucket(config.Storage.Bucket) + .WithObject(MemberAvatarPath(id, hash)) + .WithObjectSize(image.Length) + .WithStreamData(image) + .WithContentType("image/webp") + ); + + member.Avatar = hash; + await db.SaveChangesAsync(); + + if (prevHash != null && prevHash != hash) + await minio.RemoveObjectAsync(new RemoveObjectArgs() + .WithBucket(config.Storage.Bucket) + .WithObject(MemberAvatarPath(id, prevHash)) + ); + + logger.Information("Updated avatar for member {MemberId}", id); + } + catch (ArgumentException ae) + { + logger.Warning("Invalid data URI for new avatar for member {MemberId}: {Reason}", id, ae.Message); + } } - public Task ClearMemberAvatar(Snowflake id) + public async Task ClearMemberAvatar(Snowflake id) { - throw new NotImplementedException(); + var member = await db.Members.FindAsync(id); + if (member == null) + { + logger.Warning("Clear avatar job queued for {MemberId} but no member with that ID exists", id); + return; + } + + if (member.Avatar == null) + { + logger.Warning("Clear avatar job queued for {MemberId} with null avatar", id); + return; + } + + await minio.RemoveObjectAsync(new RemoveObjectArgs() + .WithBucket(config.Storage.Bucket) + .WithObject(MemberAvatarPath(member.Id, member.Avatar)) + ); + + member.Avatar = null; + await db.SaveChangesAsync(); } private async Task ConvertAvatar(string uri) diff --git a/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs b/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs index e9b7c89..39dfd85 100644 --- a/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs +++ b/Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs @@ -1,4 +1,5 @@ using System.Net; +using Foxnouns.Backend.Utils; using Newtonsoft.Json; using Newtonsoft.Json.Converters; @@ -54,6 +55,12 @@ public class ErrorHandlerMiddleware(ILogger baseLogger, IHub sentry) : IMiddlewa return; } + if (ae is ApiError.BadRequest br) + { + await ctx.Response.WriteAsync(br.ToJson().ToString()); + return; + } + await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError { Status = (int)ae.StatusCode, @@ -71,7 +78,7 @@ public class ErrorHandlerMiddleware(ILogger baseLogger, IHub sentry) : IMiddlewa { logger.Error(e, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path); } - + var errorId = sentry.CaptureException(e, scope => { var user = ctx.GetUser(); @@ -101,8 +108,9 @@ public record HttpApiError { public required int Status { get; init; } - [JsonConverter(typeof(StringEnumConverter))] + [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] public required ErrorCode Code { get; init; } + [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] public string? ErrorId { get; init; } public required string Message { get; init; } diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs index ac746f7..4a311a7 100644 --- a/Foxnouns.Backend/Program.cs +++ b/Foxnouns.Backend/Program.cs @@ -4,6 +4,7 @@ using Serilog; using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; +using Foxnouns.Backend.Utils; using Hangfire; using Hangfire.Redis.StackExchange; using Microsoft.AspNetCore.Mvc; @@ -22,24 +23,34 @@ var config = builder.AddConfiguration(); builder.AddSerilog(); -builder.WebHost.UseSentry(opts => -{ - opts.Dsn = config.Logging.SentryUrl; - opts.TracesSampleRate = config.Logging.SentryTracesSampleRate; - opts.MaxRequestBodySize = RequestSize.Small; -}); +builder.WebHost + .UseSentry(opts => + { + opts.Dsn = config.Logging.SentryUrl; + opts.TracesSampleRate = config.Logging.SentryTracesSampleRate; + opts.MaxRequestBodySize = RequestSize.Small; + }) + .ConfigureKestrel(opts => + { + // Requests are limited to a maximum of 2 MB. + // No valid request body will ever come close to this limit, + // but the limit is slightly higher to prevent valid requests from being rejected. + opts.Limits.MaxRequestBodySize = 2 * 1024 * 1024; + }); builder.Services .AddControllers() .AddNewtonsoftJson(options => - options.SerializerSettings.ContractResolver = new DefaultContractResolver + { + options.SerializerSettings.ContractResolver = new PatchRequestContractResolver { NamingStrategy = new SnakeCaseNamingStrategy() - }) + }; + }) .ConfigureApiBehaviorOptions(options => { options.InvalidModelStateResponseFactory = actionContext => new BadRequestObjectResult( - new ApiError.BadRequest("Bad request", actionContext.ModelState).ToJson() + new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson() ); }); @@ -64,9 +75,9 @@ builder.Services .Build()); builder.Services.AddHangfire(c => c.UseSentry().UseRedisStorage(config.Jobs.Redis, new RedisStorageOptions - { - Prefix = "foxnouns_" - })) +{ + Prefix = "foxnouns_" +})) .AddHangfireServer(options => { options.WorkerCount = config.Jobs.Workers; }); var app = builder.Build(); diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index 7eedb84..a87a50c 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -46,7 +46,7 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator AssertValidAuthType(authType, instance); if (await db.Users.AnyAsync(u => u.Username == username)) - throw new ApiError.BadRequest("Username is already taken"); + throw new ApiError.BadRequest("Username is already taken", "username"); var user = new User { @@ -122,7 +122,7 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator public (string, Token) GenerateToken(User user, Application application, string[] scopes, Instant expires) { if (!AuthUtils.ValidateScopes(application, scopes)) - throw new ApiError.BadRequest("Invalid scopes requested for this token"); + throw new ApiError.BadRequest("Invalid scopes requested for this token", "scopes"); var (token, hash) = GenerateToken(); return (token, new Token diff --git a/Foxnouns.Backend/Services/MemberRendererService.cs b/Foxnouns.Backend/Services/MemberRendererService.cs index 73d1998..e151777 100644 --- a/Foxnouns.Backend/Services/MemberRendererService.cs +++ b/Foxnouns.Backend/Services/MemberRendererService.cs @@ -3,16 +3,20 @@ using Foxnouns.Backend.Database.Models; namespace Foxnouns.Backend.Services; -public class MemberRendererService(DatabaseContext db) +public class MemberRendererService(DatabaseContext db, Config config) { public PartialMember RenderPartialMember(Member member) => new(member.Id, member.Name, - member.DisplayName, member.Bio, member.Names, member.Pronouns); + member.DisplayName, member.Bio, AvatarUrlFor(member), member.Names, member.Pronouns); + + private string? AvatarUrlFor(Member member) => + member.Avatar != null ? $"{config.MediaBaseUrl}/members/{member.Id}/avatars/{member.Avatar}.webp" : null; public record PartialMember( Snowflake Id, string Name, string? DisplayName, string? Bio, + string? AvatarUrl, IEnumerable Names, IEnumerable Pronouns); } \ No newline at end of file diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index 0ac7f90..ca5fff4 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -1,24 +1,54 @@ using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Utils; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; namespace Foxnouns.Backend.Services; -public class UserRendererService(DatabaseContext db, MemberRendererService memberRendererService) +public class UserRendererService(DatabaseContext db, MemberRendererService memberRendererService, Config config) { - public async Task RenderUserAsync(User user, User? selfUser = null, bool renderMembers = true) + public async Task RenderUserAsync(User user, User? selfUser = null, + Token? token = null, + bool renderMembers = true, + bool renderAuthMethods = false) { - renderMembers = renderMembers && (!user.ListHidden || selfUser?.Id == user.Id); + var isSelfUser = selfUser?.Id == user.Id; + var tokenCanReadHiddenMembers = token.HasScope("member.read"); + var tokenCanReadAuth = token.HasScope("user.read_privileged"); - var members = renderMembers ? await db.Members.Where(m => m.UserId == user.Id).ToListAsync() : []; + renderMembers = renderMembers && + (!user.ListHidden || (isSelfUser && tokenCanReadHiddenMembers)); + renderAuthMethods = renderAuthMethods && isSelfUser && tokenCanReadAuth; + + IEnumerable members = + renderMembers ? await db.Members.Where(m => m.UserId == user.Id).ToListAsync() : []; + // Unless the user is requesting their own members AND the token can read hidden members, we filter out unlisted members. + if (!(isSelfUser && tokenCanReadHiddenMembers)) members = members.Where(m => m.Unlisted); + + var authMethods = renderAuthMethods + ? await db.AuthMethods + .Where(a => a.UserId == user.Id) + .Include(a => a.FediverseApplication) + .ToListAsync() + : []; return new UserResponse( - user.Id, user.Username, user.DisplayName, user.Bio, user.MemberTitle, user.Avatar, user.Links, user.Names, + user.Id, user.Username, user.DisplayName, user.Bio, user.MemberTitle, AvatarUrlFor(user), user.Links, user.Names, user.Pronouns, user.Fields, - renderMembers ? members.Select(memberRendererService.RenderPartialMember) : null); + renderMembers ? members.Select(memberRendererService.RenderPartialMember) : null, + renderAuthMethods + ? authMethods.Select(a => new AuthenticationMethodResponse( + a.Id, a.AuthType, a.RemoteId, + a.RemoteUsername, a.FediverseApplication?.Domain + )) + : null + ); } + private string? AvatarUrlFor(User user) => + user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null; + public record UserResponse( Snowflake Id, string Username, @@ -30,7 +60,20 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe IEnumerable Names, IEnumerable Pronouns, IEnumerable Fields, - [JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - IEnumerable? Members + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + IEnumerable? Members, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + IEnumerable? AuthMethods + ); + + public record AuthenticationMethodResponse( + Snowflake Id, + [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] + AuthType Type, + string RemoteId, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + string? RemoteUsername, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + string? FediverseInstance ); } \ No newline at end of file diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs index 45c9ad5..8dfb137 100644 --- a/Foxnouns.Backend/Utils/AuthUtils.cs +++ b/Foxnouns.Backend/Utils/AuthUtils.cs @@ -10,20 +10,20 @@ public static class AuthUtils private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"]; public static readonly string[] UserScopes = - ["user.read_hidden", "user.read_privileged", "user.update"]; + ["user.read_privileged", "user.update"]; public static readonly string[] MemberScopes = ["member.read", "member.update", "member.create"]; /// /// All scopes endpoints can be secured by. This does *not* include the catch-all token scopes. /// - public static readonly string[] Scopes = ["identify", ..UserScopes, ..MemberScopes]; + public static readonly string[] Scopes = ["identify", .. UserScopes, .. MemberScopes]; /// /// All scopes that can be granted to applications and tokens. Includes the catch-all token scopes, /// except for "*" which is only granted to the frontend. /// - public static readonly string[] ApplicationScopes = [..Scopes, "user", "member"]; + public static readonly string[] ApplicationScopes = [.. Scopes, "user", "member"]; public static string[] ExpandScopes(this string[] scopes) { @@ -35,6 +35,9 @@ public static class AuthUtils return expandedScopes.ToArray(); } + public static bool HasScope(this Token? token, string scope) => + token?.Scopes.ExpandScopes().Contains(scope) == true; + private static string[] ExpandAppScopes(this string[] scopes) { var expandedScopes = scopes.ExpandScopes().ToList(); diff --git a/Foxnouns.Backend/Utils/PatchRequest.cs b/Foxnouns.Backend/Utils/PatchRequest.cs new file mode 100644 index 0000000..da98615 --- /dev/null +++ b/Foxnouns.Backend/Utils/PatchRequest.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Foxnouns.Backend.Utils; + +/// +/// A base class used for PATCH requests which stores information on whether a key is explicitly set to null or not passed at all. +/// +public abstract class PatchRequest +{ + private readonly HashSet _properties = []; + public bool HasProperty(string propertyName) => _properties.Contains(propertyName); + public void SetHasProperty(string propertyName) => _properties.Add(propertyName); +} + +/// +/// A custom contract resolver to reduce the boilerplate needed to use . +/// Based on this StackOverflow answer: https://stackoverflow.com/a/58748036 +/// +public class PatchRequestContractResolver : DefaultContractResolver +{ + protected override JsonProperty CreateProperty(MemberInfo member, MemberSerialization memberSerialization) + { + var prop = base.CreateProperty(member, memberSerialization); + + prop.SetIsSpecified += (o, _) => + { + if (o is not PatchRequest patchRequest) return; + patchRequest.SetHasProperty(prop.UnderlyingName!); + }; + + return prop; + } +} \ No newline at end of file diff --git a/Foxnouns.Backend/Utils/ScreamingSnakeCaseEnumConverter.cs b/Foxnouns.Backend/Utils/ScreamingSnakeCaseEnumConverter.cs new file mode 100644 index 0000000..dafc2b8 --- /dev/null +++ b/Foxnouns.Backend/Utils/ScreamingSnakeCaseEnumConverter.cs @@ -0,0 +1,18 @@ +using System.Globalization; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; + +namespace Foxnouns.Backend.Utils; + +/// +/// A custom StringEnumConverter that converts enum members to SCREAMING_SNAKE_CASE, rather than CamelCase as is the default. +/// Newtonsoft.Json doesn't provide a screaming snake case naming strategy, so we just wrap the normal snake case one and convert it to uppercase. +/// +public class ScreamingSnakeCaseEnumConverter() : StringEnumConverter(new ScreamingSnakeCaseNamingStrategy(), false) +{ + private class ScreamingSnakeCaseNamingStrategy : SnakeCaseNamingStrategy + { + protected override string ResolvePropertyName(string name) => + base.ResolvePropertyName(name).ToUpper(CultureInfo.InvariantCulture); + } +} \ No newline at end of file diff --git a/Foxnouns.Backend/Utils/ValidationUtils.cs b/Foxnouns.Backend/Utils/ValidationUtils.cs new file mode 100644 index 0000000..182f1bc --- /dev/null +++ b/Foxnouns.Backend/Utils/ValidationUtils.cs @@ -0,0 +1,76 @@ +using System.Text.RegularExpressions; + +namespace Foxnouns.Backend.Utils; + +/// +/// Static methods for validating user input (mostly making sure it's not too short or too long) +/// +public static class ValidationUtils +{ + private static readonly Regex UsernameRegex = new("^[\\w-.]{2,40}$", RegexOptions.IgnoreCase); + + private static readonly string[] InvalidUsernames = + [ + "..", + "admin", + "administrator", + "mod", + "moderator", + "api", + "page", + "pronouns", + "settings", + "pronouns.cc", + "pronounscc" + ]; + + /// + /// Validates whether a username is valid. If it is not valid, throws . + /// This does not check if the username is already taken. + /// + public static void ValidateUsername(string username) + { + if (!UsernameRegex.IsMatch(username)) + throw username.Length switch + { + < 2 => new ApiError.BadRequest("Username is too short", "username"), + > 40 => new ApiError.BadRequest("Username is too long", "username"), + _ => new ApiError.BadRequest( + "Username is invalid, can only contain alphanumeric characters, dashes, underscores, and periods", + "username") + }; + + if (InvalidUsernames.Any(u => string.Equals(u, username, StringComparison.InvariantCultureIgnoreCase))) + throw new ApiError.BadRequest("Username is not allowed", "username"); + } + + public static void ValidateDisplayName(string? displayName) + { + if (displayName == null) return; + switch (displayName.Length) + { + case 0: + throw new ApiError.BadRequest("Display name is too short", "display_name"); + case > 100: + throw new ApiError.BadRequest("Display name is too long", "display_name"); + } + } + + public static void ValidateBio(string? bio) + { + if (bio == null) return; + switch (bio.Length) + { + case 0: + throw new ApiError.BadRequest("Bio is too short", "bio"); + case > 1024: + throw new ApiError.BadRequest("Bio is too long", "bio"); + } + } + + public static void ValidateAvatar(string? avatar) + { + if (avatar == null) return; + if (avatar.Length > 1_500_000) throw new ApiError.BadRequest("Avatar is too big", "avatar"); + } +} \ No newline at end of file diff --git a/Foxnouns.Backend/config.example.ini b/Foxnouns.Backend/config.example.ini index 2586a62..c791d1f 100644 --- a/Foxnouns.Backend/config.example.ini +++ b/Foxnouns.Backend/config.example.ini @@ -4,6 +4,8 @@ Host = localhost Port = 5000 ; The base *external* URL BaseUrl = https://pronouns.localhost +; The base URL for media, without a trailing slash. This must be publicly accessible. +MediaBaseUrl = https://cdn-staging.pronouns.localhost [Logging] ; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal diff --git a/SCOPES.md b/SCOPES.md new file mode 100644 index 0000000..5cab914 --- /dev/null +++ b/SCOPES.md @@ -0,0 +1,18 @@ +# List of API endpoints and scopes + +## Scopes + +- `identify`: `@me` will refer to token user (always granted) +- `user.read_privileged`: can read privileged information such as authentication methods +- `user.update`: can update the user's profile. + **cannot** update anything locked behind `user.read_privileged` +- `member.read`: can view member list if it's hidden and enumerate unlisted members +- `member.create`: can create new members +- `member.update`: can edit and delete members + +## Users + +- GET `/users/{userRef}`: `identify` required to use `@me` as user reference. + `user.read_privileged` required to view authentication methods. + `member.read` required to view unlisted members. +- PATCH `/users/@me`: `user.update` required. \ No newline at end of file diff --git a/STYLE.md b/STYLE.md new file mode 100644 index 0000000..6acdab8 --- /dev/null +++ b/STYLE.md @@ -0,0 +1,12 @@ +# Code style + +## C# code style + +Code should be formatted with `dotnet format` or Rider's built-in formatter. +Variables should *always* be declared using `var`, unless the correct type +can't be inferred from the declaration (i.e. if the variable needs to be an +`IEnumerable` instead of a `List`, or if a variable is initialized as `null`). + +## TypeScript code style + +Use `prettier` for formatting the frontend code. \ No newline at end of file