feat: add PATCH request support, expand PATCH /users/@me, serialize enums correctly
This commit is contained in:
parent
d6c9345dba
commit
e95e0a79ff
20 changed files with 427 additions and 48 deletions
|
@ -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}";
|
||||
|
||||
|
|
|
@ -48,7 +48,7 @@ public class DiscordAuthController(
|
|||
public async Task<IActionResult> RegisterAsync([FromBody] AuthController.OauthRegisterRequest req)
|
||||
{
|
||||
var remoteUser = await keyCacheSvc.GetKeyAsync<RemoteAuthService.RemoteUser>($"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",
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> 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<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> GetMeAsync()
|
||||
{
|
||||
var user = await db.ResolveUserAsync(CurrentUser!.Id);
|
||||
return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser));
|
||||
return await GetUserInnerAsync(user);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> GetUserInnerAsync(User user)
|
||||
{
|
||||
return Ok(await userRendererService.RenderUserAsync(
|
||||
user,
|
||||
selfUser: CurrentUser,
|
||||
token: CurrentToken,
|
||||
renderMembers: true,
|
||||
renderAuthMethods: true
|
||||
));
|
||||
}
|
||||
|
||||
[HttpPatch("@me")]
|
||||
[Authorize("user.update")]
|
||||
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||
public async Task<IActionResult> 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; }
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace Foxnouns.Backend.Database.Models;
|
||||
|
||||
public class User : BaseModel
|
||||
|
|
|
@ -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<string>? 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<string, string>? errors = null)
|
||||
: ApiError(message, statusCode: HttpStatusCode.BadRequest)
|
||||
{
|
||||
public BadRequest(string message, string field) : this(message,
|
||||
new Dictionary<string, string> { { 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A special version of BadRequest that ASP.NET generates when it encounters an invalid request.
|
||||
/// Any other methods should use <see cref="ApiError.BadRequest" /> instead.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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<Stream> ConvertAvatar(string uri)
|
||||
|
|
|
@ -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; }
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<FieldEntry> Names,
|
||||
IEnumerable<Pronoun> Pronouns);
|
||||
}
|
|
@ -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<UserResponse> RenderUserAsync(User user, User? selfUser = null, bool renderMembers = true)
|
||||
public async Task<UserResponse> 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<Member> 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<FieldEntry> Names,
|
||||
IEnumerable<Pronoun> Pronouns,
|
||||
IEnumerable<Field> Fields,
|
||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
IEnumerable<MemberRendererService.PartialMember>? Members
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
IEnumerable<MemberRendererService.PartialMember>? Members,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
IEnumerable<AuthenticationMethodResponse>? 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
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
|
|
35
Foxnouns.Backend/Utils/PatchRequest.cs
Normal file
35
Foxnouns.Backend/Utils/PatchRequest.cs
Normal 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;
|
||||
}
|
||||
}
|
18
Foxnouns.Backend/Utils/ScreamingSnakeCaseEnumConverter.cs
Normal file
18
Foxnouns.Backend/Utils/ScreamingSnakeCaseEnumConverter.cs
Normal 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);
|
||||
}
|
||||
}
|
76
Foxnouns.Backend/Utils/ValidationUtils.cs
Normal file
76
Foxnouns.Backend/Utils/ValidationUtils.cs
Normal 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");
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
18
SCOPES.md
Normal file
18
SCOPES.md
Normal file
|
@ -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.
|
12
STYLE.md
Normal file
12
STYLE.md
Normal file
|
@ -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<T>` instead of a `List<T>`, or if a variable is initialized as `null`).
|
||||
|
||||
## TypeScript code style
|
||||
|
||||
Use `prettier` for formatting the frontend code.
|
Loading…
Reference in a new issue