using System.Diagnostics.CodeAnalysis; using Coravel.Mailer.Mail.Helpers; using Coravel.Queuing.Interfaces; using EntityFramework.Exceptions.Common; 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; using NodaTime; namespace Foxnouns.Backend.Controllers; [Route("/api/v2/users")] public class UsersController( DatabaseContext db, ILogger logger, UserRendererService userRenderer, ISnowflakeGenerator snowflakeGenerator, IQueue queue, IClock clock ) : ApiControllerBase { private readonly ILogger _logger = logger.ForContext(); [HttpGet("{userRef}")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetUserAsync(string userRef, CancellationToken ct = default) { var user = await db.ResolveUserAsync(userRef, CurrentToken, ct); return Ok( await userRenderer.RenderUserAsync( user, selfUser: CurrentUser, token: CurrentToken, renderMembers: true, renderAuthMethods: true, ct: ct ) ); } [HttpPatch("@me")] [Authorize("user.update")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task UpdateUserAsync( [FromBody] UpdateUserRequest req, CancellationToken ct = default ) { await using var tx = await db.Database.BeginTransactionAsync(ct); var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct); var errors = new List<(string, ValidationError?)>(); if (req.Username != null && req.Username != user.Username) { errors.Add(("username", ValidationUtils.ValidateUsername(req.Username))); user.Username = req.Username; } if (req.HasProperty(nameof(req.DisplayName))) { errors.Add(("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName))); user.DisplayName = req.DisplayName; } if (req.HasProperty(nameof(req.Bio))) { errors.Add(("bio", ValidationUtils.ValidateBio(req.Bio))); user.Bio = req.Bio; } if (req.HasProperty(nameof(req.Links))) { errors.AddRange(ValidationUtils.ValidateLinks(req.Links)); user.Links = req.Links ?? []; } if (req.Names != null) { errors.AddRange( ValidationUtils.ValidateFieldEntries( req.Names, CurrentUser!.CustomPreferences, "names" ) ); user.Names = req.Names.ToList(); } if (req.Pronouns != null) { errors.AddRange( ValidationUtils.ValidatePronouns(req.Pronouns, CurrentUser!.CustomPreferences) ); user.Pronouns = req.Pronouns.ToList(); } if (req.Fields != null) { errors.AddRange( ValidationUtils.ValidateFields(req.Fields.ToList(), CurrentUser!.CustomPreferences) ); user.Fields = req.Fields.ToList(); } if (req.Flags != null) { var flagError = await db.SetUserFlagsAsync(CurrentUser!.Id, req.Flags); if (flagError != null) errors.Add(("flags", flagError)); } if (req.HasProperty(nameof(req.Avatar))) errors.Add(("avatar", ValidationUtils.ValidateAvatar(req.Avatar))); if (req.HasProperty(nameof(req.MemberTitle))) { if (string.IsNullOrEmpty(req.MemberTitle)) { user.MemberTitle = null; } else { errors.Add(("member_title", ValidationUtils.ValidateDisplayName(req.MemberTitle))); user.MemberTitle = req.MemberTitle; } } if (req.HasProperty(nameof(req.MemberListHidden))) user.ListHidden = req.MemberListHidden == true; if (req.HasProperty(nameof(req.Timezone))) { if (string.IsNullOrEmpty(req.Timezone)) { user.Timezone = null; } else { if (TimeZoneInfo.TryFindSystemTimeZoneById(req.Timezone, out _)) user.Timezone = req.Timezone; else errors.Add( ( "timezone", ValidationError.GenericValidationError("Invalid timezone", req.Timezone) ) ); } } ValidationUtils.Validate(errors); // This is fired off regardless of whether the transaction is committed // (atomic operations are hard when combined with background jobs) // so it's in a separate block to the validation above. if (req.HasProperty(nameof(req.Avatar))) queue.QueueInvocableWithPayload( new AvatarUpdatePayload(CurrentUser!.Id, req.Avatar) ); try { await db.SaveChangesAsync(ct); } catch (UniqueConstraintException) { _logger.Debug( "Could not update user {Id} due to name conflict ({CurrentName} / {NewName})", user.Id, user.Username, req.Username ); throw new ApiError.BadRequest( "That username is already taken.", "username", req.Username! ); } await tx.CommitAsync(ct); return Ok( await userRenderer.RenderUserAsync( user, CurrentUser, renderMembers: false, renderAuthMethods: false, ct: ct ) ); } [HttpPatch("@me/custom-preferences")] [Authorize("user.update")] [ProducesResponseType>(StatusCodes.Status200OK)] public async Task UpdateCustomPreferencesAsync( [FromBody] List req, CancellationToken ct = default ) { ValidationUtils.Validate(ValidateCustomPreferences(req)); var user = await db.ResolveUserAsync(CurrentUser!.Id, ct); var preferences = user .CustomPreferences.Where(x => req.Any(r => r.Id == x.Key)) .ToDictionary(); foreach (var r in req) { if (r.Id != null && preferences.ContainsKey(r.Id.Value)) { preferences[r.Id.Value] = new User.CustomPreference { Favourite = r.Favourite, Icon = r.Icon, Muted = r.Muted, Size = r.Size, Tooltip = r.Tooltip, }; } else { preferences[snowflakeGenerator.GenerateSnowflake()] = new User.CustomPreference { Favourite = r.Favourite, Icon = r.Icon, Muted = r.Muted, Size = r.Size, Tooltip = r.Tooltip, }; } } user.CustomPreferences = preferences; await db.SaveChangesAsync(ct); return Ok(user.CustomPreferences); } [SuppressMessage("ReSharper", "ClassNeverInstantiated.Global")] public class CustomPreferencesUpdateRequest { public Snowflake? Id { get; init; } public required string Icon { get; set; } public required string Tooltip { get; set; } public PreferenceSize Size { get; set; } public bool Muted { get; set; } public bool Favourite { get; set; } } public const int MaxCustomPreferences = 25; private static List<(string, ValidationError?)> ValidateCustomPreferences( List preferences ) { var errors = new List<(string, ValidationError?)>(); if (preferences.Count > MaxCustomPreferences) errors.Add( ( "custom_preferences", ValidationError.LengthError( "Too many custom preferences", 0, MaxCustomPreferences, preferences.Count ) ) ); if (preferences.Count > 50) return errors; // TODO: validate individual preferences return errors; } public class UpdateUserRequest : PatchRequest { public string? Username { get; init; } public string? DisplayName { get; init; } public string? Bio { get; init; } public string? Avatar { get; init; } public string[]? Links { get; init; } public FieldEntry[]? Names { get; init; } public Pronoun[]? Pronouns { get; init; } public Field[]? Fields { get; init; } public Snowflake[]? Flags { get; init; } public string? MemberTitle { get; init; } public bool? MemberListHidden { get; init; } public string? Timezone { get; init; } } [HttpGet("@me/settings")] [Authorize("user.read_hidden")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task GetUserSettingsAsync(CancellationToken ct = default) { var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct); return Ok(user.Settings); } [HttpPatch("@me/settings")] [Authorize("user.read_hidden", "user.update")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task UpdateUserSettingsAsync( [FromBody] UpdateUserSettingsRequest req, CancellationToken ct = default ) { var user = await db.Users.FirstAsync(u => u.Id == CurrentUser!.Id, ct); if (req.HasProperty(nameof(req.DarkMode))) user.Settings.DarkMode = req.DarkMode; db.Update(user); await db.SaveChangesAsync(ct); return Ok(user.Settings); } public class UpdateUserSettingsRequest : PatchRequest { public bool? DarkMode { get; init; } } [HttpPost("@me/reroll-sid")] [Authorize("user.update")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] public async Task RerollSidAsync() { var minTimeAgo = clock.GetCurrentInstant() - Duration.FromHours(1); if (CurrentUser!.LastSidReroll > minTimeAgo) throw new ApiError.BadRequest("Cannot reroll short ID yet"); // Using ExecuteUpdateAsync here as the new short ID is generated by the database await db .Users.Where(u => u.Id == CurrentUser.Id) .ExecuteUpdateAsync(s => s.SetProperty(u => u.Sid, _ => db.FindFreeUserSid()) .SetProperty(u => u.LastSidReroll, clock.GetCurrentInstant()) .SetProperty(u => u.LastActive, clock.GetCurrentInstant()) ); // Get the user's new sid var newSid = await db .Users.Where(u => u.Id == CurrentUser.Id) .Select(u => u.Sid) .FirstAsync(); var user = await db.ResolveUserAsync(CurrentUser.Id); return Ok( await userRenderer.RenderUserAsync( CurrentUser, CurrentUser, CurrentToken, renderMembers: false, overrideSid: newSid ) ); } }