diff --git a/ENDPOINTS.md b/ENDPOINTS.md index 41ca62a..ddadf1e 100644 --- a/ENDPOINTS.md +++ b/ENDPOINTS.md @@ -23,7 +23,8 @@ `user.read_hidden` required to view timezone and other hidden non-privileged data. `user.read_privileged` required to view authentication methods. `member.read` required to view unlisted members. -- [x] PATCH `/users/@me`: updates current user. `user.update` required. +- [x] PATCH `/users/@me`: updates current user. `user.update` required +- [x] PATCH `/users/@me/custom-preferences`: updates user's custom preferences. `user.update` required - [ ] DELETE `/users/@me`: deletes current user. `*` required - [ ] POST `/users/@me/export`: queues new data export. `*` required - [ ] GET `/users/@me/export`: gets latest data export. `*` required @@ -36,12 +37,12 @@ ## Members - [x] GET `/users/{userRef}/members`: gets list of a user's members. - if the user's member list is hidden, + if the user's member list is hidden, and it is not the authenticated user (or the token doesn't have the `member.read` scope) returns an empty array. - [x] GET `/users/{userRef}/members/{memberRef}`: gets a single member. will always return a member if it exists, even if the member is unlisted. -- [ ] POST `/users/@me/members`: creates a new member. `member.create` required +- [x] POST `/users/@me/members`: creates a new member. `member.create` required - [ ] PATCH `/users/@me/members/{memberRef}`: edits a member. `member.update` required -- [ ] DELETE `/users/@me/members/{memberRef}`: deletes a member. `member.update` required +- [x] DELETE `/users/@me/members/{memberRef}`: deletes a member. `member.update` required - [ ] POST `/users/@me/members/{memberRef}/reroll`: rerolls a member's short ID. `member.update` required. diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index 7aea73c..ec28ee5 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -45,7 +45,9 @@ public class MembersController( ("name", ValidationUtils.ValidateMemberName(req.Name)), ("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)), ("bio", ValidationUtils.ValidateBio(req.Bio)), - ("avatar", ValidationUtils.ValidateAvatar(req.Avatar)) + ("avatar", ValidationUtils.ValidateAvatar(req.Avatar)), + ..ValidationUtils.ValidateFields(req.Fields, CurrentUser!.CustomPreferences), + ..ValidationUtils.ValidateFieldEntries(req.Names?.ToArray(), CurrentUser!.CustomPreferences, "names") ]); var member = new Member @@ -55,6 +57,9 @@ public class MembersController( Name = req.Name, DisplayName = req.DisplayName, Bio = req.Bio, + Fields = req.Fields ?? [], + Names = req.Names ?? [], + Pronouns = req.Pronouns ?? [], Unlisted = req.Unlisted ?? false }; db.Add(member); @@ -95,5 +100,13 @@ public class MembersController( return NoContent(); } - public record CreateMemberRequest(string Name, string? DisplayName, string? Bio, string? Avatar, bool? Unlisted); + public record CreateMemberRequest( + string Name, + string? DisplayName, + string? Bio, + string? Avatar, + bool? Unlisted, + List? Names, + List? Pronouns, + List? Fields); } \ No newline at end of file diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index ab9ad0e..19bb88a 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using Foxnouns.Backend.Database; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Jobs; @@ -10,7 +11,10 @@ using Microsoft.EntityFrameworkCore; namespace Foxnouns.Backend.Controllers; [Route("/api/v2/users")] -public class UsersController(DatabaseContext db, UserRendererService userRendererService) : ApiControllerBase +public class UsersController( + DatabaseContext db, + UserRendererService userRendererService, + ISnowflakeGenerator snowflakeGenerator) : ApiControllerBase { [HttpGet("{userRef}")] [ProducesResponseType(statusCode: StatusCodes.Status200OK)] @@ -74,6 +78,74 @@ public class UsersController(DatabaseContext db, UserRendererService userRendere renderAuthMethods: false)); } + [HttpPatch("@me/custom-preferences")] + [Authorize("user.update")] + [ProducesResponseType>(StatusCodes.Status200OK)] + public async Task UpdateCustomPreferencesAsync([FromBody] List req) + { + ValidationUtils.Validate(ValidateCustomPreferences(req)); + + var user = await db.ResolveUserAsync(CurrentUser!.Id); + 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(); + + 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; } + } + + private static List<(string, ValidationError?)> ValidateCustomPreferences( + List preferences) + { + var errors = new List<(string, ValidationError?)>(); + + if (preferences.Count > 25) + errors.Add(("custom_preferences", + ValidationError.LengthError("Too many custom preferences", 0, 25, preferences.Count))); + if (preferences.Count > 50) return errors; + + // TODO: validate individual preferences + + return errors; + } + public class UpdateUserRequest : PatchRequest { public string? Username { get; init; } diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index cd4ae8b..725afb6 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -1,3 +1,4 @@ +using System.Diagnostics.CodeAnalysis; using EntityFramework.Exceptions.PostgreSQL; using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Extensions; @@ -31,6 +32,7 @@ public class DatabaseContext : DbContext var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString); dataSourceBuilder.UseNodaTime(); + dataSourceBuilder.UseJsonNet(); _dataSource = dataSourceBuilder.Build(); _loggerFactory = loggerFactory; } @@ -57,18 +59,18 @@ public class DatabaseContext : DbContext modelBuilder.Entity().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); modelBuilder.Entity().HasIndex(k => k.Key).IsUnique(); - modelBuilder.Entity() - .OwnsOne(u => u.Fields, f => f.ToJson()) - .OwnsOne(u => u.Names, n => n.ToJson()) - .OwnsOne(u => u.Pronouns, p => p.ToJson()); + modelBuilder.Entity().Property(u => u.Fields).HasColumnType("jsonb"); + modelBuilder.Entity().Property(u => u.Names).HasColumnType("jsonb"); + modelBuilder.Entity().Property(u => u.Pronouns).HasColumnType("jsonb"); + modelBuilder.Entity().Property(u => u.CustomPreferences).HasColumnType("jsonb"); - modelBuilder.Entity() - .OwnsOne(m => m.Fields, f => f.ToJson()) - .OwnsOne(m => m.Names, n => n.ToJson()) - .OwnsOne(m => m.Pronouns, p => p.ToJson()); + modelBuilder.Entity().Property(m => m.Fields).HasColumnType("jsonb"); + modelBuilder.Entity().Property(m => m.Names).HasColumnType("jsonb"); + modelBuilder.Entity().Property(m => m.Pronouns).HasColumnType("jsonb"); } } +[SuppressMessage("ReSharper", "UnusedType.Global", Justification = "Used by EF Core's migration generator")] public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory { public DatabaseContext CreateDbContext(string[] args) diff --git a/Foxnouns.Backend/Database/Migrations/20240821210355_AddCustomPreferences.Designer.cs b/Foxnouns.Backend/Database/Migrations/20240821210355_AddCustomPreferences.Designer.cs new file mode 100644 index 0000000..c08b9fb --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240821210355_AddCustomPreferences.Designer.cs @@ -0,0 +1,535 @@ +// +using System; +using System.Collections.Generic; +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NodaTime; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20240821210355_AddCustomPreferences")] + partial class AddCustomPreferences + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.7") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Application", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("RedirectUris") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("redirect_uris"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("scopes"); + + b.HasKey("Id") + .HasName("pk_applications"); + + b.ToTable("applications", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("AuthType") + .HasColumnType("integer") + .HasColumnName("auth_type"); + + b.Property("FediverseApplicationId") + .HasColumnType("bigint") + .HasColumnName("fediverse_application_id"); + + b.Property("RemoteId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("remote_id"); + + b.Property("RemoteUsername") + .HasColumnType("text") + .HasColumnName("remote_username"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_auth_methods"); + + b.HasIndex("FediverseApplicationId") + .HasDatabaseName("ix_auth_methods_fediverse_application_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_auth_methods_user_id"); + + b.ToTable("auth_methods", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.FediverseApplication", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("ClientId") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_id"); + + b.Property("ClientSecret") + .IsRequired() + .HasColumnType("text") + .HasColumnName("client_secret"); + + b.Property("Domain") + .IsRequired() + .HasColumnType("text") + .HasColumnName("domain"); + + b.Property("InstanceType") + .HasColumnType("integer") + .HasColumnName("instance_type"); + + b.HasKey("Id") + .HasName("pk_fediverse_applications"); + + b.ToTable("fediverse_applications", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("Links") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("links"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("Unlisted") + .HasColumnType("boolean") + .HasColumnName("unlisted"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_members"); + + b.HasIndex("UserId", "Name") + .IsUnique() + .HasDatabaseName("ix_members_user_id_name"); + + b.ToTable("members", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Expires") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text") + .HasColumnName("key"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Id") + .HasName("pk_temporary_keys"); + + b.HasIndex("Key") + .IsUnique() + .HasDatabaseName("ix_temporary_keys_key"); + + b.ToTable("temporary_keys", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("ApplicationId") + .HasColumnType("bigint") + .HasColumnName("application_id"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("bytea") + .HasColumnName("hash"); + + b.Property("ManuallyExpired") + .HasColumnType("boolean") + .HasColumnName("manually_expired"); + + b.Property("Scopes") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("scopes"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_tokens"); + + b.HasIndex("ApplicationId") + .HasDatabaseName("ix_tokens_application_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_tokens_user_id"); + + b.ToTable("tokens", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("Bio") + .HasColumnType("text") + .HasColumnName("bio"); + + b.Property>("CustomPreferences") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("custom_preferences"); + + b.Property("Deleted") + .HasColumnType("boolean") + .HasColumnName("deleted"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deleted_at"); + + b.Property("DeletedBy") + .HasColumnType("bigint") + .HasColumnName("deleted_by"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("LastActive") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_active"); + + b.Property("Links") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("links"); + + b.Property("ListHidden") + .HasColumnType("boolean") + .HasColumnName("list_hidden"); + + b.Property("MemberTitle") + .HasColumnType("text") + .HasColumnName("member_title"); + + b.Property("Password") + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_users_username"); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication") + .WithMany() + .HasForeignKey("FediverseApplicationId") + .HasConstraintName("fk_auth_methods_fediverse_applications_fediverse_application_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.User", "User") + .WithMany("AuthMethods") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_auth_methods_users_user_id"); + + b.Navigation("FediverseApplication"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", "User") + .WithMany("Members") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_members_users_user_id"); + + b.OwnsOne("System.Collections.Generic.List", "Fields", b1 => + { + b1.Property("MemberId") + .HasColumnType("bigint"); + + b1.Property("Capacity") + .HasColumnType("integer"); + + b1.HasKey("MemberId"); + + b1.ToTable("members"); + + b1.ToJson("fields"); + + b1.WithOwner() + .HasForeignKey("MemberId") + .HasConstraintName("fk_members_members_id"); + }); + + b.OwnsOne("System.Collections.Generic.List", "Names", b1 => + { + b1.Property("MemberId") + .HasColumnType("bigint"); + + b1.Property("Capacity") + .HasColumnType("integer"); + + b1.HasKey("MemberId"); + + b1.ToTable("members"); + + b1.ToJson("names"); + + b1.WithOwner() + .HasForeignKey("MemberId") + .HasConstraintName("fk_members_members_id"); + }); + + b.OwnsOne("System.Collections.Generic.List", "Pronouns", b1 => + { + b1.Property("MemberId") + .HasColumnType("bigint"); + + b1.Property("Capacity") + .HasColumnType("integer"); + + b1.HasKey("MemberId"); + + b1.ToTable("members"); + + b1.ToJson("pronouns"); + + b1.WithOwner() + .HasForeignKey("MemberId") + .HasConstraintName("fk_members_members_id"); + }); + + b.Navigation("Fields") + .IsRequired(); + + b.Navigation("Names") + .IsRequired(); + + b.Navigation("Pronouns") + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application") + .WithMany() + .HasForeignKey("ApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tokens_applications_application_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tokens_users_user_id"); + + b.Navigation("Application"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => + { + b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 => + { + b1.Property("UserId") + .HasColumnType("bigint"); + + b1.Property("Capacity") + .HasColumnType("integer"); + + b1.HasKey("UserId") + .HasName("pk_users"); + + b1.ToTable("users"); + + b1.ToJson("fields"); + + b1.WithOwner() + .HasForeignKey("UserId") + .HasConstraintName("fk_users_users_user_id"); + }); + + b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 => + { + b1.Property("UserId") + .HasColumnType("bigint"); + + b1.Property("Capacity") + .HasColumnType("integer"); + + b1.HasKey("UserId") + .HasName("pk_users"); + + b1.ToTable("users"); + + b1.ToJson("names"); + + b1.WithOwner() + .HasForeignKey("UserId") + .HasConstraintName("fk_users_users_user_id"); + }); + + b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 => + { + b1.Property("UserId") + .HasColumnType("bigint"); + + b1.Property("Capacity") + .HasColumnType("integer"); + + b1.HasKey("UserId") + .HasName("pk_users"); + + b1.ToTable("users"); + + b1.ToJson("pronouns"); + + b1.WithOwner() + .HasForeignKey("UserId") + .HasConstraintName("fk_users_users_user_id"); + }); + + b.Navigation("Fields") + .IsRequired(); + + b.Navigation("Names") + .IsRequired(); + + b.Navigation("Pronouns") + .IsRequired(); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => + { + b.Navigation("AuthMethods"); + + b.Navigation("Members"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/20240821210355_AddCustomPreferences.cs b/Foxnouns.Backend/Database/Migrations/20240821210355_AddCustomPreferences.cs new file mode 100644 index 0000000..7d68ad7 --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240821210355_AddCustomPreferences.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using Foxnouns.Backend.Database.Models; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + public partial class AddCustomPreferences : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn>( + name: "custom_preferences", + table: "users", + type: "jsonb", + nullable: false, + defaultValueSql: "'{}'"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "custom_preferences", + table: "users"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index 4440499..fc99285 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -1,6 +1,8 @@ // using System; +using System.Collections.Generic; using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -18,7 +20,7 @@ namespace Foxnouns.Backend.Database.Migrations { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "8.0.5") + .HasAnnotation("ProductVersion", "8.0.7") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -267,6 +269,11 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("text") .HasColumnName("bio"); + b.Property>("CustomPreferences") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("custom_preferences"); + b.Property("Deleted") .HasColumnType("boolean") .HasColumnName("deleted"); diff --git a/Foxnouns.Backend/Database/Models/User.cs b/Foxnouns.Backend/Database/Models/User.cs index b0e060c..305bd46 100644 --- a/Foxnouns.Backend/Database/Models/User.cs +++ b/Foxnouns.Backend/Database/Models/User.cs @@ -1,4 +1,6 @@ using System.ComponentModel.DataAnnotations.Schema; +using Foxnouns.Backend.Utils; +using Newtonsoft.Json; using NodaTime; namespace Foxnouns.Backend.Database.Models; @@ -16,6 +18,7 @@ public class User : BaseModel public List Names { get; set; } = []; public List Pronouns { get; set; } = []; public List Fields { get; set; } = []; + public Dictionary CustomPreferences { get; set; } = []; public UserRole Role { get; set; } = UserRole.User; public string? Password { get; set; } // Password may be null if the user doesn't authenticate with an email address @@ -29,6 +32,18 @@ public class User : BaseModel public Instant? DeletedAt { get; set; } public Snowflake? DeletedBy { get; set; } [NotMapped] public bool? SelfDelete => Deleted ? DeletedBy != null : null; + + public class CustomPreference + { + public required string Icon { get; set; } + public required string Tooltip { get; set; } + public bool Muted { get; set; } + public bool Favourite { get; set; } + + // This type is generally serialized directly, so the converter is applied here. + [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] + public PreferenceSize Size { get; set; } + } } public enum UserRole @@ -36,4 +51,11 @@ public enum UserRole User, Moderator, Admin, +} + +public enum PreferenceSize +{ + Large, + Normal, + Small, } \ No newline at end of file diff --git a/Foxnouns.Backend/Database/Snowflake.cs b/Foxnouns.Backend/Database/Snowflake.cs index 04937da..78efee6 100644 --- a/Foxnouns.Backend/Database/Snowflake.cs +++ b/Foxnouns.Backend/Database/Snowflake.cs @@ -1,4 +1,6 @@ +using System.ComponentModel; using System.Diagnostics.CodeAnalysis; +using System.Globalization; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Newtonsoft.Json; using NodaTime; @@ -6,7 +8,8 @@ using NodaTime; namespace Foxnouns.Backend.Database; [JsonConverter(typeof(JsonConverter))] -public readonly struct Snowflake(ulong value) +[TypeConverter(typeof(TypeConverter))] +public readonly struct Snowflake(ulong value) : IEquatable { public const long Epoch = 1_640_995_200_000; // 2022-01-01 at 00:00:00 UTC public readonly ulong Value = value; @@ -55,6 +58,12 @@ public readonly struct Snowflake(ulong value) } public override bool Equals(object? obj) => obj is Snowflake other && Value == other.Value; + + public bool Equals(Snowflake other) + { + return Value == other.Value; + } + public override int GetHashCode() => Value.GetHashCode(); public override string ToString() => Value.ToString(); @@ -81,4 +90,18 @@ public readonly struct Snowflake(ulong value) return ulong.Parse((string)reader.Value!); } } + + private class TypeConverter : System.ComponentModel.TypeConverter + { + public override bool CanConvertFrom(ITypeDescriptorContext? context, Type sourceType) => + sourceType == typeof(string); + + public override bool CanConvertTo(ITypeDescriptorContext? context, [NotNullWhen(true)] Type? destinationType) => + destinationType == typeof(Snowflake); + + public override object? ConvertFrom(ITypeDescriptorContext? context, CultureInfo? culture, object value) + { + return TryParse((string)value, out var snowflake) ? snowflake : null; + } + } } \ No newline at end of file diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index 45f5cd0..29c91d7 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -26,6 +26,7 @@ + diff --git a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs index 570d917..dd0d97f 100644 --- a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs +++ b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs @@ -1,3 +1,4 @@ +using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Utils; namespace Foxnouns.Backend.Middleware; @@ -21,6 +22,10 @@ public class AuthorizationMiddleware : IMiddleware if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()) throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.Scopes.ExpandScopes())); + if (attribute.RequireAdmin && token.User.Role != UserRole.Admin) + throw new ApiError.Forbidden("This endpoint can only be used by admins."); + if (attribute.RequireModerator && token.User.Role != UserRole.Admin && token.User.Role != UserRole.Moderator) + throw new ApiError.Forbidden("This endpoint can only be used by moderators."); await next(ctx); } diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index c423e59..9bb198e 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -37,8 +37,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe return new UserResponse( user.Id, user.Username, user.DisplayName, user.Bio, user.MemberTitle, AvatarUrlFor(user), user.Links, - user.Names, - user.Pronouns, user.Fields, + user.Names, user.Pronouns, user.Fields, user.CustomPreferences, renderMembers ? members.Select(memberRendererService.RenderPartialMember) : null, renderAuthMethods ? authMethods.Select(a => new AuthenticationMethodResponse( @@ -68,6 +67,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe IEnumerable Names, IEnumerable Pronouns, IEnumerable Fields, + Dictionary CustomPreferences, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] IEnumerable? Members, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] diff --git a/Foxnouns.Backend/Utils/ValidationUtils.cs b/Foxnouns.Backend/Utils/ValidationUtils.cs index 64c0c53..276395f 100644 --- a/Foxnouns.Backend/Utils/ValidationUtils.cs +++ b/Foxnouns.Backend/Utils/ValidationUtils.cs @@ -1,4 +1,6 @@ using System.Text.RegularExpressions; +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; namespace Foxnouns.Backend.Utils; @@ -112,8 +114,93 @@ public static class ValidationUtils return avatar?.Length switch { 0 => ValidationError.GenericValidationError("Avatar cannot be empty", null), - > 1_500_00 => ValidationError.GenericValidationError("Avatar is too large", null), + > 1_500_000 => ValidationError.GenericValidationError("Avatar is too large", null), _ => null }; } + + private const int FieldLimit = 25; + private const int FieldNameLimit = 100; + private const int FieldEntryTextLimit = 100; + private const int FieldEntriesLimit = 100; + + private static readonly string[] DefaultStatusOptions = + [ + "favourite", + "okay", + "jokingly", + "friends_only", + "avoid" + ]; + + public static IEnumerable<(string, ValidationError?)> ValidateFields(List? fields, + IReadOnlyDictionary customPreferences) + { + if (fields == null) return []; + + var errors = new List<(string, ValidationError?)>(); + if (fields.Count > 25) + errors.Add(("fields", ValidationError.LengthError("Too many fields", 0, FieldLimit, fields.Count))); + // No overwhelming this function, thank you + if (fields.Count > 100) return errors; + + foreach (var (field, index) in fields.Select((field, index) => (field, index))) + { + switch (field.Name.Length) + { + case > FieldNameLimit: + errors.Add(($"fields.{index}.name", + ValidationError.LengthError("Field name is too long", 1, FieldNameLimit, field.Name.Length))); + break; + case < 1: + errors.Add(($"fields.{index}.name", + ValidationError.LengthError("Field name is too short", 1, FieldNameLimit, field.Name.Length))); + break; + } + + errors = errors.Concat(ValidateFieldEntries(field.Entries, customPreferences, $"fields.{index}")).ToList(); + } + + return errors; + } + + public static IEnumerable<(string, ValidationError?)> ValidateFieldEntries(FieldEntry[]? entries, + IReadOnlyDictionary customPreferences, string errorPrefix = "fields") + { + if (entries == null || entries.Length == 0) return []; + var errors = new List<(string, ValidationError?)>(); + + if (entries.Length > FieldEntriesLimit) + errors.Add(($"{errorPrefix}.entries", + ValidationError.LengthError("Field has too many entries", 0, FieldEntriesLimit, + entries.Length))); + + // Same as above, no overwhelming this function with a ridiculous amount of entries + if (entries.Length > FieldEntriesLimit + 50) return errors; + + foreach (var (entry, entryIdx) in entries.Select((entry, entryIdx) => (entry, entryIdx))) + { + switch (entry.Value.Length) + { + case > FieldEntryTextLimit: + errors.Add(($"{errorPrefix}.entries.{entryIdx}.value", + ValidationError.LengthError("Field value is too long", 1, FieldEntryTextLimit, + entry.Value.Length))); + break; + case < 1: + errors.Add(($"{errorPrefix}.entries.{entryIdx}.value", + ValidationError.LengthError("Field value is too short", 1, FieldEntryTextLimit, + entry.Value.Length))); + break; + } + + var customPreferenceIds = customPreferences?.Keys.Select(id => id.ToString()) ?? []; + + if (!DefaultStatusOptions.Contains(entry.Status) && !customPreferenceIds.Contains(entry.Status)) + errors.Add(($"{errorPrefix}.entries.{entryIdx}.status", + ValidationError.GenericValidationError("Invalid status", entry.Status))); + } + + return errors; + } } \ No newline at end of file