add a bunch of stuff copied from Foxchat.NET
This commit is contained in:
parent
f4c0a40259
commit
6114f384a0
21 changed files with 1216 additions and 35 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,2 +1,3 @@
|
||||||
bin/
|
bin/
|
||||||
obj/
|
obj/
|
||||||
|
.version
|
||||||
|
|
26
Foxnouns.Backend/BuildInfo.cs
Normal file
26
Foxnouns.Backend/BuildInfo.cs
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
namespace Foxnouns.Backend;
|
||||||
|
|
||||||
|
public static class BuildInfo
|
||||||
|
{
|
||||||
|
public static string Hash { get; private set; } = "(unknown)";
|
||||||
|
public static string Version { get; private set; } = "(unknown)";
|
||||||
|
|
||||||
|
public static async Task ReadBuildInfo()
|
||||||
|
{
|
||||||
|
await using var stream = typeof(BuildInfo).Assembly.GetManifestResourceStream("version");
|
||||||
|
if (stream == null) return;
|
||||||
|
|
||||||
|
using var reader = new StreamReader(stream);
|
||||||
|
var data = (await reader.ReadToEndAsync()).Trim().Split("\n");
|
||||||
|
if (data.Length < 3) return;
|
||||||
|
|
||||||
|
Hash = data[0];
|
||||||
|
var dirty = data[2] == "dirty";
|
||||||
|
|
||||||
|
var versionData = data[1].Split("-");
|
||||||
|
if (versionData.Length < 3) return;
|
||||||
|
Version = versionData[0];
|
||||||
|
if (versionData[1] != "0" || dirty) Version += $"+{versionData[2]}";
|
||||||
|
if (dirty) Version += ".dirty";
|
||||||
|
}
|
||||||
|
}
|
13
Foxnouns.Backend/Controllers/ApiControllerBase.cs
Normal file
13
Foxnouns.Backend/Controllers/ApiControllerBase.cs
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Controllers;
|
||||||
|
|
||||||
|
[ApiController]
|
||||||
|
[Authenticate]
|
||||||
|
public class ApiControllerBase : ControllerBase
|
||||||
|
{
|
||||||
|
internal Token? Token => HttpContext.GetToken();
|
||||||
|
internal new User? User => HttpContext.GetUser();
|
||||||
|
}
|
32
Foxnouns.Backend/Controllers/UsersController.cs
Normal file
32
Foxnouns.Backend/Controllers/UsersController.cs
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Controllers;
|
||||||
|
|
||||||
|
[Route("/api/v2/users")]
|
||||||
|
public class UsersController(DatabaseContext db) : ApiControllerBase
|
||||||
|
{
|
||||||
|
[HttpGet("{userRef}")]
|
||||||
|
public async Task<IActionResult> GetUser(string userRef)
|
||||||
|
{
|
||||||
|
var user = await db.ResolveUserAsync(userRef);
|
||||||
|
return Ok(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("@me")]
|
||||||
|
[Authorize("identify")]
|
||||||
|
public Task<IActionResult> GetMe()
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPatch("@me")]
|
||||||
|
public Task<IActionResult> UpdateUser([FromBody] UpdateUserRequest req)
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record UpdateUserRequest(string? Username, string? DisplayName);
|
||||||
|
}
|
|
@ -15,6 +15,7 @@ public class DatabaseContext : DbContext
|
||||||
public DbSet<AuthMethod> AuthMethods { get; set; }
|
public DbSet<AuthMethod> AuthMethods { get; set; }
|
||||||
public DbSet<FediverseApplication> FediverseApplications { get; set; }
|
public DbSet<FediverseApplication> FediverseApplications { get; set; }
|
||||||
public DbSet<Token> Tokens { get; set; }
|
public DbSet<Token> Tokens { get; set; }
|
||||||
|
public DbSet<Application> Applications { get; set; }
|
||||||
|
|
||||||
public DatabaseContext(Config config)
|
public DatabaseContext(Config config)
|
||||||
{
|
{
|
||||||
|
|
47
Foxnouns.Backend/Database/DatabaseQueryExtensions.cs
Normal file
47
Foxnouns.Backend/Database/DatabaseQueryExtensions.cs
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Database;
|
||||||
|
|
||||||
|
public static class DatabaseQueryExtensions
|
||||||
|
{
|
||||||
|
public static async Task<User> ResolveUserAsync(this DatabaseContext context, string userRef)
|
||||||
|
{
|
||||||
|
User? user;
|
||||||
|
if (Snowflake.TryParse(userRef, out var snowflake))
|
||||||
|
{
|
||||||
|
user = await context.Users
|
||||||
|
.Include(u => u.Members)
|
||||||
|
.FirstOrDefaultAsync(u => u.Id == snowflake);
|
||||||
|
if (user != null) return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
user = await context.Users
|
||||||
|
.Include(u => u.Members)
|
||||||
|
.FirstOrDefaultAsync(u => u.Username == userRef);
|
||||||
|
if (user != null) return user;
|
||||||
|
throw new FoxnounsError.UnknownEntityError(typeof(User));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Application> GetFrontendApplicationAsync(this DatabaseContext context)
|
||||||
|
{
|
||||||
|
var app = await context.Applications.FirstOrDefaultAsync(a => a.Id == new Snowflake(0));
|
||||||
|
if (app != null) return app;
|
||||||
|
|
||||||
|
app = new Application
|
||||||
|
{
|
||||||
|
Id = new Snowflake(0),
|
||||||
|
ClientId = RandomNumberGenerator.GetHexString(32, true),
|
||||||
|
ClientSecret = OauthUtils.RandomToken(48),
|
||||||
|
Name = "pronouns.cc",
|
||||||
|
Scopes = ["*"],
|
||||||
|
RedirectUris = [],
|
||||||
|
};
|
||||||
|
|
||||||
|
context.Add(app);
|
||||||
|
await context.SaveChangesAsync();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
}
|
470
Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.Designer.cs
generated
Normal file
470
Foxnouns.Backend/Database/Migrations/20240528125310_AddApplications.Designer.cs
generated
Normal file
|
@ -0,0 +1,470 @@
|
||||||
|
// <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("20240528125310_AddApplications")]
|
||||||
|
partial class AddApplications
|
||||||
|
{
|
||||||
|
/// <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.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<string[]>("Links")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text[]")
|
||||||
|
.HasColumnName("links");
|
||||||
|
|
||||||
|
b.Property<string>("MemberTitle")
|
||||||
|
.HasColumnType("text")
|
||||||
|
.HasColumnName("member_title");
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,80 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddApplications : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<long>(
|
||||||
|
name: "application_id",
|
||||||
|
table: "tokens",
|
||||||
|
type: "bigint",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0L);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<byte[]>(
|
||||||
|
name: "hash",
|
||||||
|
table: "tokens",
|
||||||
|
type: "bytea",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: new byte[0]);
|
||||||
|
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "applications",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
id = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
client_id = table.Column<string>(type: "text", nullable: false),
|
||||||
|
client_secret = table.Column<string>(type: "text", nullable: false),
|
||||||
|
name = table.Column<string>(type: "text", nullable: false),
|
||||||
|
scopes = table.Column<string[]>(type: "text[]", nullable: false),
|
||||||
|
redirect_uris = table.Column<string[]>(type: "text[]", nullable: false)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("pk_applications", x => x.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "ix_tokens_application_id",
|
||||||
|
table: "tokens",
|
||||||
|
column: "application_id");
|
||||||
|
|
||||||
|
migrationBuilder.AddForeignKey(
|
||||||
|
name: "fk_tokens_applications_application_id",
|
||||||
|
table: "tokens",
|
||||||
|
column: "application_id",
|
||||||
|
principalTable: "applications",
|
||||||
|
principalColumn: "id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropForeignKey(
|
||||||
|
name: "fk_tokens_applications_application_id",
|
||||||
|
table: "tokens");
|
||||||
|
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "applications");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "ix_tokens_application_id",
|
||||||
|
table: "tokens");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "application_id",
|
||||||
|
table: "tokens");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "hash",
|
||||||
|
table: "tokens");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -22,6 +22,43 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
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 =>
|
modelBuilder.Entity("Foxnouns.Backend.Database.Models.AuthMethod", b =>
|
||||||
{
|
{
|
||||||
b.Property<long>("Id")
|
b.Property<long>("Id")
|
||||||
|
@ -144,10 +181,19 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasColumnType("bigint")
|
.HasColumnType("bigint")
|
||||||
.HasColumnName("id");
|
.HasColumnName("id");
|
||||||
|
|
||||||
|
b.Property<long>("ApplicationId")
|
||||||
|
.HasColumnType("bigint")
|
||||||
|
.HasColumnName("application_id");
|
||||||
|
|
||||||
b.Property<Instant>("ExpiresAt")
|
b.Property<Instant>("ExpiresAt")
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("expires_at");
|
.HasColumnName("expires_at");
|
||||||
|
|
||||||
|
b.Property<byte[]>("Hash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("bytea")
|
||||||
|
.HasColumnName("hash");
|
||||||
|
|
||||||
b.Property<bool>("ManuallyExpired")
|
b.Property<bool>("ManuallyExpired")
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasColumnName("manually_expired");
|
.HasColumnName("manually_expired");
|
||||||
|
@ -164,6 +210,9 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b.HasKey("Id")
|
b.HasKey("Id")
|
||||||
.HasName("pk_tokens");
|
.HasName("pk_tokens");
|
||||||
|
|
||||||
|
b.HasIndex("ApplicationId")
|
||||||
|
.HasDatabaseName("ix_tokens_application_id");
|
||||||
|
|
||||||
b.HasIndex("UserId")
|
b.HasIndex("UserId")
|
||||||
.HasDatabaseName("ix_tokens_user_id");
|
.HasDatabaseName("ix_tokens_user_id");
|
||||||
|
|
||||||
|
@ -315,6 +364,13 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.Token", b =>
|
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")
|
b.HasOne("Foxnouns.Backend.Database.Models.User", "User")
|
||||||
.WithMany()
|
.WithMany()
|
||||||
.HasForeignKey("UserId")
|
.HasForeignKey("UserId")
|
||||||
|
@ -322,6 +378,8 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_tokens_users_user_id");
|
.HasConstraintName("fk_tokens_users_user_id");
|
||||||
|
|
||||||
|
b.Navigation("Application");
|
||||||
|
|
||||||
b.Navigation("User");
|
b.Navigation("User");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
40
Foxnouns.Backend/Database/Models/Application.cs
Normal file
40
Foxnouns.Backend/Database/Models/Application.cs
Normal file
|
@ -0,0 +1,40 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Database.Models;
|
||||||
|
|
||||||
|
public class Application : BaseModel
|
||||||
|
{
|
||||||
|
public required string ClientId { get; init; }
|
||||||
|
public required string ClientSecret { get; init; }
|
||||||
|
public required string Name { get; init; }
|
||||||
|
public required string[] Scopes { get; init; }
|
||||||
|
public required string[] RedirectUris { get; set; }
|
||||||
|
|
||||||
|
public static Application Create(ISnowflakeGenerator snowflakeGenerator, string name, string[] scopes,
|
||||||
|
string[] redirectUrls)
|
||||||
|
{
|
||||||
|
var clientId = RandomNumberGenerator.GetHexString(32, true);
|
||||||
|
var clientSecret = OauthUtils.RandomToken(48);
|
||||||
|
|
||||||
|
if (scopes.Except(OauthUtils.ApplicationScopes).Any())
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Invalid scopes passed to Application.Create", nameof(scopes));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (redirectUrls.Any(s => !OauthUtils.ValidateRedirectUri(s)))
|
||||||
|
{
|
||||||
|
throw new ArgumentException("Invalid redirect URLs passed to Application.Create", nameof(redirectUrls));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Application
|
||||||
|
{
|
||||||
|
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||||
|
ClientId = clientId,
|
||||||
|
ClientSecret = clientSecret,
|
||||||
|
Name = name,
|
||||||
|
Scopes = scopes,
|
||||||
|
RedirectUris = redirectUrls
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,12 +4,14 @@ namespace Foxnouns.Backend.Database.Models;
|
||||||
|
|
||||||
public class Token : BaseModel
|
public class Token : BaseModel
|
||||||
{
|
{
|
||||||
|
public required byte[] Hash { get; init; }
|
||||||
public required Instant ExpiresAt { get; init; }
|
public required Instant ExpiresAt { get; init; }
|
||||||
public required string[] Scopes { get; init; }
|
public required string[] Scopes { get; init; }
|
||||||
public bool ManuallyExpired { get; set; }
|
public bool ManuallyExpired { get; set; }
|
||||||
|
|
||||||
public bool IsExpired => ManuallyExpired || ExpiresAt < SystemClock.Instance.GetCurrentInstant();
|
|
||||||
|
|
||||||
public Snowflake UserId { get; init; }
|
public Snowflake UserId { get; init; }
|
||||||
public User User { get; init; } = null!;
|
public User User { get; init; } = null!;
|
||||||
|
|
||||||
|
public Snowflake ApplicationId { get; set; }
|
||||||
|
public Application Application { get; set; } = null!;
|
||||||
}
|
}
|
|
@ -1,3 +1,4 @@
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
|
@ -43,6 +44,14 @@ public readonly struct Snowflake(ulong value)
|
||||||
public static implicit operator Snowflake(ulong n) => new(n);
|
public static implicit operator Snowflake(ulong n) => new(n);
|
||||||
public static implicit operator Snowflake(long n) => new((ulong)n);
|
public static implicit operator Snowflake(long n) => new((ulong)n);
|
||||||
|
|
||||||
|
public static bool TryParse(string input, [NotNullWhen(true)] out Snowflake? snowflake)
|
||||||
|
{
|
||||||
|
snowflake = null;
|
||||||
|
if (!ulong.TryParse(input, out var res)) return false;
|
||||||
|
snowflake = new Snowflake(res);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public override bool Equals(object? obj) => obj is Snowflake other && Value == other.Value;
|
public override bool Equals(object? obj) => obj is Snowflake other && Value == other.Value;
|
||||||
public override int GetHashCode() => Value.GetHashCode();
|
public override int GetHashCode() => Value.GetHashCode();
|
||||||
|
|
||||||
|
|
67
Foxnouns.Backend/ExpectedError.cs
Normal file
67
Foxnouns.Backend/ExpectedError.cs
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
using System.Net;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
|
using Microsoft.AspNetCore.Mvc.ModelBinding;
|
||||||
|
using Newtonsoft.Json.Linq;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend;
|
||||||
|
|
||||||
|
public class FoxnounsError(string message, Exception? inner = null) : Exception(message)
|
||||||
|
{
|
||||||
|
public Exception? Inner => inner;
|
||||||
|
|
||||||
|
public class DatabaseError(string message, Exception? inner = null) : FoxnounsError(message, inner);
|
||||||
|
|
||||||
|
public class UnknownEntityError(Type entityType, Exception? inner = null)
|
||||||
|
: DatabaseError($"Entity of type {entityType.Name} not found", inner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ApiError(string message, HttpStatusCode? statusCode = null) : FoxnounsError(message)
|
||||||
|
{
|
||||||
|
public readonly HttpStatusCode StatusCode = statusCode ?? HttpStatusCode.InternalServerError;
|
||||||
|
|
||||||
|
public class Unauthorized(string message) : ApiError(message, statusCode: HttpStatusCode.Unauthorized);
|
||||||
|
|
||||||
|
public class Forbidden(string message, IEnumerable<string>? scopes = null)
|
||||||
|
: ApiError(message, statusCode: HttpStatusCode.Forbidden)
|
||||||
|
{
|
||||||
|
public readonly string[] Scopes = scopes?.ToArray() ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public class BadRequest(string message, ModelStateDictionary? modelState = null)
|
||||||
|
: ApiError(message, statusCode: HttpStatusCode.BadRequest)
|
||||||
|
{
|
||||||
|
public readonly ModelStateDictionary? Errors = modelState;
|
||||||
|
|
||||||
|
public JObject ToJson()
|
||||||
|
{
|
||||||
|
var o = new JObject
|
||||||
|
{
|
||||||
|
{ "status", (int)HttpStatusCode.BadRequest },
|
||||||
|
{ "code", ErrorCode.BadRequest.ToString() }
|
||||||
|
};
|
||||||
|
if (Errors == null) return o;
|
||||||
|
|
||||||
|
var a = new JArray();
|
||||||
|
foreach (var error in Errors.Where(e => e.Value is { Errors.Count: > 0 }))
|
||||||
|
{
|
||||||
|
var errorObj = new JObject
|
||||||
|
{
|
||||||
|
{ "key", error.Key },
|
||||||
|
{
|
||||||
|
"errors",
|
||||||
|
new JArray(error.Value!.Errors.Select(e => new JObject { { "message", e.ErrorMessage } }))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
a.Add(errorObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
o.Add("errors", a);
|
||||||
|
return o;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class NotFound(string message) : ApiError(message, statusCode: HttpStatusCode.NotFound);
|
||||||
|
|
||||||
|
public class AuthenticationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest);
|
||||||
|
}
|
|
@ -1,10 +1,13 @@
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Serilog.Events;
|
using Serilog.Events;
|
||||||
using Serilog.Sinks.SystemConsole.Themes;
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Extensions;
|
namespace Foxnouns.Backend.Extensions;
|
||||||
|
|
||||||
public static class ServiceCollectionExtensions
|
public static class WebApplicationExtensions
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Adds Serilog to this service collection. This method also initializes Serilog, so it should be called as early as possible, before any log calls.
|
/// Adds Serilog to this service collection. This method also initializes Serilog, so it should be called as early as possible, before any log calls.
|
||||||
|
@ -22,7 +25,7 @@ public static class ServiceCollectionExtensions
|
||||||
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
|
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
|
||||||
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
|
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
|
||||||
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
|
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
|
||||||
.WriteTo.Console(theme: AnsiConsoleTheme.Code);
|
.WriteTo.Console();
|
||||||
|
|
||||||
if (config.SeqLogUrl != null)
|
if (config.SeqLogUrl != null)
|
||||||
{
|
{
|
||||||
|
@ -39,7 +42,6 @@ public static class ServiceCollectionExtensions
|
||||||
|
|
||||||
public static Config AddConfiguration(this WebApplicationBuilder builder)
|
public static Config AddConfiguration(this WebApplicationBuilder builder)
|
||||||
{
|
{
|
||||||
|
|
||||||
builder.Configuration.Sources.Clear();
|
builder.Configuration.Sources.Clear();
|
||||||
builder.Configuration.AddConfiguration();
|
builder.Configuration.AddConfiguration();
|
||||||
|
|
||||||
|
@ -57,4 +59,56 @@ public static class ServiceCollectionExtensions
|
||||||
.AddIniFile(file, optional: false, reloadOnChange: true)
|
.AddIniFile(file, optional: false, reloadOnChange: true)
|
||||||
.AddEnvironmentVariables();
|
.AddEnvironmentVariables();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static IServiceCollection AddCustomServices(this IServiceCollection services) => services
|
||||||
|
.AddSingleton<IClock>(SystemClock.Instance)
|
||||||
|
.AddSnowflakeGenerator();
|
||||||
|
|
||||||
|
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services
|
||||||
|
.AddScoped<ErrorHandlerMiddleware>()
|
||||||
|
.AddScoped<AuthenticationMiddleware>()
|
||||||
|
.AddScoped<AuthorizationMiddleware>();
|
||||||
|
|
||||||
|
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) => app
|
||||||
|
.UseMiddleware<ErrorHandlerMiddleware>()
|
||||||
|
.UseMiddleware<AuthenticationMiddleware>()
|
||||||
|
.UseMiddleware<AuthorizationMiddleware>();
|
||||||
|
|
||||||
|
public static async Task Initialize(this WebApplication app, string[] args)
|
||||||
|
{
|
||||||
|
await BuildInfo.ReadBuildInfo();
|
||||||
|
|
||||||
|
await using var scope = app.Services.CreateAsyncScope();
|
||||||
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger>().ForContext<WebApplication>();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||||
|
|
||||||
|
logger.Information("Starting Foxnouns.NET {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash);
|
||||||
|
|
||||||
|
var pendingMigrations = (await db.Database.GetPendingMigrationsAsync()).ToList();
|
||||||
|
if (args.Contains("--migrate") || args.Contains("--migrate-and-start"))
|
||||||
|
{
|
||||||
|
if (pendingMigrations.Count == 0)
|
||||||
|
{
|
||||||
|
logger.Information("Migrations requested but no migrations are required");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.Information("Migrating database to the latest version");
|
||||||
|
await db.Database.MigrateAsync();
|
||||||
|
logger.Information("Successfully migrated database");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!args.Contains("--migrate-and-start")) Environment.Exit(0);
|
||||||
|
}
|
||||||
|
else if (pendingMigrations.Count > 0)
|
||||||
|
{
|
||||||
|
logger.Fatal(
|
||||||
|
"There are {Count} pending migrations, run server with --migrate or --migrate-and-start to run migrations.",
|
||||||
|
pendingMigrations.Count);
|
||||||
|
Environment.Exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.Information("Initializing frontend OAuth application");
|
||||||
|
_ = await db.GetFrontendApplicationAsync();
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,5 +1,4 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFramework>net8.0</TargetFramework>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
@ -26,4 +25,8 @@
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">
|
||||||
|
<Exec Command="../build_info.sh" IgnoreExitCode="false">
|
||||||
|
</Exec>
|
||||||
|
</Target>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
66
Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs
Normal file
66
Foxnouns.Backend/Middleware/AuthenticationMiddleware.cs
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using NodaTime;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Middleware;
|
||||||
|
|
||||||
|
public class AuthenticationMiddleware(DatabaseContext db, IClock clock) : IMiddleware
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||||
|
{
|
||||||
|
var endpoint = ctx.GetEndpoint();
|
||||||
|
var metadata = endpoint?.Metadata.GetMetadata<AuthenticateAttribute>();
|
||||||
|
|
||||||
|
if (metadata == null)
|
||||||
|
{
|
||||||
|
await next(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var header = ctx.Request.Headers.Authorization.ToString();
|
||||||
|
if (!OauthUtils.TryFromBase64String(header, out var rawToken))
|
||||||
|
{
|
||||||
|
await next(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var hash = SHA512.HashData(rawToken);
|
||||||
|
var oauthToken = await db.Tokens
|
||||||
|
.Include(t => t.Application)
|
||||||
|
.Include(t => t.User)
|
||||||
|
.FirstOrDefaultAsync(t => t.Hash == hash && t.ExpiresAt > clock.GetCurrentInstant() && !t.ManuallyExpired);
|
||||||
|
if (oauthToken == null)
|
||||||
|
{
|
||||||
|
await next(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.SetToken(oauthToken);
|
||||||
|
|
||||||
|
await next(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class HttpContextExtensions
|
||||||
|
{
|
||||||
|
private const string Key = "token";
|
||||||
|
|
||||||
|
public static void SetToken(this HttpContext ctx, Token token) => ctx.Items.Add(Key, token);
|
||||||
|
public static User? GetUser(this HttpContext ctx) => ctx.GetToken()?.User;
|
||||||
|
|
||||||
|
public static User GetUserOrThrow(this HttpContext ctx) =>
|
||||||
|
ctx.GetUser() ?? throw new ApiError.AuthenticationError("No user in HttpContext");
|
||||||
|
|
||||||
|
public static Token? GetToken(this HttpContext ctx)
|
||||||
|
{
|
||||||
|
if (ctx.Items.TryGetValue(Key, out var token))
|
||||||
|
return token as Token;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||||
|
public class AuthenticateAttribute : Attribute;
|
36
Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs
Normal file
36
Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
using Foxnouns.Backend.Utils;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Middleware;
|
||||||
|
|
||||||
|
public class AuthorizationMiddleware : IMiddleware
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||||
|
{
|
||||||
|
var endpoint = ctx.GetEndpoint();
|
||||||
|
var attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
|
||||||
|
|
||||||
|
if (attribute == null)
|
||||||
|
{
|
||||||
|
await next(ctx);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = ctx.GetToken();
|
||||||
|
if (token == null)
|
||||||
|
throw new ApiError.Unauthorized("This endpoint requires an authenticated user.");
|
||||||
|
if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any())
|
||||||
|
throw new ApiError.Forbidden("This endpoint requires ungranted scopes.",
|
||||||
|
attribute.Scopes.Except(token.Scopes.ExpandScopes()));
|
||||||
|
|
||||||
|
await next(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||||
|
public class AuthorizeAttribute(params string[] scopes) : Attribute
|
||||||
|
{
|
||||||
|
public readonly bool RequireAdmin = scopes.Contains(":admin");
|
||||||
|
public readonly bool RequireModerator = scopes.Contains(":moderator");
|
||||||
|
|
||||||
|
public readonly string[] Scopes = scopes.Except([":admin", ":moderator"]).ToArray();
|
||||||
|
}
|
93
Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs
Normal file
93
Foxnouns.Backend/Middleware/ErrorHandlerMiddleware.cs
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
using System.Net;
|
||||||
|
using Newtonsoft.Json;
|
||||||
|
using Newtonsoft.Json.Converters;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Middleware;
|
||||||
|
|
||||||
|
public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware
|
||||||
|
{
|
||||||
|
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await next(ctx);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
var type = e.TargetSite?.DeclaringType ?? typeof(ErrorHandlerMiddleware);
|
||||||
|
var typeName = e.TargetSite?.DeclaringType?.FullName ?? "<unknown>";
|
||||||
|
var logger = baseLogger.ForContext(type);
|
||||||
|
|
||||||
|
if (ctx.Response.HasStarted)
|
||||||
|
{
|
||||||
|
logger.Error(e, "Error in {ClassName} ({Path}) after response started being sent", typeName,
|
||||||
|
ctx.Request.Path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e is ApiError ae)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = (int)ae.StatusCode;
|
||||||
|
ctx.Response.Headers.RequestId = ctx.TraceIdentifier;
|
||||||
|
ctx.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
if (ae is ApiError.Forbidden fe)
|
||||||
|
{
|
||||||
|
await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError
|
||||||
|
{
|
||||||
|
Status = (int)fe.StatusCode,
|
||||||
|
Code = ErrorCode.Forbidden,
|
||||||
|
Message = fe.Message,
|
||||||
|
Scopes = fe.Scopes.Length > 0 ? fe.Scopes : null
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError
|
||||||
|
{
|
||||||
|
Status = (int)ae.StatusCode,
|
||||||
|
Code = ErrorCode.GenericApiError,
|
||||||
|
Message = ae.Message,
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e is FoxnounsError fce)
|
||||||
|
{
|
||||||
|
logger.Error(fce.Inner ?? fce, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
logger.Error(e, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path);
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||||
|
ctx.Response.Headers.RequestId = ctx.TraceIdentifier;
|
||||||
|
ctx.Response.ContentType = "application/json; charset=utf-8";
|
||||||
|
await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError
|
||||||
|
{
|
||||||
|
Status = (int)HttpStatusCode.InternalServerError,
|
||||||
|
Code = ErrorCode.InternalServerError,
|
||||||
|
Message = "Internal server error",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record HttpApiError
|
||||||
|
{
|
||||||
|
public required int Status { get; init; }
|
||||||
|
[JsonConverter(typeof(StringEnumConverter))]
|
||||||
|
public required ErrorCode Code { get; init; }
|
||||||
|
public required string Message { get; init; }
|
||||||
|
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||||
|
public string[]? Scopes { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ErrorCode
|
||||||
|
{
|
||||||
|
InternalServerError,
|
||||||
|
Forbidden,
|
||||||
|
BadRequest,
|
||||||
|
AuthenticationError,
|
||||||
|
GenericApiError,
|
||||||
|
}
|
|
@ -1,6 +1,8 @@
|
||||||
|
using Foxnouns.Backend;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Newtonsoft.Json.Serialization;
|
using Newtonsoft.Json.Serialization;
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
@ -15,21 +17,31 @@ builder.Services
|
||||||
options.SerializerSettings.ContractResolver = new DefaultContractResolver
|
options.SerializerSettings.ContractResolver = new DefaultContractResolver
|
||||||
{
|
{
|
||||||
NamingStrategy = new SnakeCaseNamingStrategy()
|
NamingStrategy = new SnakeCaseNamingStrategy()
|
||||||
|
})
|
||||||
|
.ConfigureApiBehaviorOptions(options =>
|
||||||
|
{
|
||||||
|
options.InvalidModelStateResponseFactory = actionContext => new BadRequestObjectResult(
|
||||||
|
new ApiError.BadRequest("Bad request", actionContext.ModelState).ToJson()
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddDbContext<DatabaseContext>()
|
.AddDbContext<DatabaseContext>()
|
||||||
.AddSnowflakeGenerator()
|
.AddCustomServices()
|
||||||
|
.AddCustomMiddleware()
|
||||||
.AddEndpointsApiExplorer()
|
.AddEndpointsApiExplorer()
|
||||||
.AddSwaggerGen();
|
.AddSwaggerGen();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
await app.Initialize(args);
|
||||||
|
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
app.UseRouting();
|
app.UseRouting();
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
|
app.UseCustomMiddleware();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
app.Urls.Clear();
|
app.Urls.Clear();
|
||||||
|
|
67
Foxnouns.Backend/Utils/OauthUtils.cs
Normal file
67
Foxnouns.Backend/Utils/OauthUtils.cs
Normal file
|
@ -0,0 +1,67 @@
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
|
||||||
|
namespace Foxnouns.Backend.Utils;
|
||||||
|
|
||||||
|
public static class OauthUtils
|
||||||
|
{
|
||||||
|
public const string ClientCredentials = "client_credentials";
|
||||||
|
public const string AuthorizationCode = "authorization_code";
|
||||||
|
private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"];
|
||||||
|
|
||||||
|
public static readonly string[] UserScopes =
|
||||||
|
["user.read_hidden", "user.read_privileged", "user.update"];
|
||||||
|
|
||||||
|
public static readonly string[] MemberScopes = ["member.read", "member.update", "member.create"];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All scopes endpoints can be secured by. This does *not* include the catch-all token scopes.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly string[] Scopes = ["identify", ..UserScopes, ..MemberScopes];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// All scopes that can be granted to applications and tokens. Includes the catch-all token scopes,
|
||||||
|
/// except for "*" which is only granted to the frontend.
|
||||||
|
/// </summary>
|
||||||
|
public static readonly string[] ApplicationScopes = [..Scopes, "user", "member"];
|
||||||
|
|
||||||
|
public static string[] ExpandScopes(this string[] scopes)
|
||||||
|
{
|
||||||
|
if (scopes.Contains("*")) return Scopes;
|
||||||
|
List<string> expandedScopes = ["identify"];
|
||||||
|
if (scopes.Contains("user")) expandedScopes.AddRange(UserScopes);
|
||||||
|
if (scopes.Contains("member")) expandedScopes.AddRange(MemberScopes);
|
||||||
|
|
||||||
|
return expandedScopes.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool ValidateRedirectUri(string uri)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var scheme = new Uri(uri).Scheme;
|
||||||
|
return !ForbiddenSchemes.Contains(scheme);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public static bool TryFromBase64String(string b64, out byte[] bytes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
bytes = Convert.FromBase64String(b64);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
bytes = [];
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string RandomToken(int bytes = 48) =>
|
||||||
|
Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('=');
|
||||||
|
}
|
4
build_info.sh
Executable file
4
build_info.sh
Executable file
|
@ -0,0 +1,4 @@
|
||||||
|
#!/bin/sh
|
||||||
|
(git rev-parse HEAD &&
|
||||||
|
git describe --tags --always --long &&
|
||||||
|
if test -z "$(git ls-files --exclude-standard --modified --deleted --others)"; then echo clean; else echo dirty; fi) > ../.version
|
Loading…
Reference in a new issue