feat: add deleted user columns in database

This commit is contained in:
sam 2024-07-13 03:09:00 +02:00
parent e95e0a79ff
commit fa49030b06
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
17 changed files with 1254 additions and 54 deletions

45
ENDPOINTS.md Normal file
View file

@ -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.
-

View file

@ -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

View file

@ -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<IActionResult> 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);
}

View file

@ -10,6 +10,7 @@ namespace Foxnouns.Backend.Database;
public class DatabaseContext : DbContext
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILoggerFactory? _loggerFactory;
public DbSet<User> Users { get; set; }
public DbSet<Member> Members { get; set; }
@ -19,7 +20,7 @@ public class DatabaseContext : DbContext
public DbSet<Application> Applications { get; set; }
public DbSet<TemporaryKey> 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<Data
// Get the configuration as our config class
.Get<Config>() ?? new();
return new DatabaseContext(config);
return new DatabaseContext(config, null);
}
}

View file

@ -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<User> 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);

View file

@ -0,0 +1,515 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<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<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,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
public partial class AddUserLastActive : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Instant>(
name: "last_active",
table: "users",
type: "timestamp with time zone",
nullable: false,
defaultValueSql: "now()");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "last_active",
table: "users");
}
}
}

View file

@ -0,0 +1,528 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<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<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,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
public partial class AddDeleted : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "deleted",
table: "users",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<Instant>(
name: "deleted_at",
table: "users",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "deleted_by",
table: "users",
type: "bigint",
nullable: true);
}
/// <inheritdoc />
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");
}
}
}

View file

@ -267,10 +267,26 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text")
.HasColumnName("bio");
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[]")
@ -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<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
{
b1.Property<long>("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<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
{
b1.Property<long>("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<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
{
b1.Property<long>("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<long>("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<long>("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<long>("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");

View file

@ -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<Member> Members { get; } = [];
public List<AuthMethod> 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

View file

@ -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;
}

View file

@ -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<User> _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);

View file

@ -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<Member> 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<MemberRendererService.PartialMember>? Members,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
IEnumerable<AuthenticationMethodResponse>? AuthMethods
IEnumerable<AuthenticationMethodResponse>? AuthMethods,
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
Instant? LastActive
);
public record AuthenticationMethodResponse(

View file

@ -75,7 +75,6 @@ public static class AuthUtils
}
catch (Exception e)
{
Console.WriteLine($"Error converting string: {e}");
bytes = [];
return false;
}

View file

@ -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.

View file

@ -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.