feat: add PATCH request support, expand PATCH /users/@me, serialize enums correctly

This commit is contained in:
sam 2024-07-12 17:12:24 +02:00
parent d6c9345dba
commit e95e0a79ff
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
20 changed files with 427 additions and 48 deletions

View file

@ -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"];
/// <summary>
/// All scopes endpoints can be secured by. This does *not* include the catch-all token scopes.
/// </summary>
public static readonly string[] Scopes = ["identify", ..UserScopes, ..MemberScopes];
public static readonly string[] Scopes = ["identify", .. UserScopes, .. MemberScopes];
/// <summary>
/// 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.
/// </summary>
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();

View file

@ -0,0 +1,35 @@
using System.Reflection;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
namespace Foxnouns.Backend.Utils;
/// <summary>
/// A base class used for PATCH requests which stores information on whether a key is explicitly set to null or not passed at all.
/// </summary>
public abstract class PatchRequest
{
private readonly HashSet<string> _properties = [];
public bool HasProperty(string propertyName) => _properties.Contains(propertyName);
public void SetHasProperty(string propertyName) => _properties.Add(propertyName);
}
/// <summary>
/// A custom contract resolver to reduce the boilerplate needed to use <see cref="PatchRequest" />.
/// Based on this StackOverflow answer: https://stackoverflow.com/a/58748036
/// </summary>
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;
}
}

View file

@ -0,0 +1,18 @@
using System.Globalization;
using Newtonsoft.Json.Converters;
using Newtonsoft.Json.Serialization;
namespace Foxnouns.Backend.Utils;
/// <summary>
/// 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.
/// </summary>
public class ScreamingSnakeCaseEnumConverter() : StringEnumConverter(new ScreamingSnakeCaseNamingStrategy(), false)
{
private class ScreamingSnakeCaseNamingStrategy : SnakeCaseNamingStrategy
{
protected override string ResolvePropertyName(string name) =>
base.ResolvePropertyName(name).ToUpper(CultureInfo.InvariantCulture);
}
}

View file

@ -0,0 +1,76 @@
using System.Text.RegularExpressions;
namespace Foxnouns.Backend.Utils;
/// <summary>
/// Static methods for validating user input (mostly making sure it's not too short or too long)
/// </summary>
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"
];
/// <summary>
/// Validates whether a username is valid. If it is not valid, throws <see cref="Foxnouns.Backend.ApiError" />.
/// This does not check if the username is already taken.
/// </summary>
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");
}
}