diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs new file mode 100644 index 0000000..b08dcef --- /dev/null +++ b/Foxnouns.Backend/Controllers/FlagsController.cs @@ -0,0 +1,28 @@ +using Foxnouns.Backend.Database; +using Foxnouns.Backend.Middleware; +using Foxnouns.Backend.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Foxnouns.Backend.Controllers; + +[Route("/api/v2/users/@me/flags")] +public class FlagsController(DatabaseContext db, UserRendererService userRenderer) : ApiControllerBase +{ + [HttpGet] + [Authorize("identify")] + [ProducesResponseType>(statusCode: StatusCodes.Status200OK)] + public async Task GetFlagsAsync(CancellationToken ct = default) + { + var flags = await db.PrideFlags.Where(f => f.UserId == CurrentUser!.Id).ToListAsync(ct); + + return Ok(flags.Select(f => new PrideFlagResponse( + f.Id, userRenderer.ImageUrlFor(f), f.Name, f.Description))); + } + + private record PrideFlagResponse( + Snowflake Id, + string ImageUrl, + string Name, + string? Description); +} \ No newline at end of file diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index e907abd..81866fe 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -21,6 +21,10 @@ public class DatabaseContext : DbContext public DbSet Tokens { get; set; } public DbSet Applications { get; set; } public DbSet TemporaryKeys { get; set; } + + public DbSet PrideFlags { get; set; } + public DbSet UserFlags { get; set; } + public DbSet MemberFlags { get; set; } public DatabaseContext(Config config, ILoggerFactory? loggerFactory) { @@ -77,6 +81,9 @@ public class DatabaseContext : DbContext modelBuilder.Entity().Property(m => m.Names).HasColumnType("jsonb"); modelBuilder.Entity().Property(m => m.Pronouns).HasColumnType("jsonb"); + modelBuilder.Entity().Navigation(f => f.PrideFlag).AutoInclude(); + modelBuilder.Entity().Navigation(f => f.PrideFlag).AutoInclude(); + modelBuilder.HasDbFunction(typeof(DatabaseContext).GetMethod(nameof(FindFreeUserSid))!) .HasName("find_free_user_sid"); diff --git a/Foxnouns.Backend/Database/Migrations/20240926180037_AddFlags.cs b/Foxnouns.Backend/Database/Migrations/20240926180037_AddFlags.cs new file mode 100644 index 0000000..ae59b0b --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240926180037_AddFlags.cs @@ -0,0 +1,129 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + [DbContext(typeof(DatabaseContext))] + [Migration("20240926180037_AddFlags")] + public partial class AddFlags : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "pride_flags", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false), + user_id = table.Column(type: "bigint", nullable: false), + hash = table.Column(type: "text", nullable: false), + name = table.Column(type: "text", nullable: false), + description = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_pride_flags", x => x.id); + table.ForeignKey( + name: "fk_pride_flags_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "member_flags", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + member_id = table.Column(type: "bigint", nullable: false), + pride_flag_id = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_member_flags", x => x.id); + table.ForeignKey( + name: "fk_member_flags_members_member_id", + column: x => x.member_id, + principalTable: "members", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_member_flags_pride_flags_pride_flag_id", + column: x => x.pride_flag_id, + principalTable: "pride_flags", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "user_flags", + columns: table => new + { + id = table.Column(type: "bigint", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + user_id = table.Column(type: "bigint", nullable: false), + pride_flag_id = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_user_flags", x => x.id); + table.ForeignKey( + name: "fk_user_flags_pride_flags_pride_flag_id", + column: x => x.pride_flag_id, + principalTable: "pride_flags", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_user_flags_users_user_id", + column: x => x.user_id, + principalTable: "users", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_member_flags_member_id", + table: "member_flags", + column: "member_id"); + + migrationBuilder.CreateIndex( + name: "ix_member_flags_pride_flag_id", + table: "member_flags", + column: "pride_flag_id"); + + migrationBuilder.CreateIndex( + name: "ix_pride_flags_user_id", + table: "pride_flags", + column: "user_id"); + + migrationBuilder.CreateIndex( + name: "ix_user_flags_pride_flag_id", + table: "user_flags", + column: "pride_flag_id"); + + migrationBuilder.CreateIndex( + name: "ix_user_flags_user_id", + table: "user_flags", + column: "user_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "member_flags"); + + migrationBuilder.DropTable( + name: "user_flags"); + + migrationBuilder.DropTable( + name: "pride_flags"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index dd457cf..e1e05c2 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -204,6 +204,68 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("members", (string)null); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("MemberId") + .HasColumnType("bigint") + .HasColumnName("member_id"); + + b.Property("PrideFlagId") + .HasColumnType("bigint") + .HasColumnName("pride_flag_id"); + + b.HasKey("Id") + .HasName("pk_member_flags"); + + b.HasIndex("MemberId") + .HasDatabaseName("ix_member_flags_member_id"); + + b.HasIndex("PrideFlagId") + .HasDatabaseName("ix_member_flags_pride_flag_id"); + + b.ToTable("member_flags", (string)null); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => + { + b.Property("Id") + .HasColumnType("bigint") + .HasColumnName("id"); + + b.Property("Description") + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Hash") + .IsRequired() + .HasColumnType("text") + .HasColumnName("hash"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_pride_flags"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_pride_flags_user_id"); + + b.ToTable("pride_flags", (string)null); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.TemporaryKey", b => { b.Property("Id") @@ -391,6 +453,35 @@ namespace Foxnouns.Backend.Database.Migrations b.ToTable("users", (string)null); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("PrideFlagId") + .HasColumnType("bigint") + .HasColumnName("pride_flag_id"); + + b.Property("UserId") + .HasColumnType("bigint") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("pk_user_flags"); + + b.HasIndex("PrideFlagId") + .HasDatabaseName("ix_user_flags_pride_flag_id"); + + b.HasIndex("UserId") + .HasDatabaseName("ix_user_flags_user_id"); + + b.ToTable("user_flags", (string)null); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b => { b.HasOne("Foxnouns.Backend.Database.Models.FediverseApplication", "FediverseApplication") @@ -422,6 +513,35 @@ namespace Foxnouns.Backend.Database.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.MemberFlag", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.Member", null) + .WithMany("ProfileFlags") + .HasForeignKey("MemberId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_member_flags_members_member_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag") + .WithMany() + .HasForeignKey("PrideFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_member_flags_pride_flags_pride_flag_id"); + + b.Navigation("PrideFlag"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.PrideFlag", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.User", null) + .WithMany("Flags") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_pride_flags_users_user_id"); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b => { b.HasOne("Foxnouns.Backend.Database.Models.Application", "Application") @@ -443,11 +563,39 @@ namespace Foxnouns.Backend.Database.Migrations b.Navigation("User"); }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.UserFlag", b => + { + b.HasOne("Foxnouns.Backend.Database.Models.PrideFlag", "PrideFlag") + .WithMany() + .HasForeignKey("PrideFlagId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_flags_pride_flags_pride_flag_id"); + + b.HasOne("Foxnouns.Backend.Database.Models.User", null) + .WithMany("ProfileFlags") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_flags_users_user_id"); + + b.Navigation("PrideFlag"); + }); + + modelBuilder.Entity("Foxnouns.Backend.Database.Models.Member", b => + { + b.Navigation("ProfileFlags"); + }); + modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => { b.Navigation("AuthMethods"); + b.Navigation("Flags"); + b.Navigation("Members"); + + b.Navigation("ProfileFlags"); }); #pragma warning restore 612, 618 } diff --git a/Foxnouns.Backend/Database/Models/Member.cs b/Foxnouns.Backend/Database/Models/Member.cs index d20d374..ceaf84a 100644 --- a/Foxnouns.Backend/Database/Models/Member.cs +++ b/Foxnouns.Backend/Database/Models/Member.cs @@ -14,6 +14,8 @@ public class Member : BaseModel public List Pronouns { get; set; } = []; public List Fields { get; set; } = []; + public List ProfileFlags { get; set; } = []; + public Snowflake UserId { get; init; } public User User { get; init; } = null!; } \ No newline at end of file diff --git a/Foxnouns.Backend/Database/Models/PrideFlag.cs b/Foxnouns.Backend/Database/Models/PrideFlag.cs new file mode 100644 index 0000000..b7f91cf --- /dev/null +++ b/Foxnouns.Backend/Database/Models/PrideFlag.cs @@ -0,0 +1,25 @@ +namespace Foxnouns.Backend.Database.Models; + +public class PrideFlag : BaseModel +{ + public required Snowflake UserId { get; init; } + public required string Hash { get; init; } + public required string Name { get; set; } + public string? Description { get; set; } +} + +public class UserFlag +{ + public long Id { get; init; } + public required Snowflake UserId { get; init; } + public required Snowflake PrideFlagId { get; init; } + public PrideFlag PrideFlag { get; init; } = null!; +} + +public class MemberFlag +{ + public long Id { get; init; } + public required Snowflake MemberId { get; init; } + public required Snowflake PrideFlagId { get; init; } + public PrideFlag PrideFlag { get; init; } = null!; +} \ No newline at end of file diff --git a/Foxnouns.Backend/Database/Models/User.cs b/Foxnouns.Backend/Database/Models/User.cs index 112e560..2328fbe 100644 --- a/Foxnouns.Backend/Database/Models/User.cs +++ b/Foxnouns.Backend/Database/Models/User.cs @@ -21,6 +21,9 @@ public class User : BaseModel public List Fields { get; set; } = []; public Dictionary CustomPreferences { get; set; } = []; + public List Flags { get; set; } = []; + public List ProfileFlags { 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 diff --git a/Foxnouns.Backend/Services/MemberRendererService.cs b/Foxnouns.Backend/Services/MemberRendererService.cs index 625e8b8..11416f0 100644 --- a/Foxnouns.Backend/Services/MemberRendererService.cs +++ b/Foxnouns.Backend/Services/MemberRendererService.cs @@ -47,6 +47,8 @@ public class MemberRendererService(DatabaseContext db, Config config) private string? AvatarUrlFor(User user) => user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null; + private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp"; + public record PartialMember( Snowflake Id, string Sid, diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index 0f8fe2e..688214d 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -58,6 +58,8 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe private string? AvatarUrlFor(User user) => user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null; + + public string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp"; public record UserResponse( Snowflake Id,