feat: update custom preferences endpoint

This commit is contained in:
sam 2024-08-22 15:13:46 +02:00
parent c4e39d4d59
commit ef221b2c45
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
13 changed files with 820 additions and 20 deletions

View file

@ -23,7 +23,8 @@
`user.read_hidden` required to view timezone and other hidden non-privileged data. `user.read_hidden` required to view timezone and other hidden non-privileged data.
`user.read_privileged` required to view authentication methods. `user.read_privileged` required to view authentication methods.
`member.read` required to view unlisted members. `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 - [ ] DELETE `/users/@me`: deletes current user. `*` required
- [ ] POST `/users/@me/export`: queues new data export. `*` required - [ ] POST `/users/@me/export`: queues new data export. `*` required
- [ ] GET `/users/@me/export`: gets latest data export. `*` required - [ ] GET `/users/@me/export`: gets latest data export. `*` required
@ -36,12 +37,12 @@
## Members ## Members
- [x] GET `/users/{userRef}/members`: gets list of a user's 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) and it is not the authenticated user (or the token doesn't have the `member.read` scope)
returns an empty array. returns an empty array.
- [x] GET `/users/{userRef}/members/{memberRef}`: gets a single member. - [x] GET `/users/{userRef}/members/{memberRef}`: gets a single member.
will always return a member if it exists, even if the member is unlisted. 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 - [ ] 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. - [ ] POST `/users/@me/members/{memberRef}/reroll`: rerolls a member's short ID. `member.update` required.

View file

@ -45,7 +45,9 @@ public class MembersController(
("name", ValidationUtils.ValidateMemberName(req.Name)), ("name", ValidationUtils.ValidateMemberName(req.Name)),
("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)), ("display_name", ValidationUtils.ValidateDisplayName(req.DisplayName)),
("bio", ValidationUtils.ValidateBio(req.Bio)), ("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 var member = new Member
@ -55,6 +57,9 @@ public class MembersController(
Name = req.Name, Name = req.Name,
DisplayName = req.DisplayName, DisplayName = req.DisplayName,
Bio = req.Bio, Bio = req.Bio,
Fields = req.Fields ?? [],
Names = req.Names ?? [],
Pronouns = req.Pronouns ?? [],
Unlisted = req.Unlisted ?? false Unlisted = req.Unlisted ?? false
}; };
db.Add(member); db.Add(member);
@ -95,5 +100,13 @@ public class MembersController(
return NoContent(); 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<FieldEntry>? Names,
List<Pronoun>? Pronouns,
List<Field>? Fields);
} }

View file

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Jobs;
@ -10,7 +11,10 @@ using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers; namespace Foxnouns.Backend.Controllers;
[Route("/api/v2/users")] [Route("/api/v2/users")]
public class UsersController(DatabaseContext db, UserRendererService userRendererService) : ApiControllerBase public class UsersController(
DatabaseContext db,
UserRendererService userRendererService,
ISnowflakeGenerator snowflakeGenerator) : ApiControllerBase
{ {
[HttpGet("{userRef}")] [HttpGet("{userRef}")]
[ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<UserRendererService.UserResponse>(statusCode: StatusCodes.Status200OK)]
@ -74,6 +78,74 @@ public class UsersController(DatabaseContext db, UserRendererService userRendere
renderAuthMethods: false)); renderAuthMethods: false));
} }
[HttpPatch("@me/custom-preferences")]
[Authorize("user.update")]
[ProducesResponseType<Dictionary<Snowflake, User.CustomPreference>>(StatusCodes.Status200OK)]
public async Task<IActionResult> UpdateCustomPreferencesAsync([FromBody] List<CustomPreferencesUpdateRequest> 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<CustomPreferencesUpdateRequest> 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 class UpdateUserRequest : PatchRequest
{ {
public string? Username { get; init; } public string? Username { get; init; }

View file

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using EntityFramework.Exceptions.PostgreSQL; using EntityFramework.Exceptions.PostgreSQL;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Extensions; using Foxnouns.Backend.Extensions;
@ -31,6 +32,7 @@ public class DatabaseContext : DbContext
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString); var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString);
dataSourceBuilder.UseNodaTime(); dataSourceBuilder.UseNodaTime();
dataSourceBuilder.UseJsonNet();
_dataSource = dataSourceBuilder.Build(); _dataSource = dataSourceBuilder.Build();
_loggerFactory = loggerFactory; _loggerFactory = loggerFactory;
} }
@ -57,18 +59,18 @@ public class DatabaseContext : DbContext
modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique(); modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique();
modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique(); modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique();
modelBuilder.Entity<User>() modelBuilder.Entity<User>().Property(u => u.Fields).HasColumnType("jsonb");
.OwnsOne(u => u.Fields, f => f.ToJson()) modelBuilder.Entity<User>().Property(u => u.Names).HasColumnType("jsonb");
.OwnsOne(u => u.Names, n => n.ToJson()) modelBuilder.Entity<User>().Property(u => u.Pronouns).HasColumnType("jsonb");
.OwnsOne(u => u.Pronouns, p => p.ToJson()); modelBuilder.Entity<User>().Property(u => u.CustomPreferences).HasColumnType("jsonb");
modelBuilder.Entity<Member>() modelBuilder.Entity<Member>().Property(m => m.Fields).HasColumnType("jsonb");
.OwnsOne(m => m.Fields, f => f.ToJson()) modelBuilder.Entity<Member>().Property(m => m.Names).HasColumnType("jsonb");
.OwnsOne(m => m.Names, n => n.ToJson()) modelBuilder.Entity<Member>().Property(m => m.Pronouns).HasColumnType("jsonb");
.OwnsOne(m => m.Pronouns, p => p.ToJson());
} }
} }
[SuppressMessage("ReSharper", "UnusedType.Global", Justification = "Used by EF Core's migration generator")]
public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory<DatabaseContext> public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory<DatabaseContext>
{ {
public DatabaseContext CreateDbContext(string[] args) public DatabaseContext CreateDbContext(string[] args)

View file

@ -0,0 +1,535 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<string[]>("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
b.Property<string[]>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<int>("AuthType")
.HasColumnType("integer")
.HasColumnName("auth_type");
b.Property<long?>("FediverseApplicationId")
.HasColumnType("bigint")
.HasColumnName("fediverse_application_id");
b.Property<string>("RemoteId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("remote_id");
b.Property<string>("RemoteUsername")
.HasColumnType("text")
.HasColumnName("remote_username");
b.Property<long>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("ClientId")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_id");
b.Property<string>("ClientSecret")
.IsRequired()
.HasColumnType("text")
.HasColumnName("client_secret");
b.Property<string>("Domain")
.IsRequired()
.HasColumnType("text")
.HasColumnName("domain");
b.Property<int>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("text")
.HasColumnName("name");
b.Property<bool>("Unlisted")
.HasColumnType("boolean")
.HasColumnName("unlisted");
b.Property<long>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint")
.HasColumnName("id");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Instant>("Expires")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires");
b.Property<string>("Key")
.IsRequired()
.HasColumnType("text")
.HasColumnName("key");
b.Property<string>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<long>("ApplicationId")
.HasColumnType("bigint")
.HasColumnName("application_id");
b.Property<Instant>("ExpiresAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("expires_at");
b.Property<byte[]>("Hash")
.IsRequired()
.HasColumnType("bytea")
.HasColumnName("hash");
b.Property<bool>("ManuallyExpired")
.HasColumnType("boolean")
.HasColumnName("manually_expired");
b.Property<string[]>("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
b.Property<long>("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<long>("Id")
.HasColumnType("bigint")
.HasColumnName("id");
b.Property<string>("Avatar")
.HasColumnType("text")
.HasColumnName("avatar");
b.Property<string>("Bio")
.HasColumnType("text")
.HasColumnName("bio");
b.Property<Dictionary<Guid, User.CustomPreference>>("CustomPreferences")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("custom_preferences");
b.Property<bool>("Deleted")
.HasColumnType("boolean")
.HasColumnName("deleted");
b.Property<Instant?>("DeletedAt")
.HasColumnType("timestamp with time zone")
.HasColumnName("deleted_at");
b.Property<long?>("DeletedBy")
.HasColumnType("bigint")
.HasColumnName("deleted_by");
b.Property<string>("DisplayName")
.HasColumnType("text")
.HasColumnName("display_name");
b.Property<Instant>("LastActive")
.HasColumnType("timestamp with time zone")
.HasColumnName("last_active");
b.Property<string[]>("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
b.Property<bool>("ListHidden")
.HasColumnType("boolean")
.HasColumnName("list_hidden");
b.Property<string>("MemberTitle")
.HasColumnType("text")
.HasColumnName("member_title");
b.Property<string>("Password")
.HasColumnType("text")
.HasColumnName("password");
b.Property<int>("Role")
.HasColumnType("integer")
.HasColumnName("role");
b.Property<string>("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<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("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<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("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<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
{
b1.Property<long>("MemberId")
.HasColumnType("bigint");
b1.Property<int>("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<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("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<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("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<long>("UserId")
.HasColumnType("bigint");
b1.Property<int>("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
}
}
}

View file

@ -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
{
/// <inheritdoc />
public partial class AddCustomPreferences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Dictionary<Guid, User.CustomPreference>>(
name: "custom_preferences",
table: "users",
type: "jsonb",
nullable: false,
defaultValueSql: "'{}'");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "custom_preferences",
table: "users");
}
}
}

View file

@ -1,6 +1,8 @@
// <auto-generated /> // <auto-generated />
using System; using System;
using System.Collections.Generic;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
@ -18,7 +20,7 @@ namespace Foxnouns.Backend.Database.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "8.0.5") .HasAnnotation("ProductVersion", "8.0.7")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@ -267,6 +269,11 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text") .HasColumnType("text")
.HasColumnName("bio"); .HasColumnName("bio");
b.Property<Dictionary<Guid, User.CustomPreference>>("CustomPreferences")
.IsRequired()
.HasColumnType("jsonb")
.HasColumnName("custom_preferences");
b.Property<bool>("Deleted") b.Property<bool>("Deleted")
.HasColumnType("boolean") .HasColumnType("boolean")
.HasColumnName("deleted"); .HasColumnName("deleted");

View file

@ -1,4 +1,6 @@
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Foxnouns.Backend.Utils;
using Newtonsoft.Json;
using NodaTime; using NodaTime;
namespace Foxnouns.Backend.Database.Models; namespace Foxnouns.Backend.Database.Models;
@ -16,6 +18,7 @@ public class User : BaseModel
public List<FieldEntry> Names { get; set; } = []; public List<FieldEntry> Names { get; set; } = [];
public List<Pronoun> Pronouns { get; set; } = []; public List<Pronoun> Pronouns { get; set; } = [];
public List<Field> Fields { get; set; } = []; public List<Field> Fields { get; set; } = [];
public Dictionary<Snowflake, CustomPreference> CustomPreferences { get; set; } = [];
public UserRole Role { get; set; } = UserRole.User; 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 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 Instant? DeletedAt { get; set; }
public Snowflake? DeletedBy { get; set; } public Snowflake? DeletedBy { get; set; }
[NotMapped] public bool? SelfDelete => Deleted ? DeletedBy != null : null; [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 public enum UserRole
@ -36,4 +51,11 @@ public enum UserRole
User, User,
Moderator, Moderator,
Admin, Admin,
}
public enum PreferenceSize
{
Large,
Normal,
Small,
} }

View file

@ -1,4 +1,6 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Newtonsoft.Json; using Newtonsoft.Json;
using NodaTime; using NodaTime;
@ -6,7 +8,8 @@ using NodaTime;
namespace Foxnouns.Backend.Database; namespace Foxnouns.Backend.Database;
[JsonConverter(typeof(JsonConverter))] [JsonConverter(typeof(JsonConverter))]
public readonly struct Snowflake(ulong value) [TypeConverter(typeof(TypeConverter))]
public readonly struct Snowflake(ulong value) : IEquatable<Snowflake>
{ {
public const long Epoch = 1_640_995_200_000; // 2022-01-01 at 00:00:00 UTC public const long Epoch = 1_640_995_200_000; // 2022-01-01 at 00:00:00 UTC
public readonly ulong Value = value; 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 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 int GetHashCode() => Value.GetHashCode();
public override string ToString() => Value.ToString(); public override string ToString() => Value.ToString();
@ -81,4 +90,18 @@ public readonly struct Snowflake(ulong value)
return ulong.Parse((string)reader.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;
}
}
} }

View file

@ -26,6 +26,7 @@
<PackageReference Include="NodaTime" Version="3.1.11"/> <PackageReference Include="NodaTime" Version="3.1.11"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/>
<PackageReference Include="Npgsql.Json.NET" Version="8.0.3" />
<PackageReference Include="Sentry.AspNetCore" Version="4.9.0" /> <PackageReference Include="Sentry.AspNetCore" Version="4.9.0" />
<PackageReference Include="Sentry.Hangfire" Version="4.9.0" /> <PackageReference Include="Sentry.Hangfire" Version="4.9.0" />
<PackageReference Include="Serilog" Version="4.0.1" /> <PackageReference Include="Serilog" Version="4.0.1" />

View file

@ -1,3 +1,4 @@
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils; using Foxnouns.Backend.Utils;
namespace Foxnouns.Backend.Middleware; namespace Foxnouns.Backend.Middleware;
@ -21,6 +22,10 @@ public class AuthorizationMiddleware : IMiddleware
if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()) if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any())
throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", throw new ApiError.Forbidden("This endpoint requires ungranted scopes.",
attribute.Scopes.Except(token.Scopes.ExpandScopes())); 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); await next(ctx);
} }

View file

@ -37,8 +37,7 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
return new UserResponse( return new UserResponse(
user.Id, user.Username, user.DisplayName, user.Bio, user.MemberTitle, AvatarUrlFor(user), user.Links, user.Id, user.Username, user.DisplayName, user.Bio, user.MemberTitle, AvatarUrlFor(user), user.Links,
user.Names, user.Names, user.Pronouns, user.Fields, user.CustomPreferences,
user.Pronouns, user.Fields,
renderMembers ? members.Select(memberRendererService.RenderPartialMember) : null, renderMembers ? members.Select(memberRendererService.RenderPartialMember) : null,
renderAuthMethods renderAuthMethods
? authMethods.Select(a => new AuthenticationMethodResponse( ? authMethods.Select(a => new AuthenticationMethodResponse(
@ -68,6 +67,7 @@ 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,
Dictionary<Snowflake, User.CustomPreference> CustomPreferences,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
IEnumerable<MemberRendererService.PartialMember>? Members, IEnumerable<MemberRendererService.PartialMember>? Members,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]

View file

@ -1,4 +1,6 @@
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
namespace Foxnouns.Backend.Utils; namespace Foxnouns.Backend.Utils;
@ -112,8 +114,93 @@ public static class ValidationUtils
return avatar?.Length switch return avatar?.Length switch
{ {
0 => ValidationError.GenericValidationError("Avatar cannot be empty", null), 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 _ => 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<Field>? fields,
IReadOnlyDictionary<Snowflake, User.CustomPreference> 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<Snowflake, User.CustomPreference> 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;
}
} }