diff --git a/ENDPOINTS.md b/ENDPOINTS.md new file mode 100644 index 0000000..4f7fcf5 --- /dev/null +++ b/ENDPOINTS.md @@ -0,0 +1,45 @@ +# List of API endpoints and scopes + +## Scopes + +- `identify`: `@me` will refer to token user (always granted) +- `user.read_privileged`: can read privileged information such as authentication methods +- `user.update`: can update the user's profile. + **cannot** update anything locked behind `user.read_privileged` +- `member.read`: can view member list if it's hidden and enumerate unlisted members +- `member.create`: can create new members +- `member.update`: can edit and delete members + +## Meta + +- [ ] GET `/meta`: gets stats and server information + +## Users + +- [ ] GET `/users/{userRef}`: views current user. + `identify` required to use `@me` as user reference. + `user.read_privileged` required to view authentication methods. + `member.read` required to view unlisted members. +- [ ] PATCH `/users/@me`: updates current user. `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 +- [ ] GET `/users/@me/flags`: get all the user's flags. `identify` required +- [ ] POST `/users/@me/flags`: creates a new flag. `user.update` required +- [ ] PATCH `/users/@me/flags/{id}`: updates an existing flag. `user.update` required +- [ ] DELETE `/users/@me/flags/{id}`: deletes a user flag. `user.update` required +- [ ] POST `/users/@me/reroll`: rerolls a user's short ID. `user.update` required + +## Members + +- [ ] GET `/users/{userRef}/members`: gets list of a user's members. + 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. +- [ ] 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 +- [ ] PATCH `/users/@me/members/{memberRef}`: edits a member. `member.update` required +- [ ] 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. +- \ No newline at end of file diff --git a/Foxnouns.Backend/Config.cs b/Foxnouns.Backend/Config.cs index 1cb21c6..6766b3b 100644 --- a/Foxnouns.Backend/Config.cs +++ b/Foxnouns.Backend/Config.cs @@ -26,6 +26,7 @@ public class Config public string? SentryUrl { get; init; } public bool SentryTracing { get; init; } = false; public double SentryTracesSampleRate { get; init; } = 0.0; + public bool LogQueries { get; init; } = false; } public class DatabaseConfig diff --git a/Foxnouns.Backend/Controllers/MetaController.cs b/Foxnouns.Backend/Controllers/MetaController.cs index 9d2c991..5dded77 100644 --- a/Foxnouns.Backend/Controllers/MetaController.cs +++ b/Foxnouns.Backend/Controllers/MetaController.cs @@ -1,29 +1,38 @@ using Foxnouns.Backend.Database; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Mvc; +using NodaTime; namespace Foxnouns.Backend.Controllers; [Route("/api/v2/meta")] -public class MetaController(DatabaseContext db) : ApiControllerBase +public class MetaController(DatabaseContext db, IClock clock) : ApiControllerBase { + private const string Repository = "https://codeberg.org/pronounscc/pronouns.cc"; + [HttpGet] [ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MetaResponse))] public async Task GetMeta() { - var userCount = await db.Users.CountAsync(); + var now = clock.GetCurrentInstant(); + var users = await db.Users.Select(u => u.LastActive).ToListAsync(); var memberCount = await db.Members.CountAsync(); return Ok(new MetaResponse( - BuildInfo.Version, BuildInfo.Hash, memberCount, - new UserInfo(userCount, 0, 0, 0)) + Repository, BuildInfo.Version, BuildInfo.Hash, memberCount, + new UserInfo( + users.Count, + users.Count(i => i > now - Duration.FromDays(30)), + users.Count(i => i > now - Duration.FromDays(7)), + users.Count(i => i > now - Duration.FromDays(1)) + )) ); } [HttpGet("/api/v2/coffee")] public IActionResult BrewCoffee() => Problem("Sorry, I'm a teapot!", statusCode: StatusCodes.Status418ImATeapot); - private record MetaResponse(string Version, string Hash, int Members, UserInfo Users); + private record MetaResponse(string Repository, string Version, string Hash, int Members, UserInfo Users); private record UserInfo(int Total, int ActiveMonth, int ActiveWeek, int ActiveDay); } \ No newline at end of file diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs index 03e2c42..5c54ab5 100644 --- a/Foxnouns.Backend/Database/DatabaseContext.cs +++ b/Foxnouns.Backend/Database/DatabaseContext.cs @@ -10,6 +10,7 @@ namespace Foxnouns.Backend.Database; public class DatabaseContext : DbContext { private readonly NpgsqlDataSource _dataSource; + private readonly ILoggerFactory? _loggerFactory; public DbSet Users { get; set; } public DbSet Members { get; set; } @@ -19,7 +20,7 @@ public class DatabaseContext : DbContext public DbSet Applications { get; set; } public DbSet TemporaryKeys { get; set; } - public DatabaseContext(Config config) + public DatabaseContext(Config config, ILoggerFactory? loggerFactory) { var connString = new NpgsqlConnectionStringBuilder(config.Database.Url) { @@ -30,13 +31,15 @@ public class DatabaseContext : DbContext var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString); dataSourceBuilder.UseNodaTime(); _dataSource = dataSourceBuilder.Build(); + _loggerFactory = loggerFactory; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) => optionsBuilder .ConfigureWarnings(c => c.Ignore(CoreEventId.ManyServiceProvidersCreatedWarning)) .UseNpgsql(_dataSource, o => o.UseNodaTime()) - .UseSnakeCaseNamingConvention(); + .UseSnakeCaseNamingConvention() + .UseLoggerFactory(_loggerFactory); protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) { @@ -73,6 +76,6 @@ public class DesignTimeDatabaseContextFactory : IDesignTimeDbContextFactory() ?? new(); - return new DatabaseContext(config); + return new DatabaseContext(config, null); } } \ No newline at end of file diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs index 9b357fa..8262e5d 100644 --- a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs +++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs @@ -15,11 +15,13 @@ public static class DatabaseQueryExtensions if (Snowflake.TryParse(userRef, out var snowflake)) { user = await context.Users + .Where(u => !u.Deleted) .FirstOrDefaultAsync(u => u.Id == snowflake); if (user != null) return user; } user = await context.Users + .Where(u => !u.Deleted) .FirstOrDefaultAsync(u => u.Username == userRef); if (user != null) return user; throw new ApiError.NotFound("No user with that ID or username found.", code: ErrorCode.UserNotFound); @@ -28,6 +30,7 @@ public static class DatabaseQueryExtensions public static async Task ResolveUserAsync(this DatabaseContext context, Snowflake id) { var user = await context.Users + .Where(u => !u.Deleted) .FirstOrDefaultAsync(u => u.Id == id); if (user != null) return user; throw new ApiError.NotFound("No user with that ID found.", code: ErrorCode.UserNotFound); @@ -37,6 +40,7 @@ public static class DatabaseQueryExtensions { var member = await context.Members .Include(m => m.User) + .Where(m => !m.User.Deleted) .FirstOrDefaultAsync(m => m.Id == id); if (member != null) return member; throw new ApiError.NotFound("No member with that ID found.", code: ErrorCode.MemberNotFound); @@ -56,12 +60,14 @@ public static class DatabaseQueryExtensions { member = await context.Members .Include(m => m.User) + .Where(m => !m.User.Deleted) .FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId); if (member != null) return member; } member = await context.Members .Include(m => m.User) + .Where(m => !m.User.Deleted) .FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId); if (member != null) return member; throw new ApiError.NotFound("No member with that ID or name found.", code: ErrorCode.MemberNotFound); diff --git a/Foxnouns.Backend/Database/Migrations/20240712233806_AddUserLastActive.Designer.cs b/Foxnouns.Backend/Database/Migrations/20240712233806_AddUserLastActive.Designer.cs new file mode 100644 index 0000000..0430058 --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240712233806_AddUserLastActive.Designer.cs @@ -0,0 +1,515 @@ +// +using Foxnouns.Backend.Database; +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("20240712233806_AddUserLastActive")] + partial class AddUserLastActive + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.5") + .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("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/20240712233806_AddUserLastActive.cs b/Foxnouns.Backend/Database/Migrations/20240712233806_AddUserLastActive.cs new file mode 100644 index 0000000..8d1392c --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240712233806_AddUserLastActive.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + public partial class AddUserLastActive : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "last_active", + table: "users", + type: "timestamp with time zone", + nullable: false, + defaultValueSql: "now()"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "last_active", + table: "users"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/20240713000719_AddDeleted.Designer.cs b/Foxnouns.Backend/Database/Migrations/20240713000719_AddDeleted.Designer.cs new file mode 100644 index 0000000..8c2dcdc --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240713000719_AddDeleted.Designer.cs @@ -0,0 +1,528 @@ +// +using System; +using Foxnouns.Backend.Database; +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("20240713000719_AddDeleted")] + partial class AddDeleted + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.5") + .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("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/20240713000719_AddDeleted.cs b/Foxnouns.Backend/Database/Migrations/20240713000719_AddDeleted.cs new file mode 100644 index 0000000..a14ad5c --- /dev/null +++ b/Foxnouns.Backend/Database/Migrations/20240713000719_AddDeleted.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using NodaTime; + +#nullable disable + +namespace Foxnouns.Backend.Database.Migrations +{ + /// + public partial class AddDeleted : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "deleted", + table: "users", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "deleted_at", + table: "users", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "deleted_by", + table: "users", + type: "bigint", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "deleted", + table: "users"); + + migrationBuilder.DropColumn( + name: "deleted_at", + table: "users"); + + migrationBuilder.DropColumn( + name: "deleted_by", + table: "users"); + } + } +} diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs index 360d43d..4440499 100644 --- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs +++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs @@ -267,10 +267,26 @@ namespace Foxnouns.Backend.Database.Migrations .HasColumnType("text") .HasColumnName("bio"); + 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[]") @@ -335,7 +351,7 @@ namespace Foxnouns.Backend.Database.Migrations .IsRequired() .HasConstraintName("fk_members_users_user_id"); - b.OwnsOne("Foxnouns.Backend.Database.Models.Member.Fields#System.Collections.Generic.List", "Fields", b1 => + b.OwnsOne("System.Collections.Generic.List", "Fields", b1 => { b1.Property("MemberId") .HasColumnType("bigint"); @@ -345,7 +361,7 @@ namespace Foxnouns.Backend.Database.Migrations b1.HasKey("MemberId"); - b1.ToTable("members", (string)null); + b1.ToTable("members"); b1.ToJson("fields"); @@ -354,7 +370,7 @@ namespace Foxnouns.Backend.Database.Migrations .HasConstraintName("fk_members_members_id"); }); - b.OwnsOne("Foxnouns.Backend.Database.Models.Member.Names#System.Collections.Generic.List", "Names", b1 => + b.OwnsOne("System.Collections.Generic.List", "Names", b1 => { b1.Property("MemberId") .HasColumnType("bigint"); @@ -364,7 +380,7 @@ namespace Foxnouns.Backend.Database.Migrations b1.HasKey("MemberId"); - b1.ToTable("members", (string)null); + b1.ToTable("members"); b1.ToJson("names"); @@ -373,7 +389,7 @@ namespace Foxnouns.Backend.Database.Migrations .HasConstraintName("fk_members_members_id"); }); - b.OwnsOne("Foxnouns.Backend.Database.Models.Member.Pronouns#System.Collections.Generic.List", "Pronouns", b1 => + b.OwnsOne("System.Collections.Generic.List", "Pronouns", b1 => { b1.Property("MemberId") .HasColumnType("bigint"); @@ -383,7 +399,7 @@ namespace Foxnouns.Backend.Database.Migrations b1.HasKey("MemberId"); - b1.ToTable("members", (string)null); + b1.ToTable("members"); b1.ToJson("pronouns"); @@ -427,7 +443,7 @@ namespace Foxnouns.Backend.Database.Migrations modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b => { - b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 => + b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 => { b1.Property("UserId") .HasColumnType("bigint"); @@ -438,7 +454,7 @@ namespace Foxnouns.Backend.Database.Migrations b1.HasKey("UserId") .HasName("pk_users"); - b1.ToTable("users", (string)null); + b1.ToTable("users"); b1.ToJson("fields"); @@ -447,7 +463,7 @@ namespace Foxnouns.Backend.Database.Migrations .HasConstraintName("fk_users_users_user_id"); }); - b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 => + b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 => { b1.Property("UserId") .HasColumnType("bigint"); @@ -458,7 +474,7 @@ namespace Foxnouns.Backend.Database.Migrations b1.HasKey("UserId") .HasName("pk_users"); - b1.ToTable("users", (string)null); + b1.ToTable("users"); b1.ToJson("names"); @@ -467,7 +483,7 @@ namespace Foxnouns.Backend.Database.Migrations .HasConstraintName("fk_users_users_user_id"); }); - b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 => + b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 => { b1.Property("UserId") .HasColumnType("bigint"); @@ -478,7 +494,7 @@ namespace Foxnouns.Backend.Database.Migrations b1.HasKey("UserId") .HasName("pk_users"); - b1.ToTable("users", (string)null); + b1.ToTable("users"); b1.ToJson("pronouns"); diff --git a/Foxnouns.Backend/Database/Models/User.cs b/Foxnouns.Backend/Database/Models/User.cs index 986c779..b0e060c 100644 --- a/Foxnouns.Backend/Database/Models/User.cs +++ b/Foxnouns.Backend/Database/Models/User.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using System.ComponentModel.DataAnnotations.Schema; +using NodaTime; namespace Foxnouns.Backend.Database.Models; @@ -21,6 +22,13 @@ public class User : BaseModel public List Members { get; } = []; public List AuthMethods { get; } = []; + + public required Instant LastActive { get; set; } + + public bool Deleted { get; set; } + public Instant? DeletedAt { get; set; } + public Snowflake? DeletedBy { get; set; } + [NotMapped] public bool? SelfDelete => Deleted ? DeletedBy != null : null; } public enum UserRole diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs index 0bf334c..38279ac 100644 --- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs +++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs @@ -24,6 +24,8 @@ public static class WebApplicationExtensions // ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead. // Serilog doesn't disable the built-in logs, so we do it here. .MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command", + config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) @@ -34,10 +36,8 @@ public static class WebApplicationExtensions logCfg.WriteTo.Seq(config.Logging.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose); } - Log.Logger = logCfg.CreateLogger(); - // AddSerilog doesn't seem to add an ILogger to the service collection, so add that manually. - builder.Services.AddSerilog().AddSingleton(Log.Logger); + builder.Services.AddSerilog().AddSingleton(Log.Logger = logCfg.CreateLogger()); return builder; } diff --git a/Foxnouns.Backend/Services/AuthService.cs b/Foxnouns.Backend/Services/AuthService.cs index a87a50c..b4a1adf 100644 --- a/Foxnouns.Backend/Services/AuthService.cs +++ b/Foxnouns.Backend/Services/AuthService.cs @@ -8,7 +8,7 @@ using NodaTime; namespace Foxnouns.Backend.Services; -public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator snowflakeGenerator) +public class AuthService(ILogger logger, IClock clock, DatabaseContext db, ISnowflakeGenerator snowflakeGenerator) { private readonly PasswordHasher _passwordHasher = new(); @@ -26,7 +26,8 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator { new AuthMethod { Id = snowflakeGenerator.GenerateSnowflake(), AuthType = AuthType.Email, RemoteId = email } - } + }, + LastActive = clock.GetCurrentInstant() }; db.Add(user); @@ -59,7 +60,8 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator Id = snowflakeGenerator.GenerateSnowflake(), AuthType = authType, RemoteId = remoteId, RemoteUsername = remoteUsername, FediverseApplication = instance } - } + }, + LastActive = clock.GetCurrentInstant() }; db.Add(user); diff --git a/Foxnouns.Backend/Services/UserRendererService.cs b/Foxnouns.Backend/Services/UserRendererService.cs index ca5fff4..2a3754a 100644 --- a/Foxnouns.Backend/Services/UserRendererService.cs +++ b/Foxnouns.Backend/Services/UserRendererService.cs @@ -3,6 +3,7 @@ using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Utils; using Microsoft.EntityFrameworkCore; using Newtonsoft.Json; +using NodaTime; namespace Foxnouns.Backend.Services; @@ -14,12 +15,12 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe bool renderAuthMethods = false) { var isSelfUser = selfUser?.Id == user.Id; - var tokenCanReadHiddenMembers = token.HasScope("member.read"); - var tokenCanReadAuth = token.HasScope("user.read_privileged"); + var tokenCanReadHiddenMembers = token.HasScope("member.read") && isSelfUser; + var tokenPrivileged = token.HasScope("user.read_privileged") && isSelfUser; renderMembers = renderMembers && - (!user.ListHidden || (isSelfUser && tokenCanReadHiddenMembers)); - renderAuthMethods = renderAuthMethods && isSelfUser && tokenCanReadAuth; + (!user.ListHidden || tokenCanReadHiddenMembers); + renderAuthMethods = renderAuthMethods && tokenPrivileged; IEnumerable members = renderMembers ? await db.Members.Where(m => m.UserId == user.Id).ToListAsync() : []; @@ -34,7 +35,8 @@ 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.Id, user.Username, user.DisplayName, user.Bio, user.MemberTitle, AvatarUrlFor(user), user.Links, + user.Names, user.Pronouns, user.Fields, renderMembers ? members.Select(memberRendererService.RenderPartialMember) : null, renderAuthMethods @@ -42,7 +44,8 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe a.Id, a.AuthType, a.RemoteId, a.RemoteUsername, a.FediverseApplication?.Domain )) - : null + : null, + tokenPrivileged ? user.LastActive : null ); } @@ -63,7 +66,9 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] IEnumerable? Members, [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] - IEnumerable? AuthMethods + IEnumerable? AuthMethods, + [property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] + Instant? LastActive ); public record AuthenticationMethodResponse( diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs index 8dfb137..39d5870 100644 --- a/Foxnouns.Backend/Utils/AuthUtils.cs +++ b/Foxnouns.Backend/Utils/AuthUtils.cs @@ -75,7 +75,6 @@ public static class AuthUtils } catch (Exception e) { - Console.WriteLine($"Error converting string: {e}"); bytes = []; return false; } diff --git a/Foxnouns.Backend/config.example.ini b/Foxnouns.Backend/config.example.ini index c791d1f..c8d38ca 100644 --- a/Foxnouns.Backend/config.example.ini +++ b/Foxnouns.Backend/config.example.ini @@ -18,11 +18,12 @@ SentryUrl = https://examplePublicKey@o0.ingest.sentry.io/0 SentryTracing = true ; Percentage of performance traces to send to Sentry (optional). Defaults to 0.0 (no traces at all) SentryTracesSampleRate = 1.0 +; Whether to log SQL queries. Note that this is very verbose. Defaults to false. +LogQueries = false [Database] ; The database URL in ADO.NET format. Url = "Host=localhost;Database=foxnouns_net;Username=pronouns;Password=pronouns" - ; The timeout for opening new connections. Defaults to 5. Timeout = 5 ; The maximum number of open connections. Defaults to 50. diff --git a/SCOPES.md b/SCOPES.md deleted file mode 100644 index 5cab914..0000000 --- a/SCOPES.md +++ /dev/null @@ -1,18 +0,0 @@ -# List of API endpoints and scopes - -## Scopes - -- `identify`: `@me` will refer to token user (always granted) -- `user.read_privileged`: can read privileged information such as authentication methods -- `user.update`: can update the user's profile. - **cannot** update anything locked behind `user.read_privileged` -- `member.read`: can view member list if it's hidden and enumerate unlisted members -- `member.create`: can create new members -- `member.update`: can edit and delete members - -## Users - -- GET `/users/{userRef}`: `identify` required to use `@me` as user reference. - `user.read_privileged` required to view authentication methods. - `member.read` required to view unlisted members. -- PATCH `/users/@me`: `user.update` required. \ No newline at end of file