diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs index e4f4c77..e976072 100644 --- a/Foxnouns.Backend/Controllers/FlagsController.cs +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -22,6 +22,7 @@ using Foxnouns.Backend.Services; using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; +using XidNet; namespace Foxnouns.Backend.Controllers; @@ -64,6 +65,7 @@ public class FlagsController( var flag = new PrideFlag { Id = snowflakeGenerator.GenerateSnowflake(), + LegacyId = Xid.NewXid().ToString(), UserId = CurrentUser!.Id, Name = req.Name, Description = req.Description, diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs index 0d145d8..c58af0e 100644 --- a/Foxnouns.Backend/Controllers/MembersController.cs +++ b/Foxnouns.Backend/Controllers/MembersController.cs @@ -26,6 +26,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Storage; using NodaTime; +using XidNet; namespace Foxnouns.Backend.Controllers; @@ -101,6 +102,7 @@ public class MembersController( var member = new Member { Id = snowflakeGenerator.GenerateSnowflake(), + LegacyId = Xid.NewXid().ToString(), User = CurrentUser!, Name = req.Name, DisplayName = req.DisplayName, diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs index e909ef8..f0ae29d 100644 --- a/Foxnouns.Backend/Controllers/UsersController.cs +++ b/Foxnouns.Backend/Controllers/UsersController.cs @@ -222,7 +222,7 @@ public class UsersController( .CustomPreferences.Where(x => req.Any(r => r.Id == x.Key)) .ToDictionary(); - foreach (CustomPreferenceUpdateRequest? r in req) + foreach (CustomPreferenceUpdateRequest r in req) { if (r.Id != null && preferences.ContainsKey(r.Id.Value)) { @@ -233,6 +233,7 @@ public class UsersController( Muted = r.Muted, Size = r.Size, Tooltip = r.Tooltip, + LegacyId = preferences[r.Id.Value].LegacyId, }; } else @@ -244,6 +245,7 @@ public class UsersController( Muted = r.Muted, Size = r.Size, Tooltip = r.Tooltip, + LegacyId = Guid.NewGuid(), }; } } diff --git a/Foxnouns.Backend/Controllers/V1/UsersV1Controller.cs b/Foxnouns.Backend/Controllers/V1/UsersV1Controller.cs new file mode 100644 index 0000000..e11e490 --- /dev/null +++ b/Foxnouns.Backend/Controllers/V1/UsersV1Controller.cs @@ -0,0 +1,16 @@ +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Services.V1; +using Microsoft.AspNetCore.Mvc; + +namespace Foxnouns.Backend.Controllers.V1; + +[Route("/api/v1/users")] +public class UsersV1Controller(UsersV1Service usersV1Service) : ApiControllerBase +{ + [HttpGet("{userRef}")] + public async Task GetUserAsync(string userRef, CancellationToken ct = default) + { + User user = await usersV1Service.ResolveUserAsync(userRef, CurrentToken, ct); + return Ok(await usersV1Service.RenderUserAsync(user)); + } +} diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index 42a5009..ae620f2 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -139,6 +139,26 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options) modelBuilder .HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeMemberSid))!) .HasName("find_free_member_sid"); + + // Indexes for legacy IDs for APIv1 + modelBuilder.Entity().HasIndex(u => u.LegacyId).IsUnique(); + modelBuilder.Entity().HasIndex(m => m.LegacyId).IsUnique(); + modelBuilder.Entity().HasIndex(f => f.LegacyId).IsUnique(); + + // a UUID is not an xid, but this should always be set by the application anyway. + // we're just setting it here to shut EFCore up because squashing migrations is for nerds + modelBuilder + .Entity() + .Property(u => u.LegacyId) + .HasDefaultValueSql("gen_random_uuid()"); + modelBuilder + .Entity() + .Property(m => m.LegacyId) + .HasDefaultValueSql("gen_random_uuid()"); + modelBuilder + .Entity() + .Property(f => f.LegacyId) + .HasDefaultValueSql("gen_random_uuid()"); } /// diff --git a/Foxnouns.Backend/Database/Migrations/20241225155818_AddLegacyIds.cs b/Foxnouns.Backend/Database/Migrations/20241225155818_AddLegacyIds.cs new file mode 100644 index 0000000..b8330cb --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20241225155818_AddLegacyIds.cs @@ -0,0 +1,78 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20241225155818_AddLegacyIds")] + public partial class AddLegacyIds : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "legacy_id", + table: "users", + type: "text", + nullable: false, + defaultValueSql: "gen_random_uuid()" + ); + + migrationBuilder.AddColumn( + name: "legacy_id", + table: "pride_flags", + type: "text", + nullable: false, + defaultValueSql: "gen_random_uuid()" + ); + + migrationBuilder.AddColumn( + name: "legacy_id", + table: "members", + type: "text", + nullable: false, + defaultValueSql: "gen_random_uuid()" + ); + + migrationBuilder.CreateIndex( + name: "ix_users_legacy_id", + table: "users", + column: "legacy_id", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "ix_pride_flags_legacy_id", + table: "pride_flags", + column: "legacy_id", + unique: true + ); + + migrationBuilder.CreateIndex( + name: "ix_members_legacy_id", + table: "members", + column: "legacy_id", + unique: true + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex(name: "ix_users_legacy_id", table: "users"); + + migrationBuilder.DropIndex(name: "ix_pride_flags_legacy_id", table: "pride_flags"); + + migrationBuilder.DropIndex(name: "ix_members_legacy_id", table: "members"); + + migrationBuilder.DropColumn(name: "legacy_id", table: "users"); + + migrationBuilder.DropColumn(name: "legacy_id", table: "pride_flags"); + + migrationBuilder.DropColumn(name: "legacy_id", table: "members"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index a2fa6f2..6b4f4d4 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -254,6 +254,13 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("jsonb") .HasColumnName("fields"); + b.Property("LegacyId") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasColumnName("legacy_id") + .HasDefaultValueSql("gen_random_uuid()"); + b.PrimitiveCollection("Links") .IsRequired() .HasColumnType("text[]") @@ -292,6 +299,10 @@ namespace Foxnouns.Backend.Database.Migrations b.HasKey("Id") .HasName("pk_members"); + b.HasIndex("LegacyId") + .IsUnique() + .HasDatabaseName("ix_members_legacy_id"); + b.HasIndex("Sid") .IsUnique() .HasDatabaseName("ix_members_sid"); @@ -386,6 +397,13 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("text") .HasColumnName("hash"); + b.Property("LegacyId") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasColumnName("legacy_id") + .HasDefaultValueSql("gen_random_uuid()"); + b.Property("Name") .IsRequired() .HasColumnType("text") @@ -398,6 +416,10 @@ namespace Foxnouns.Backend.Database.Migrations b.HasKey("Id") .HasName("pk_pride_flags"); + b.HasIndex("LegacyId") + .IsUnique() + .HasDatabaseName("ix_pride_flags_legacy_id"); + b.HasIndex("UserId") .HasDatabaseName("ix_pride_flags_user_id"); @@ -582,6 +604,13 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("timestamp with time zone") .HasColumnName("last_sid_reroll"); + b.Property("LegacyId") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasColumnName("legacy_id") + .HasDefaultValueSql("gen_random_uuid()"); + b.PrimitiveCollection("Links") .IsRequired() .HasColumnType("text[]") @@ -637,6 +666,10 @@ namespace Foxnouns.Backend.Database.Migrations b.HasKey("Id") .HasName("pk_users"); + b.HasIndex("LegacyId") + .IsUnique() + .HasDatabaseName("ix_users_legacy_id"); + b.HasIndex("Sid") .IsUnique() .HasDatabaseName("ix_users_sid"); diff --git a/Foxnouns.Backend/Database/Models/Member.cs b/Foxnouns.Backend/Database/Models/Member.cs index b9793e0..81a01d8 100644 --- a/Foxnouns.Backend/Database/Models/Member.cs +++ b/Foxnouns.Backend/Database/Models/Member.cs @@ -18,6 +18,7 @@ public class Member : BaseModel { public required string Name { get; set; } public string Sid { get; set; } = string.Empty; + public required string LegacyId { get; init; } public string? DisplayName { get; set; } public string? Bio { get; set; } public string? Avatar { get; set; } diff --git a/Foxnouns.Backend/Database/Models/PrideFlag.cs b/Foxnouns.Backend/Database/Models/PrideFlag.cs index f103610..0c04ab5 100644 --- a/Foxnouns.Backend/Database/Models/PrideFlag.cs +++ b/Foxnouns.Backend/Database/Models/PrideFlag.cs @@ -17,6 +17,7 @@ namespace Foxnouns.Backend.Database.Models; public class PrideFlag : BaseModel { public required Snowflake UserId { get; init; } + public required string LegacyId { get; init; } // A null hash means the flag hasn't been processed yet. public string? Hash { get; set; } diff --git a/Foxnouns.Backend/Database/Models/User.cs b/Foxnouns.Backend/Database/Models/User.cs index 12df0ae..3ad7ae3 100644 --- a/Foxnouns.Backend/Database/Models/User.cs +++ b/Foxnouns.Backend/Database/Models/User.cs @@ -25,6 +25,7 @@ public class User : BaseModel { public required string Username { get; set; } public string Sid { get; set; } = string.Empty; + public required string LegacyId { get; init; } public string? DisplayName { get; set; } public string? Bio { get; set; } public string? MemberTitle { get; set; } @@ -69,6 +70,8 @@ public class User : BaseModel // This type is generally serialized directly, so the converter is applied here. [JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] public PreferenceSize Size { get; set; } + + public Guid LegacyId { get; init; } = Guid.NewGuid(); } public static readonly Duration DeleteAfter = Duration.FromDays(30); diff --git a/Foxnouns.Backend/Dto/User.cs b/Foxnouns.Backend/Dto/User.cs index f193811..db4780a 100644 --- a/Foxnouns.Backend/Dto/User.cs +++ b/Foxnouns.Backend/Dto/User.cs @@ -36,7 +36,7 @@ public record UserResponse( IEnumerable Names, IEnumerable Pronouns, IEnumerable Fields, - Dictionary CustomPreferences, + Dictionary CustomPreferences, IEnumerable Flags, int? UtcOffset, [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role, @@ -52,6 +52,14 @@ public record UserResponse( [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Deleted ); +public record CustomPreferenceResponse( + string Icon, + string Tooltip, + bool Muted, + bool Favourite, + [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] PreferenceSize Size +); + public record AuthMethodResponse( Snowflake Id, [property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type, diff --git a/Foxnouns.Backend/Dto/V1/User.cs b/Foxnouns.Backend/Dto/V1/User.cs new file mode 100644 index 0000000..eab4c29 --- /dev/null +++ b/Foxnouns.Backend/Dto/V1/User.cs @@ -0,0 +1,77 @@ +// ReSharper disable NotAccessedPositionalProperty.Global +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Services.V1; +using Newtonsoft.Json; +using Newtonsoft.Json.Converters; +using Newtonsoft.Json.Serialization; + +namespace Foxnouns.Backend.Dto.V1; + +public record UserResponse( + string Id, + Snowflake IdNew, + string Sid, + string Name, + string? DisplayName, + string? Bio, + string? MemberTitle, + string? Avatar, + string[] Links, + FieldEntry[] Names, + PronounEntry[] Pronouns, + ProfileField[] Fields, + int? UtcOffset, + Dictionary CustomPreferences +); + +public record CustomPreference( + string Icon, + string Tooltip, + [property: JsonConverter(typeof(StringEnumConverter), typeof(SnakeCaseNamingStrategy))] + PreferenceSize Size, + bool Muted, + bool Favourite +); + +public record ProfileField(string Name, FieldEntry[] Entries) +{ + public static ProfileField FromField( + Field field, + Dictionary customPreferences + ) => new(field.Name, FieldEntry.FromEntries(field.Entries, customPreferences)); + + public static ProfileField[] FromFields( + IEnumerable fields, + Dictionary customPreferences + ) => fields.Select(f => FromField(f, customPreferences)).ToArray(); +} + +public record FieldEntry(string Value, string Status) +{ + public static FieldEntry[] FromEntries( + IEnumerable entries, + Dictionary customPreferences + ) => + entries + .Select(e => new FieldEntry( + e.Value, + V1Utils.TranslateStatus(e.Status, customPreferences) + )) + .ToArray(); +} + +public record PronounEntry(string Pronouns, string? DisplayText, string Status) +{ + public static PronounEntry[] FromPronouns( + IEnumerable pronouns, + Dictionary customPreferences + ) => + pronouns + .Select(p => new PronounEntry( + p.Value, + p.DisplayText, + V1Utils.TranslateStatus(p.Status, customPreferences) + )) + .ToArray(); +} diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 64564b2..86b4a82 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -19,6 +19,7 @@ using Foxnouns.Backend.Jobs; using Foxnouns.Backend.Middleware; using Foxnouns.Backend.Services; using Foxnouns.Backend.Services.Auth; +using Foxnouns.Backend.Services.V1; using Microsoft.EntityFrameworkCore; using Minio; using NodaTime; @@ -127,7 +128,9 @@ public static class WebApplicationExtensions .AddTransient() .AddTransient() .AddTransient() - .AddTransient(); + .AddTransient() + // Legacy services + .AddScoped(); if (!config.Logging.EnableMetrics) services.AddHostedService(); diff --git a/Foxnouns.Backend/Foxnouns.Backend.csproj b/Foxnouns.Backend/Foxnouns.Backend.csproj index 8238fc8..6f6d69f 100644 --- a/Foxnouns.Backend/Foxnouns.Backend.csproj +++ b/Foxnouns.Backend/Foxnouns.Backend.csproj @@ -44,6 +44,7 @@ + diff --git a/Foxnouns.Backend/Services/Auth/AuthService.cs b/Foxnouns.Backend/Services/Auth/AuthService.cs index f8c2428..6f32dc0 100644 --- a/Foxnouns.Backend/Services/Auth/AuthService.cs +++ b/Foxnouns.Backend/Services/Auth/AuthService.cs @@ -20,6 +20,7 @@ using Foxnouns.Backend.Utils; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using NodaTime; +using XidNet; namespace Foxnouns.Backend.Services.Auth; @@ -70,6 +71,7 @@ public class AuthService( }, LastActive = clock.GetCurrentInstant(), Sid = null!, + LegacyId = Xid.NewXid().ToString(), }; db.Add(user); @@ -116,6 +118,7 @@ public class AuthService( }, LastActive = clock.GetCurrentInstant(), Sid = null!, + LegacyId = Xid.NewXid().ToString(), }; db.Add(user); diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index df40e1a..5a90c2d 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -103,7 +103,8 @@ public class UserRendererService( user.Names, user.Pronouns, user.Fields, - user.CustomPreferences, + user.CustomPreferences.Select(x => (x.Key, RenderCustomPreference(x.Value))) + .ToDictionary(), flags.Select(f => RenderPrideFlag(f.PrideFlag)), utcOffset, user.Role, @@ -130,6 +131,14 @@ public class UserRendererService( : a.RemoteUsername ); + public static CustomPreferenceResponse RenderCustomPreference(User.CustomPreference pref) => + new(pref.Icon, pref.Tooltip, pref.Muted, pref.Favourite, pref.Size); + + public static Dictionary RenderCustomPreferences( + User user + ) => + user.CustomPreferences.Select(x => (x.Key, RenderCustomPreference(x.Value))).ToDictionary(); + public PartialUser RenderPartialUser(User user) => new( user.Id, diff --git a/Foxnouns.Backend/Services/V1/UsersV1Service.cs b/Foxnouns.Backend/Services/V1/UsersV1Service.cs new file mode 100644 index 0000000..7492256 --- /dev/null +++ b/Foxnouns.Backend/Services/V1/UsersV1Service.cs @@ -0,0 +1,92 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; +using Foxnouns.Backend.Dto.V1; +using Microsoft.EntityFrameworkCore; +using FieldEntry = Foxnouns.Backend.Dto.V1.FieldEntry; + +namespace Foxnouns.Backend.Services.V1; + +public class UsersV1Service(DatabaseContext db) +{ + public async Task ResolveUserAsync( + string userRef, + Token? token, + CancellationToken ct = default + ) + { + if (userRef == "@me") + { + if (token == null) + { + throw new ApiError.Unauthorized( + "This endpoint requires an authenticated user.", + ErrorCode.AuthenticationRequired + ); + } + + return await db.Users.FirstAsync(u => u.Id == token.UserId, ct); + } + + User? user; + if (Snowflake.TryParse(userRef, out Snowflake? sf)) + { + user = await db.Users.FirstOrDefaultAsync(u => u.Id == sf && !u.Deleted, ct); + if (user != null) + return user; + } + + user = await db.Users.FirstOrDefaultAsync(u => u.LegacyId == userRef && !u.Deleted, ct); + if (user != null) + return user; + + user = await db.Users.FirstOrDefaultAsync(u => u.Username == userRef && !u.Deleted, ct); + if (user != null) + return user; + + throw new ApiError.NotFound( + "No user with that ID or username found.", + ErrorCode.UserNotFound + ); + } + + public async Task RenderUserAsync(User user) + { + int? utcOffset = null; + if ( + user.Timezone != null + && TimeZoneInfo.TryFindSystemTimeZoneById(user.Timezone, out TimeZoneInfo? tz) + ) + { + utcOffset = (int)tz.GetUtcOffset(DateTimeOffset.UtcNow).TotalSeconds; + } + + return new UserResponse( + user.LegacyId, + user.Id, + user.Sid, + user.Username, + user.DisplayName, + user.Bio, + user.MemberTitle, + user.Avatar, + user.Links, + FieldEntry.FromEntries(user.Names, user.CustomPreferences), + PronounEntry.FromPronouns(user.Pronouns, user.CustomPreferences), + ProfileField.FromFields(user.Fields, user.CustomPreferences), + utcOffset, + user.CustomPreferences.Select(x => + ( + x.Value.LegacyId, + new CustomPreference( + x.Value.Icon, + x.Value.Tooltip, + x.Value.Size, + x.Value.Muted, + x.Value.Favourite + ) + ) + ) + .ToDictionary() + ); + } +} diff --git a/Foxnouns.Backend/Services/V1/V1Utils.cs b/Foxnouns.Backend/Services/V1/V1Utils.cs new file mode 100644 index 0000000..eb8d9c0 --- /dev/null +++ b/Foxnouns.Backend/Services/V1/V1Utils.cs @@ -0,0 +1,20 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Database.Models; + +namespace Foxnouns.Backend.Services.V1; + +public static class V1Utils +{ + public static string TranslateStatus( + string status, + Dictionary customPreferences + ) + { + if (!Snowflake.TryParse(status, out Snowflake? sf)) + return status; + + return customPreferences.TryGetValue(sf.Value, out User.CustomPreference? cf) + ? cf.LegacyId.ToString() + : "unknown"; + } +} diff --git a/Foxnouns.Backend/packages.lock.json b/Foxnouns.Backend/packages.lock.json index 5f1b968..dc238f7 100644 --- a/Foxnouns.Backend/packages.lock.json +++ b/Foxnouns.Backend/packages.lock.json @@ -293,6 +293,12 @@ "System.Runtime": "4.3.1" } }, + "Yort.Xid.Net": { + "type": "Direct", + "requested": "[2.0.1, )", + "resolved": "2.0.1", + "contentHash": "+3sNX7/RKSKheVuMz9jtWLazD+R4PXpx8va2d9SdDgvKOhETbEb0VYis8K/fD1qm/qOQT57LadToSpzReGMZlw==" + }, "BouncyCastle.Cryptography": { "type": "Transitive", "resolved": "2.5.0", diff --git a/Foxnouns.DataMigrator/UserMigrator.cs b/Foxnouns.DataMigrator/UserMigrator.cs index 0263c47..df895b9 100644 --- a/Foxnouns.DataMigrator/UserMigrator.cs +++ b/Foxnouns.DataMigrator/UserMigrator.cs @@ -39,6 +39,7 @@ public class UserMigrator( _user = new User { Id = goUser.SnowflakeId, + LegacyId = goUser.Id, Username = goUser.Username, DisplayName = goUser.DisplayName, Bio = goUser.Bio, @@ -139,6 +140,7 @@ public class UserMigrator( new PrideFlag { Id = flag.SnowflakeId, + LegacyId = flag.Id, UserId = _user!.Id, Hash = flag.Hash, Name = flag.Name, @@ -190,6 +192,7 @@ public class UserMigrator( UserId = _user!.Id, Name = goMember.Name, Sid = goMember.Sid, + LegacyId = goMember.Id, DisplayName = goMember.DisplayName, Bio = goMember.Bio, Avatar = goMember.Avatar, @@ -235,6 +238,7 @@ public class UserMigrator( "small" => PreferenceSize.Small, _ => PreferenceSize.Normal, }, + LegacyId = new Guid(id), }; }