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 string Host { get; init; } = "localhost";
|
||||||
public int Port { get; init; } = 3000;
|
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}";
|
public string Address => $"http://{Host}:{Port}";
|
||||||
|
|
||||||
|
|
|
@ -48,7 +48,7 @@ public class DiscordAuthController(
|
||||||
public async Task<IActionResult> RegisterAsync([FromBody] AuthController.OauthRegisterRequest req)
|
public async Task<IActionResult> RegisterAsync([FromBody] AuthController.OauthRegisterRequest req)
|
||||||
{
|
{
|
||||||
var remoteUser = await keyCacheSvc.GetKeyAsync<RemoteAuthService.RemoteUser>($"discord:{req.Ticket}");
|
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))
|
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",
|
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);
|
public IActionResult BrewCoffee() => Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot);
|
||||||
|
|
||||||
private record MetaResponse(string Version, string Hash, int Members, UserInfo Users);
|
private record MetaResponse(string Version, string Hash, int Members, UserInfo Users);
|
||||||
|
|
|
@ -1,8 +1,11 @@
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Jobs;
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Controllers;
|
namespace Foxnouns.Backend.Controllers;
|
||||||
|
|
||||||
|
@ -10,28 +13,76 @@ namespace Foxnouns.Backend.Controllers;
|
||||||
public class UsersController(DatabaseContext db, UserRendererService userRendererService) : ApiControllerBase
|
public class UsersController(DatabaseContext db, UserRendererService userRendererService) : ApiControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("{userRef}")]
|
[HttpGet("{userRef}")]
|
||||||
|
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetUserAsync(string userRef)
|
public async Task<IActionResult> GetUserAsync(string userRef)
|
||||||
{
|
{
|
||||||
var user = await db.ResolveUserAsync(userRef);
|
var user = await db.ResolveUserAsync(userRef);
|
||||||
return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser));
|
return await GetUserInnerAsync(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("@me")]
|
[HttpGet("@me")]
|
||||||
[Authorize("identify")]
|
[Authorize("identify")]
|
||||||
|
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetMeAsync()
|
public async Task<IActionResult> GetMeAsync()
|
||||||
{
|
{
|
||||||
var user = await db.ResolveUserAsync(CurrentUser!.Id);
|
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")]
|
[HttpPatch("@me")]
|
||||||
|
[Authorize("user.update")]
|
||||||
|
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> UpdateUserAsync([FromBody] UpdateUserRequest req)
|
public async Task<IActionResult> UpdateUserAsync([FromBody] UpdateUserRequest req)
|
||||||
{
|
{
|
||||||
if (req.Avatar != null)
|
await using var tx = await db.Database.BeginTransactionAsync();
|
||||||
AvatarUpdateJob.QueueUpdateUserAvatar(CurrentUser!.Id, req.Avatar);
|
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)
|
if (delete)
|
||||||
{
|
{
|
||||||
await context.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync();
|
await context.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync();
|
||||||
await context.SaveChangesAsync();
|
await context.SaveChangesAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
return value.Value;
|
return value.Value;
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Database.Models;
|
namespace Foxnouns.Backend.Database.Models;
|
||||||
|
|
||||||
public class User : BaseModel
|
public class User : BaseModel
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Collections.ObjectModel;
|
||||||
using System.Net;
|
using System.Net;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
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 HttpStatusCode StatusCode = statusCode ?? HttpStatusCode.InternalServerError;
|
||||||
public readonly ErrorCode ErrorCode = errorCode ?? ErrorCode.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)
|
public class Forbidden(string message, IEnumerable<string>? scopes = null)
|
||||||
: ApiError(message, statusCode: HttpStatusCode.Forbidden)
|
: 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 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)
|
: ApiError(message, statusCode: HttpStatusCode.BadRequest)
|
||||||
{
|
{
|
||||||
public JObject ToJson()
|
public JObject ToJson()
|
||||||
|
@ -37,6 +77,7 @@ public class ApiError(string message, HttpStatusCode? statusCode = null, ErrorCo
|
||||||
var o = new JObject
|
var o = new JObject
|
||||||
{
|
{
|
||||||
{ "status", (int)HttpStatusCode.BadRequest },
|
{ "status", (int)HttpStatusCode.BadRequest },
|
||||||
|
{ "message", Message },
|
||||||
{ "code", ErrorCode.BadRequest.ToString() }
|
{ "code", ErrorCode.BadRequest.ToString() }
|
||||||
};
|
};
|
||||||
if (modelState == null) return o;
|
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 } }))
|
new JArray(error.Value!.Errors.Select(e => new JObject { { "message", e.ErrorMessage } }))
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
a.Add(errorObj);
|
a.Add(errorObj);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -12,7 +12,7 @@ using SixLabors.ImageSharp.Processing.Processors.Transforms;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Jobs;
|
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)
|
public class AvatarUpdateJob(DatabaseContext db, IMinioClient minio, Config config, ILogger logger)
|
||||||
{
|
{
|
||||||
private readonly string[] _validContentTypes = ["image/png", "image/webp", "image/jpeg"];
|
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)
|
.WithBucket(config.Storage.Bucket)
|
||||||
.WithObject(UserAvatarPath(id, prevHash))
|
.WithObject(UserAvatarPath(id, prevHash))
|
||||||
);
|
);
|
||||||
|
|
||||||
logger.Information("Updated avatar for user {UserId}", id);
|
logger.Information("Updated avatar for user {UserId}", id);
|
||||||
}
|
}
|
||||||
catch (ArgumentException ae)
|
catch (ArgumentException ae)
|
||||||
|
@ -94,14 +94,69 @@ public class AvatarUpdateJob(DatabaseContext db, IMinioClient minio, Config conf
|
||||||
await db.SaveChangesAsync();
|
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)
|
private async Task<Stream> ConvertAvatar(string uri)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using System.Net;
|
using System.Net;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Converters;
|
using Newtonsoft.Json.Converters;
|
||||||
|
|
||||||
|
@ -54,6 +55,12 @@ public class ErrorHandlerMiddleware(ILogger baseLogger, IHub sentry) : IMiddlewa
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ae is ApiError.BadRequest br)
|
||||||
|
{
|
||||||
|
await ctx.Response.WriteAsync(br.ToJson().ToString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError
|
await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError
|
||||||
{
|
{
|
||||||
Status = (int)ae.StatusCode,
|
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);
|
logger.Error(e, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path);
|
||||||
}
|
}
|
||||||
|
|
||||||
var errorId = sentry.CaptureException(e, scope =>
|
var errorId = sentry.CaptureException(e, scope =>
|
||||||
{
|
{
|
||||||
var user = ctx.GetUser();
|
var user = ctx.GetUser();
|
||||||
|
@ -101,8 +108,9 @@ public record HttpApiError
|
||||||
{
|
{
|
||||||
public required int Status { get; init; }
|
public required int Status { get; init; }
|
||||||
|
|
||||||
[JsonConverter(typeof(StringEnumConverter))]
|
[JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||||
public required ErrorCode Code { get; init; }
|
public required ErrorCode Code { get; init; }
|
||||||
|
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
public string? ErrorId { get; init; }
|
public string? ErrorId { get; init; }
|
||||||
|
|
||||||
public required string Message { get; init; }
|
public required string Message { get; init; }
|
||||||
|
|
|
@ -4,6 +4,7 @@ using Serilog;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
using Hangfire;
|
using Hangfire;
|
||||||
using Hangfire.Redis.StackExchange;
|
using Hangfire.Redis.StackExchange;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
@ -22,24 +23,34 @@ var config = builder.AddConfiguration();
|
||||||
|
|
||||||
builder.AddSerilog();
|
builder.AddSerilog();
|
||||||
|
|
||||||
builder.WebHost.UseSentry(opts =>
|
builder.WebHost
|
||||||
{
|
.UseSentry(opts =>
|
||||||
opts.Dsn = config.Logging.SentryUrl;
|
{
|
||||||
opts.TracesSampleRate = config.Logging.SentryTracesSampleRate;
|
opts.Dsn = config.Logging.SentryUrl;
|
||||||
opts.MaxRequestBodySize = RequestSize.Small;
|
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
|
builder.Services
|
||||||
.AddControllers()
|
.AddControllers()
|
||||||
.AddNewtonsoftJson(options =>
|
.AddNewtonsoftJson(options =>
|
||||||
options.SerializerSettings.ContractResolver = new DefaultContractResolver
|
{
|
||||||
|
options.SerializerSettings.ContractResolver = new PatchRequestContractResolver
|
||||||
{
|
{
|
||||||
NamingStrategy = new SnakeCaseNamingStrategy()
|
NamingStrategy = new SnakeCaseNamingStrategy()
|
||||||
})
|
};
|
||||||
|
})
|
||||||
.ConfigureApiBehaviorOptions(options =>
|
.ConfigureApiBehaviorOptions(options =>
|
||||||
{
|
{
|
||||||
options.InvalidModelStateResponseFactory = actionContext => new BadRequestObjectResult(
|
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());
|
.Build());
|
||||||
|
|
||||||
builder.Services.AddHangfire(c => c.UseSentry().UseRedisStorage(config.Jobs.Redis, new RedisStorageOptions
|
builder.Services.AddHangfire(c => c.UseSentry().UseRedisStorage(config.Jobs.Redis, new RedisStorageOptions
|
||||||
{
|
{
|
||||||
Prefix = "foxnouns_"
|
Prefix = "foxnouns_"
|
||||||
}))
|
}))
|
||||||
.AddHangfireServer(options => { options.WorkerCount = config.Jobs.Workers; });
|
.AddHangfireServer(options => { options.WorkerCount = config.Jobs.Workers; });
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
|
@ -46,7 +46,7 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator
|
||||||
AssertValidAuthType(authType, instance);
|
AssertValidAuthType(authType, instance);
|
||||||
|
|
||||||
if (await db.Users.AnyAsync(u => u.Username == username))
|
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
|
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)
|
public (string, Token) GenerateToken(User user, Application application, string[] scopes, Instant expires)
|
||||||
{
|
{
|
||||||
if (!AuthUtils.ValidateScopes(application, scopes))
|
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();
|
var (token, hash) = GenerateToken();
|
||||||
return (token, new Token
|
return (token, new Token
|
||||||
|
|
|
@ -3,16 +3,20 @@ using Foxnouns.Backend.Database.Models;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services;
|
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,
|
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(
|
public record PartialMember(
|
||||||
Snowflake Id,
|
Snowflake Id,
|
||||||
string Name,
|
string Name,
|
||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? Bio,
|
string? Bio,
|
||||||
|
string? AvatarUrl,
|
||||||
IEnumerable<FieldEntry> Names,
|
IEnumerable<FieldEntry> Names,
|
||||||
IEnumerable<Pronoun> Pronouns);
|
IEnumerable<Pronoun> Pronouns);
|
||||||
}
|
}
|
|
@ -1,24 +1,54 @@
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services;
|
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(
|
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,
|
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(
|
public record UserResponse(
|
||||||
Snowflake Id,
|
Snowflake Id,
|
||||||
string Username,
|
string Username,
|
||||||
|
@ -30,7 +60,20 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
|
||||||
IEnumerable<FieldEntry> Names,
|
IEnumerable<FieldEntry> Names,
|
||||||
IEnumerable<Pronoun> Pronouns,
|
IEnumerable<Pronoun> Pronouns,
|
||||||
IEnumerable<Field> Fields,
|
IEnumerable<Field> Fields,
|
||||||
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
IEnumerable<MemberRendererService.PartialMember>? Members
|
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"];
|
private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"];
|
||||||
|
|
||||||
public static readonly string[] UserScopes =
|
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"];
|
public static readonly string[] MemberScopes = ["member.read", "member.update", "member.create"];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// All scopes endpoints can be secured by. This does *not* include the catch-all token scopes.
|
/// All scopes endpoints can be secured by. This does *not* include the catch-all token scopes.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly string[] Scopes = ["identify", ..UserScopes, ..MemberScopes];
|
public static readonly string[] Scopes = ["identify", .. UserScopes, .. MemberScopes];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// All scopes that can be granted to applications and tokens. Includes the catch-all token scopes,
|
/// 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.
|
/// except for "*" which is only granted to the frontend.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static readonly string[] ApplicationScopes = [..Scopes, "user", "member"];
|
public static readonly string[] ApplicationScopes = [.. Scopes, "user", "member"];
|
||||||
|
|
||||||
public static string[] ExpandScopes(this string[] scopes)
|
public static string[] ExpandScopes(this string[] scopes)
|
||||||
{
|
{
|
||||||
|
@ -35,6 +35,9 @@ public static class AuthUtils
|
||||||
return expandedScopes.ToArray();
|
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)
|
private static string[] ExpandAppScopes(this string[] scopes)
|
||||||
{
|
{
|
||||||
var expandedScopes = scopes.ExpandScopes().ToList();
|
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
|
Port = 5000
|
||||||
; The base *external* URL
|
; The base *external* URL
|
||||||
BaseUrl = https://pronouns.localhost
|
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]
|
[Logging]
|
||||||
; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal
|
; 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