add a bunch of stuff copied from Foxchat.NET

This commit is contained in:
sam 2024-05-28 15:29:18 +02:00
parent f4c0a40259
commit 6114f384a0
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
21 changed files with 1216 additions and 35 deletions

1
.gitignore vendored
View file

@ -1,2 +1,3 @@
bin/ bin/
obj/ obj/
.version

View 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";
}
}

View 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();
}

View 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);
}

View file

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

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

View 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
}
}
}

View file

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

View file

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

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

View file

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

View file

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

View 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);
}

View file

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

View file

@ -1,29 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<PropertyGroup> <ItemGroup>
<TargetFramework>net8.0</TargetFramework> <PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
<Nullable>enable</Nullable> <PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.5"/>
<ImplicitUsings>enable</ImplicitUsings> <PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4"/>
</PropertyGroup> <PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.5"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.5">
<ItemGroup> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3" /> <PrivateAssets>all</PrivateAssets>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.5" /> </PackageReference>
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4" /> <PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.5" /> <PackageReference Include="NodaTime" Version="3.1.11"/>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.5"> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/>
<PrivateAssets>all</PrivateAssets> <PackageReference Include="Serilog" Version="3.1.1"/>
</PackageReference> <PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" /> <PackageReference Include="Serilog.Sinks.Console" Version="5.0.1"/>
<PackageReference Include="NodaTime" Version="3.1.11" /> <PackageReference Include="Serilog.Sinks.Seq" Version="7.0.1"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4" /> </ItemGroup>
<PackageReference Include="Serilog" Version="3.1.1" />
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1" />
<PackageReference Include="Serilog.Sinks.Seq" Version="7.0.1" />
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0" />
</ItemGroup>
<Target Name="SetSourceRevisionId" BeforeTargets="InitializeSourceControlInformation">
<Exec Command="../build_info.sh" IgnoreExitCode="false">
</Exec>
</Target>
</Project> </Project>

View 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;

View 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();
}

View 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,
}

View file

@ -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();
@ -37,4 +49,4 @@ app.Urls.Add(config.Address);
app.Run(); app.Run();
Log.CloseAndFlush(); Log.CloseAndFlush();

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