diff --git a/.editorconfig b/.editorconfig
index 9fec5aa..00c5659 100644
--- a/.editorconfig
+++ b/.editorconfig
@@ -2,3 +2,6 @@
# CS9113: Parameter is unread.
dotnet_diagnostic.CS9113.severity = silent
+
+# EntityFramework.ModelValidation.UnlimitedStringLength
+resharper_entity_framework_model_validation_unlimited_string_length_highlighting=none
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index cd1b080..d01c4a3 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,47 @@
bin/
obj/
.version
+
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+.idea/**/discord.xml
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# CMake
+cmake-build-*/
+
+# File-based project format
+*.iws
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Visual Studio Code
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+!.vscode/*.code-snippets
diff --git a/.idea/.idea.Foxchat/.idea/.gitignore b/.idea/.idea.Foxchat/.idea/.gitignore
new file mode 100644
index 0000000..cb00b47
--- /dev/null
+++ b/.idea/.idea.Foxchat/.idea/.gitignore
@@ -0,0 +1,13 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Rider ignored files
+/.idea.Foxchat.iml
+/contentModel.xml
+/modules.xml
+/projectSettingsUpdater.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/.idea.Foxchat/.idea/.name b/.idea/.idea.Foxchat/.idea/.name
new file mode 100644
index 0000000..4dfb495
--- /dev/null
+++ b/.idea/.idea.Foxchat/.idea/.name
@@ -0,0 +1 @@
+Foxchat
\ No newline at end of file
diff --git a/.idea/.idea.Foxchat/.idea/encodings.xml b/.idea/.idea.Foxchat/.idea/encodings.xml
new file mode 100644
index 0000000..df87cf9
--- /dev/null
+++ b/.idea/.idea.Foxchat/.idea/encodings.xml
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.Foxchat/.idea/indexLayout.xml b/.idea/.idea.Foxchat/.idea/indexLayout.xml
new file mode 100644
index 0000000..7b08163
--- /dev/null
+++ b/.idea/.idea.Foxchat/.idea/indexLayout.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.Foxchat/.idea/sqldialects.xml b/.idea/.idea.Foxchat/.idea/sqldialects.xml
new file mode 100644
index 0000000..387b18e
--- /dev/null
+++ b/.idea/.idea.Foxchat/.idea/sqldialects.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/.idea.Foxchat/.idea/vcs.xml b/.idea/.idea.Foxchat/.idea/vcs.xml
new file mode 100644
index 0000000..35eb1dd
--- /dev/null
+++ b/.idea/.idea.Foxchat/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/Foxchat.Chat/Controllers/Api/GuildsController.cs b/Foxchat.Chat/Controllers/Api/GuildsController.cs
new file mode 100644
index 0000000..bd8d0bb
--- /dev/null
+++ b/Foxchat.Chat/Controllers/Api/GuildsController.cs
@@ -0,0 +1,84 @@
+using Foxchat.Chat.Database;
+using Foxchat.Chat.Database.Models;
+using Foxchat.Chat.Middleware;
+using Foxchat.Chat.Services;
+using Foxchat.Core.Models;
+using Foxchat.Core.Models.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using ApiError = Foxchat.Core.ApiError;
+
+namespace Foxchat.Chat.Controllers.Api;
+
+[ApiController]
+[Route("/_fox/chat/guilds")]
+public class GuildsController(ILogger logger, ChatContext db, UserResolverService userResolverService) : ControllerBase
+{
+ [HttpPost]
+ public async Task CreateGuild([FromBody] GuildsApi.CreateGuildRequest req)
+ {
+ var (instance, _, userId) = HttpContext.GetSignatureWithUser();
+
+ var user = await userResolverService.ResolveUserAsync(instance, userId);
+
+ var guild = new Guild
+ {
+ Name = req.Name,
+ Owner = user,
+ };
+ db.Add(guild);
+ guild.Users.Add(user);
+ var defaultChannel = new Channel
+ {
+ Guild = guild,
+ Name = "general"
+ };
+ db.Add(defaultChannel);
+
+ await db.SaveChangesAsync();
+
+ return Ok(new Guilds.Guild(
+ guild.Id.ToString(),
+ guild.Name,
+ [user.Id.ToString()],
+ [new Channels.PartialChannel(defaultChannel.Id.ToString(), defaultChannel.Name)])
+ );
+ }
+
+ [HttpGet("{id}")]
+ public async Task GetGuild(Ulid id)
+ {
+ var (instance, _, userId) = HttpContext.GetSignatureWithUser();
+ var guild = await db.Guilds
+ .Include(g => g.Channels)
+ .FirstOrDefaultAsync(g =>
+ g.Id == id && g.Users.Any(u => u.RemoteUserId == userId && u.InstanceId == instance.Id));
+ if (guild == null) throw new ApiError.NotFound("Guild not found");
+
+ return Ok(new Guilds.Guild(
+ guild.Id.ToString(),
+ guild.Name,
+ [guild.OwnerId.ToString()],
+ guild.Channels.Select(c => new Channels.PartialChannel(c.Id.ToString(), c.Name))
+ ));
+ }
+
+ [HttpGet("@me")]
+ public async Task GetUserGuilds()
+ {
+ var (instance, _, userId) = HttpContext.GetSignatureWithUser();
+ var guilds = await db.Guilds
+ .Include(g => g.Channels)
+ .Where(g => g.Users.Any(u => u.RemoteUserId == userId && u.InstanceId == instance.Id))
+ .ToListAsync();
+
+ var guildResponses = guilds.Select(g => new Guilds.Guild(
+ g.Id.ToString(),
+ g.Name,
+ [g.OwnerId.ToString()],
+ g.Channels.Select(c => new Channels.PartialChannel(c.Id.ToString(), c.Name))
+ ));
+
+ return Ok(guildResponses);
+ }
+}
\ No newline at end of file
diff --git a/Foxchat.Chat/Controllers/HelloController.cs b/Foxchat.Chat/Controllers/HelloController.cs
new file mode 100644
index 0000000..0c0c639
--- /dev/null
+++ b/Foxchat.Chat/Controllers/HelloController.cs
@@ -0,0 +1,47 @@
+using Foxchat.Chat.Database;
+using Foxchat.Chat.Database.Models;
+using Foxchat.Chat.Middleware;
+using Foxchat.Core.Extensions;
+using Foxchat.Core.Federation;
+using Foxchat.Core.Models.Http;
+using Microsoft.AspNetCore.Mvc;
+using ApiError = Foxchat.Core.ApiError;
+
+namespace Foxchat.Chat.Controllers;
+
+[ApiController]
+[ServerUnauthenticated]
+[Route("/_fox/chat/hello")]
+public class HelloController(
+ ILogger logger,
+ ChatContext db,
+ InstanceConfig config,
+ RequestSigningService requestSigningService)
+ : ControllerBase
+{
+ [HttpPost]
+ public async Task Hello([FromBody] Hello.HelloRequest req)
+ {
+ var node = await requestSigningService.RequestAsync(HttpMethod.Get, req.Host,
+ "/_fox/ident/node");
+
+ if (!HttpContext.ExtractRequestData(out var signature, out var domain, out var signatureData))
+ throw new ApiError.IncomingFederationError("This endpoint requires signed requests.");
+ if (domain != req.Host)
+ throw new ApiError.IncomingFederationError("Host is invalid.");
+
+ if (!requestSigningService.VerifySignature(node.PublicKey, signature, signatureData))
+ throw new ApiError.IncomingFederationError("Signature is not valid.");
+
+ var instance = await db.GetInstanceAsync();
+ db.IdentityInstances.Add(new IdentityInstance
+ {
+ Domain = req.Host,
+ BaseUrl = $"https://{req.Host}",
+ PublicKey = node.PublicKey
+ });
+ await db.SaveChangesAsync();
+
+ return Ok(new Hello.HelloResponse(instance.PublicKey, config.Domain));
+ }
+}
\ No newline at end of file
diff --git a/Foxchat.Chat/Database/BaseModel.cs b/Foxchat.Chat/Database/BaseModel.cs
new file mode 100644
index 0000000..9d90e4d
--- /dev/null
+++ b/Foxchat.Chat/Database/BaseModel.cs
@@ -0,0 +1,6 @@
+namespace Foxchat.Chat.Database;
+
+public abstract class BaseModel
+{
+ public Ulid Id { get; init; } = Ulid.NewUlid();
+}
diff --git a/Foxchat.Chat/Database/ChatContext.cs b/Foxchat.Chat/Database/ChatContext.cs
index 09e0180..f8ac2a5 100644
--- a/Foxchat.Chat/Database/ChatContext.cs
+++ b/Foxchat.Chat/Database/ChatContext.cs
@@ -1,5 +1,7 @@
+using Foxchat.Chat.Database.Models;
using Foxchat.Core;
using Foxchat.Core.Database;
+using Foxchat.Core.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Npgsql;
@@ -9,10 +11,16 @@ namespace Foxchat.Chat.Database;
public class ChatContext : IDatabaseContext
{
private readonly NpgsqlDataSource _dataSource;
+ private readonly ILoggerFactory? _loggerFactory;
public override DbSet Instance { get; set; }
+ public DbSet IdentityInstances { get; set; }
+ public DbSet Users { get; set; }
+ public DbSet Guilds { get; set; }
+ public DbSet Channels { get; set; }
+ public DbSet Messages { get; set; }
- public ChatContext(InstanceConfig config)
+ public ChatContext(InstanceConfig config, ILoggerFactory? loggerFactory)
{
var connString = new NpgsqlConnectionStringBuilder(config.Database.Url)
{
@@ -23,12 +31,14 @@ public class ChatContext : IDatabaseContext
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString);
dataSourceBuilder.UseNodaTime();
_dataSource = dataSourceBuilder.Build();
+ _loggerFactory = loggerFactory;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
- => optionsBuilder
- .UseNpgsql(_dataSource, o => o.UseNodaTime())
- .UseSnakeCaseNamingConvention();
+ => optionsBuilder
+ .UseNpgsql(_dataSource, o => o.UseNodaTime())
+ .UseSnakeCaseNamingConvention()
+ .UseLoggerFactory(_loggerFactory);
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
@@ -38,20 +48,35 @@ public class ChatContext : IDatabaseContext
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
+ modelBuilder.Entity().HasIndex(i => i.Domain).IsUnique();
+
+ modelBuilder.Entity().HasIndex(u => new { u.RemoteUserId, u.InstanceId }).IsUnique();
+ modelBuilder.Entity().HasIndex(u => new { u.Username, u.InstanceId }).IsUnique();
+
+ modelBuilder.Entity()
+ .HasOne(e => e.Owner)
+ .WithMany(e => e.OwnedGuilds)
+ .HasForeignKey(e => e.OwnerId)
+ .IsRequired();
+
+ modelBuilder.Entity()
+ .HasMany(e => e.Guilds)
+ .WithMany(e => e.Users);
}
}
+// ReSharper disable once UnusedType.Global
public class DesignTimeIdentityContextFactory : IDesignTimeDbContextFactory
{
public ChatContext CreateDbContext(string[] args)
{
// Read the configuration file
var config = new ConfigurationBuilder()
- .AddConfiguration("identity.ini")
+ .AddConfiguration("chat.ini")
.Build()
// Get the configuration as our config class
.Get() ?? new();
- return new ChatContext(config);
+ return new ChatContext(config, null);
}
-}
+}
\ No newline at end of file
diff --git a/Foxchat.Chat/Database/Models/Channel.cs b/Foxchat.Chat/Database/Models/Channel.cs
new file mode 100644
index 0000000..90a2530
--- /dev/null
+++ b/Foxchat.Chat/Database/Models/Channel.cs
@@ -0,0 +1,9 @@
+namespace Foxchat.Chat.Database.Models;
+
+public class Channel : BaseModel
+{
+ public Ulid GuildId { get; init; }
+ public Guild Guild { get; init; } = null!;
+ public string Name { get; set; } = null!;
+ public string? Topic { get; set; }
+}
\ No newline at end of file
diff --git a/Foxchat.Chat/Database/Models/Guild.cs b/Foxchat.Chat/Database/Models/Guild.cs
new file mode 100644
index 0000000..022b48b
--- /dev/null
+++ b/Foxchat.Chat/Database/Models/Guild.cs
@@ -0,0 +1,11 @@
+namespace Foxchat.Chat.Database.Models;
+
+public class Guild : BaseModel
+{
+ public string Name { get; set; } = null!;
+ public Ulid OwnerId { get; set; }
+ public User Owner { get; set; } = null!;
+
+ public List Users { get; } = [];
+ public List Channels { get; } = [];
+}
\ No newline at end of file
diff --git a/Foxchat.Chat/Database/Models/IdentityInstance.cs b/Foxchat.Chat/Database/Models/IdentityInstance.cs
new file mode 100644
index 0000000..ba978c3
--- /dev/null
+++ b/Foxchat.Chat/Database/Models/IdentityInstance.cs
@@ -0,0 +1,17 @@
+namespace Foxchat.Chat.Database.Models;
+
+public class IdentityInstance : BaseModel
+{
+ public string Domain { get; init; } = null!;
+ public string BaseUrl { get; init; } = null!;
+ public string PublicKey { get; init; } = null!;
+
+ public InstanceStatus Status { get; set; } = InstanceStatus.Active;
+ public string? Reason { get; set; }
+
+ public enum InstanceStatus
+ {
+ Active,
+ Suspended,
+ }
+}
diff --git a/Foxchat.Chat/Database/Models/Message.cs b/Foxchat.Chat/Database/Models/Message.cs
new file mode 100644
index 0000000..4fc90e8
--- /dev/null
+++ b/Foxchat.Chat/Database/Models/Message.cs
@@ -0,0 +1,15 @@
+using NodaTime;
+
+namespace Foxchat.Chat.Database.Models;
+
+public class Message : BaseModel
+{
+ public Ulid ChannelId { get; init; }
+ public Channel Channel { get; init; } = null!;
+ public Ulid AuthorId { get; init; }
+ public User Author { get; init; } = null!;
+
+ public string? Content { get; set; }
+
+ public Instant? UpdatedAt { get; set; }
+}
\ No newline at end of file
diff --git a/Foxchat.Chat/Database/Models/User.cs b/Foxchat.Chat/Database/Models/User.cs
new file mode 100644
index 0000000..26f2d5d
--- /dev/null
+++ b/Foxchat.Chat/Database/Models/User.cs
@@ -0,0 +1,18 @@
+using Foxchat.Core.Models;
+using NodaTime;
+
+namespace Foxchat.Chat.Database.Models;
+
+public class User : BaseModel
+{
+ public Ulid InstanceId { get; init; }
+ public IdentityInstance Instance { get; init; } = null!;
+ public string RemoteUserId { get; init; } = null!;
+ public string Username { get; init; } = null!;
+
+ public string? Avatar { get; set; }
+ public Instant LastFetchedAt { get; set; }
+
+ public List Guilds { get; } = [];
+ public List OwnedGuilds { get; } = [];
+}
\ No newline at end of file
diff --git a/Foxchat.Chat/Extensions/WebApplicationExtensions.cs b/Foxchat.Chat/Extensions/WebApplicationExtensions.cs
new file mode 100644
index 0000000..e48d404
--- /dev/null
+++ b/Foxchat.Chat/Extensions/WebApplicationExtensions.cs
@@ -0,0 +1,28 @@
+using Foxchat.Chat.Middleware;
+using Foxchat.Chat.Services;
+using Foxchat.Core.Middleware;
+
+namespace Foxchat.Chat.Extensions;
+
+public static class WebApplicationExtensions
+{
+ public static IServiceCollection AddCustomMiddleware(this IServiceCollection services)
+ {
+ return services
+ .AddScoped()
+ .AddScoped();
+ }
+
+ public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app)
+ {
+ return app
+ .UseMiddleware()
+ .UseMiddleware();
+ }
+
+ public static IServiceCollection AddChatServices(this IServiceCollection services)
+ {
+ return services
+ .AddScoped();
+ }
+}
\ No newline at end of file
diff --git a/Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs b/Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs
new file mode 100644
index 0000000..4db585a
--- /dev/null
+++ b/Foxchat.Chat/Middleware/ServerAuthenticationMiddleware.cs
@@ -0,0 +1,86 @@
+using Foxchat.Chat.Database;
+using Foxchat.Chat.Database.Models;
+using Foxchat.Core;
+using Foxchat.Core.Extensions;
+using Foxchat.Core.Federation;
+using Microsoft.EntityFrameworkCore;
+
+namespace Foxchat.Chat.Middleware;
+
+public class ServerAuthenticationMiddleware(ILogger logger, ChatContext db, RequestSigningService requestSigningService)
+ : IMiddleware
+{
+ public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
+ {
+ var endpoint = ctx.GetEndpoint();
+ // Endpoints require server authentication by default, unless they have the [Unauthenticated] attribute.
+ var metadata = endpoint?.Metadata.GetMetadata();
+ if (metadata != null)
+ {
+ await next(ctx);
+ return;
+ }
+
+ if (!ctx.ExtractRequestData(out var signature, out var domain, out var signatureData))
+ throw new ApiError.IncomingFederationError("This endpoint requires signed requests.");
+
+ var instance = await GetInstanceAsync(domain);
+
+ if (!requestSigningService.VerifySignature(instance.PublicKey, signature, signatureData))
+ throw new ApiError.IncomingFederationError("Signature is not valid.");
+
+ ctx.SetSignature(instance, signatureData);
+
+ await next(ctx);
+ }
+
+ private async Task GetInstanceAsync(string domain)
+ {
+ return await db.IdentityInstances.FirstOrDefaultAsync(i => i.Domain == domain)
+ ?? throw new ApiError.IncomingFederationError("Remote instance is not known.");
+ }
+}
+
+///
+/// Attribute to be put on controllers or methods to indicate that it does not require a signed request.
+///
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+public class ServerUnauthenticatedAttribute : Attribute;
+
+public static class HttpContextExtensions
+{
+ private const string Key = "instance";
+
+ public static void SetSignature(this HttpContext ctx, IdentityInstance instance, SignatureData data)
+ {
+ ctx.Items.Add(Key, (instance, data));
+ }
+
+ public static (IdentityInstance?, SignatureData?) GetSignature(this HttpContext ctx)
+ {
+ try
+ {
+ var obj = ctx.GetSignatureOrThrow();
+ return (obj.Item1, obj.Item2);
+ }
+ catch
+ {
+ return (null, null);
+ }
+ }
+
+ public static (IdentityInstance, SignatureData) GetSignatureOrThrow(this HttpContext ctx)
+ {
+ if (!ctx.Items.TryGetValue(Key, out var obj))
+ throw new ApiError.AuthenticationError("No instance in HttpContext");
+
+ return ((IdentityInstance, SignatureData))obj!;
+ }
+
+ public static (IdentityInstance, SignatureData, string) GetSignatureWithUser(this HttpContext ctx)
+ {
+ var (instance, sig) = ctx.GetSignatureOrThrow();
+ if (sig.UserId == null) throw new ApiError.IncomingFederationError("This endpoint requires a user ID.");
+ return (instance, sig, sig.UserId);
+ }
+}
\ No newline at end of file
diff --git a/Foxchat.Chat/Migrations/20240521132416_Init.Designer.cs b/Foxchat.Chat/Migrations/20240521132416_Init.Designer.cs
new file mode 100644
index 0000000..ff599f3
--- /dev/null
+++ b/Foxchat.Chat/Migrations/20240521132416_Init.Designer.cs
@@ -0,0 +1,325 @@
+//
+using System;
+using Foxchat.Chat.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 Foxchat.Chat.Migrations
+{
+ [DbContext(typeof(ChatContext))]
+ [Migration("20240521132416_Init")]
+ partial class Init
+ {
+ ///
+ 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("Foxchat.Chat.Database.Models.Channel", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("GuildId")
+ .HasColumnType("uuid")
+ .HasColumnName("guild_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Topic")
+ .HasColumnType("text")
+ .HasColumnName("topic");
+
+ b.HasKey("Id")
+ .HasName("pk_channels");
+
+ b.HasIndex("GuildId")
+ .HasDatabaseName("ix_channels_guild_id");
+
+ b.ToTable("channels", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("OwnerId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_id");
+
+ b.HasKey("Id")
+ .HasName("pk_guilds");
+
+ b.HasIndex("OwnerId")
+ .HasDatabaseName("ix_guilds_owner_id");
+
+ b.ToTable("guilds", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.IdentityInstance", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("BaseUrl")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("base_url");
+
+ b.Property("Domain")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("domain");
+
+ b.Property("PublicKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("public_key");
+
+ b.Property("Reason")
+ .HasColumnType("text")
+ .HasColumnName("reason");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_identity_instances");
+
+ b.HasIndex("Domain")
+ .IsUnique()
+ .HasDatabaseName("ix_identity_instances_domain");
+
+ b.ToTable("identity_instances", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AuthorId")
+ .HasColumnType("uuid")
+ .HasColumnName("author_id");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid")
+ .HasColumnName("channel_id");
+
+ b.Property("Content")
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_messages");
+
+ b.HasIndex("AuthorId")
+ .HasDatabaseName("ix_messages_author_id");
+
+ b.HasIndex("ChannelId")
+ .HasDatabaseName("ix_messages_channel_id");
+
+ b.ToTable("messages", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Avatar")
+ .HasColumnType("text")
+ .HasColumnName("avatar");
+
+ b.Property("InstanceId")
+ .HasColumnType("uuid")
+ .HasColumnName("instance_id");
+
+ b.Property("RemoteUserId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("remote_user_id");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.HasIndex("InstanceId")
+ .HasDatabaseName("ix_users_instance_id");
+
+ b.HasIndex("RemoteUserId", "InstanceId")
+ .IsUnique()
+ .HasDatabaseName("ix_users_remote_user_id_instance_id");
+
+ b.HasIndex("Username", "InstanceId")
+ .IsUnique()
+ .HasDatabaseName("ix_users_username_instance_id");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Core.Database.Instance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("PrivateKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("private_key");
+
+ b.Property("PublicKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("public_key");
+
+ b.HasKey("Id")
+ .HasName("pk_instance");
+
+ b.ToTable("instance", (string)null);
+ });
+
+ modelBuilder.Entity("GuildUser", b =>
+ {
+ b.Property("GuildsId")
+ .HasColumnType("uuid")
+ .HasColumnName("guilds_id");
+
+ b.Property("UsersId")
+ .HasColumnType("uuid")
+ .HasColumnName("users_id");
+
+ b.HasKey("GuildsId", "UsersId")
+ .HasName("pk_guild_user");
+
+ b.HasIndex("UsersId")
+ .HasDatabaseName("ix_guild_user_users_id");
+
+ b.ToTable("guild_user", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.Guild", "Guild")
+ .WithMany("Channels")
+ .HasForeignKey("GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_channels_guilds_guild_id");
+
+ b.Navigation("Guild");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.User", "Owner")
+ .WithMany("OwnedGuilds")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_guilds_users_owner_id");
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.User", "Author")
+ .WithMany()
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_messages_users_author_id");
+
+ b.HasOne("Foxchat.Chat.Database.Models.Channel", "Channel")
+ .WithMany()
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_messages_channels_channel_id");
+
+ b.Navigation("Author");
+
+ b.Navigation("Channel");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.IdentityInstance", "Instance")
+ .WithMany()
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_users_identity_instances_instance_id");
+
+ b.Navigation("Instance");
+ });
+
+ modelBuilder.Entity("GuildUser", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.Guild", null)
+ .WithMany()
+ .HasForeignKey("GuildsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_guild_user_guilds_guilds_id");
+
+ b.HasOne("Foxchat.Chat.Database.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UsersId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_guild_user_users_users_id");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b =>
+ {
+ b.Navigation("Channels");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b =>
+ {
+ b.Navigation("OwnedGuilds");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Foxchat.Chat/Migrations/20240521132416_Init.cs b/Foxchat.Chat/Migrations/20240521132416_Init.cs
new file mode 100644
index 0000000..efb5e07
--- /dev/null
+++ b/Foxchat.Chat/Migrations/20240521132416_Init.cs
@@ -0,0 +1,228 @@
+using System;
+using Microsoft.EntityFrameworkCore.Migrations;
+using NodaTime;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Foxchat.Chat.Migrations
+{
+ ///
+ public partial class Init : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "identity_instances",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ domain = table.Column(type: "text", nullable: false),
+ base_url = table.Column(type: "text", nullable: false),
+ public_key = table.Column(type: "text", nullable: false),
+ status = table.Column(type: "integer", nullable: false),
+ reason = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_identity_instances", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "instance",
+ columns: table => new
+ {
+ id = table.Column(type: "integer", nullable: false)
+ .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
+ public_key = table.Column(type: "text", nullable: false),
+ private_key = table.Column(type: "text", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_instance", x => x.id);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "users",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ instance_id = table.Column(type: "uuid", nullable: false),
+ remote_user_id = table.Column(type: "text", nullable: false),
+ username = table.Column(type: "text", nullable: false),
+ avatar = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_users", x => x.id);
+ table.ForeignKey(
+ name: "fk_users_identity_instances_instance_id",
+ column: x => x.instance_id,
+ principalTable: "identity_instances",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "guilds",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ name = table.Column(type: "text", nullable: false),
+ owner_id = table.Column(type: "uuid", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_guilds", x => x.id);
+ table.ForeignKey(
+ name: "fk_guilds_users_owner_id",
+ column: x => x.owner_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "channels",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ guild_id = table.Column(type: "uuid", nullable: false),
+ name = table.Column(type: "text", nullable: false),
+ topic = table.Column(type: "text", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_channels", x => x.id);
+ table.ForeignKey(
+ name: "fk_channels_guilds_guild_id",
+ column: x => x.guild_id,
+ principalTable: "guilds",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "guild_user",
+ columns: table => new
+ {
+ guilds_id = table.Column(type: "uuid", nullable: false),
+ users_id = table.Column(type: "uuid", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_guild_user", x => new { x.guilds_id, x.users_id });
+ table.ForeignKey(
+ name: "fk_guild_user_guilds_guilds_id",
+ column: x => x.guilds_id,
+ principalTable: "guilds",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_guild_user_users_users_id",
+ column: x => x.users_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateTable(
+ name: "messages",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ channel_id = table.Column(type: "uuid", nullable: false),
+ author_id = table.Column(type: "uuid", nullable: false),
+ content = table.Column(type: "text", nullable: true),
+ updated_at = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_messages", x => x.id);
+ table.ForeignKey(
+ name: "fk_messages_channels_channel_id",
+ column: x => x.channel_id,
+ principalTable: "channels",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ table.ForeignKey(
+ name: "fk_messages_users_author_id",
+ column: x => x.author_id,
+ principalTable: "users",
+ principalColumn: "id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "ix_channels_guild_id",
+ table: "channels",
+ column: "guild_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_guild_user_users_id",
+ table: "guild_user",
+ column: "users_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_guilds_owner_id",
+ table: "guilds",
+ column: "owner_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_identity_instances_domain",
+ table: "identity_instances",
+ column: "domain",
+ unique: true);
+
+ migrationBuilder.CreateIndex(
+ name: "ix_messages_author_id",
+ table: "messages",
+ column: "author_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_messages_channel_id",
+ table: "messages",
+ column: "channel_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_users_instance_id",
+ table: "users",
+ column: "instance_id");
+
+ migrationBuilder.CreateIndex(
+ name: "ix_users_remote_user_id_instance_id",
+ table: "users",
+ columns: new[] { "remote_user_id", "instance_id" },
+ unique: true);
+
+ // EF Core doesn't support creating indexes on arbitrary expressions, so we have to create it manually.
+ migrationBuilder.Sql("CREATE UNIQUE INDEX ix_users_username_instance_id ON users (lower(username), instance_id)");
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "guild_user");
+
+ migrationBuilder.DropTable(
+ name: "instance");
+
+ migrationBuilder.DropTable(
+ name: "messages");
+
+ migrationBuilder.DropTable(
+ name: "channels");
+
+ migrationBuilder.DropTable(
+ name: "guilds");
+
+ migrationBuilder.DropTable(
+ name: "users");
+
+ migrationBuilder.DropTable(
+ name: "identity_instances");
+ }
+ }
+}
diff --git a/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs
new file mode 100644
index 0000000..745ab75
--- /dev/null
+++ b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs
@@ -0,0 +1,329 @@
+//
+using System;
+using Foxchat.Chat.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 Foxchat.Chat.Migrations
+{
+ [DbContext(typeof(ChatContext))]
+ [Migration("20240521191115_AddLastFetchedAtToUsers")]
+ partial class AddLastFetchedAtToUsers
+ {
+ ///
+ 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("Foxchat.Chat.Database.Models.Channel", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("GuildId")
+ .HasColumnType("uuid")
+ .HasColumnName("guild_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Topic")
+ .HasColumnType("text")
+ .HasColumnName("topic");
+
+ b.HasKey("Id")
+ .HasName("pk_channels");
+
+ b.HasIndex("GuildId")
+ .HasDatabaseName("ix_channels_guild_id");
+
+ b.ToTable("channels", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("OwnerId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_id");
+
+ b.HasKey("Id")
+ .HasName("pk_guilds");
+
+ b.HasIndex("OwnerId")
+ .HasDatabaseName("ix_guilds_owner_id");
+
+ b.ToTable("guilds", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.IdentityInstance", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("BaseUrl")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("base_url");
+
+ b.Property("Domain")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("domain");
+
+ b.Property("PublicKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("public_key");
+
+ b.Property("Reason")
+ .HasColumnType("text")
+ .HasColumnName("reason");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_identity_instances");
+
+ b.HasIndex("Domain")
+ .IsUnique()
+ .HasDatabaseName("ix_identity_instances_domain");
+
+ b.ToTable("identity_instances", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AuthorId")
+ .HasColumnType("uuid")
+ .HasColumnName("author_id");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid")
+ .HasColumnName("channel_id");
+
+ b.Property("Content")
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_messages");
+
+ b.HasIndex("AuthorId")
+ .HasDatabaseName("ix_messages_author_id");
+
+ b.HasIndex("ChannelId")
+ .HasDatabaseName("ix_messages_channel_id");
+
+ b.ToTable("messages", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Avatar")
+ .HasColumnType("text")
+ .HasColumnName("avatar");
+
+ b.Property("InstanceId")
+ .HasColumnType("uuid")
+ .HasColumnName("instance_id");
+
+ b.Property("LastFetchedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_fetched_at");
+
+ b.Property("RemoteUserId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("remote_user_id");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.HasIndex("InstanceId")
+ .HasDatabaseName("ix_users_instance_id");
+
+ b.HasIndex("RemoteUserId", "InstanceId")
+ .IsUnique()
+ .HasDatabaseName("ix_users_remote_user_id_instance_id");
+
+ b.HasIndex("Username", "InstanceId")
+ .IsUnique()
+ .HasDatabaseName("ix_users_username_instance_id");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Core.Database.Instance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("PrivateKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("private_key");
+
+ b.Property("PublicKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("public_key");
+
+ b.HasKey("Id")
+ .HasName("pk_instance");
+
+ b.ToTable("instance", (string)null);
+ });
+
+ modelBuilder.Entity("GuildUser", b =>
+ {
+ b.Property("GuildsId")
+ .HasColumnType("uuid")
+ .HasColumnName("guilds_id");
+
+ b.Property("UsersId")
+ .HasColumnType("uuid")
+ .HasColumnName("users_id");
+
+ b.HasKey("GuildsId", "UsersId")
+ .HasName("pk_guild_user");
+
+ b.HasIndex("UsersId")
+ .HasDatabaseName("ix_guild_user_users_id");
+
+ b.ToTable("guild_user", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.Guild", "Guild")
+ .WithMany("Channels")
+ .HasForeignKey("GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_channels_guilds_guild_id");
+
+ b.Navigation("Guild");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.User", "Owner")
+ .WithMany("OwnedGuilds")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_guilds_users_owner_id");
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.User", "Author")
+ .WithMany()
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_messages_users_author_id");
+
+ b.HasOne("Foxchat.Chat.Database.Models.Channel", "Channel")
+ .WithMany()
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_messages_channels_channel_id");
+
+ b.Navigation("Author");
+
+ b.Navigation("Channel");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.IdentityInstance", "Instance")
+ .WithMany()
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_users_identity_instances_instance_id");
+
+ b.Navigation("Instance");
+ });
+
+ modelBuilder.Entity("GuildUser", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.Guild", null)
+ .WithMany()
+ .HasForeignKey("GuildsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_guild_user_guilds_guilds_id");
+
+ b.HasOne("Foxchat.Chat.Database.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UsersId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_guild_user_users_users_id");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b =>
+ {
+ b.Navigation("Channels");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b =>
+ {
+ b.Navigation("OwnedGuilds");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.cs b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.cs
new file mode 100644
index 0000000..f4a99ba
--- /dev/null
+++ b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.cs
@@ -0,0 +1,30 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+using NodaTime;
+
+#nullable disable
+
+namespace Foxchat.Chat.Migrations
+{
+ ///
+ public partial class AddLastFetchedAtToUsers : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "last_fetched_at",
+ table: "users",
+ type: "timestamp with time zone",
+ nullable: false,
+ defaultValue: NodaTime.Instant.FromUnixTimeTicks(0L));
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "last_fetched_at",
+ table: "users");
+ }
+ }
+}
diff --git a/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs b/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs
new file mode 100644
index 0000000..184e756
--- /dev/null
+++ b/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs
@@ -0,0 +1,326 @@
+//
+using System;
+using Foxchat.Chat.Database;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using NodaTime;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Foxchat.Chat.Migrations
+{
+ [DbContext(typeof(ChatContext))]
+ partial class ChatContextModelSnapshot : ModelSnapshot
+ {
+ protected override void BuildModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "8.0.5")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("GuildId")
+ .HasColumnType("uuid")
+ .HasColumnName("guild_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("Topic")
+ .HasColumnType("text")
+ .HasColumnName("topic");
+
+ b.HasKey("Id")
+ .HasName("pk_channels");
+
+ b.HasIndex("GuildId")
+ .HasDatabaseName("ix_channels_guild_id");
+
+ b.ToTable("channels", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("OwnerId")
+ .HasColumnType("uuid")
+ .HasColumnName("owner_id");
+
+ b.HasKey("Id")
+ .HasName("pk_guilds");
+
+ b.HasIndex("OwnerId")
+ .HasDatabaseName("ix_guilds_owner_id");
+
+ b.ToTable("guilds", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.IdentityInstance", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("BaseUrl")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("base_url");
+
+ b.Property("Domain")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("domain");
+
+ b.Property("PublicKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("public_key");
+
+ b.Property("Reason")
+ .HasColumnType("text")
+ .HasColumnName("reason");
+
+ b.Property("Status")
+ .HasColumnType("integer")
+ .HasColumnName("status");
+
+ b.HasKey("Id")
+ .HasName("pk_identity_instances");
+
+ b.HasIndex("Domain")
+ .IsUnique()
+ .HasDatabaseName("ix_identity_instances_domain");
+
+ b.ToTable("identity_instances", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AuthorId")
+ .HasColumnType("uuid")
+ .HasColumnName("author_id");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid")
+ .HasColumnName("channel_id");
+
+ b.Property("Content")
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property("UpdatedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("updated_at");
+
+ b.HasKey("Id")
+ .HasName("pk_messages");
+
+ b.HasIndex("AuthorId")
+ .HasDatabaseName("ix_messages_author_id");
+
+ b.HasIndex("ChannelId")
+ .HasDatabaseName("ix_messages_channel_id");
+
+ b.ToTable("messages", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Avatar")
+ .HasColumnType("text")
+ .HasColumnName("avatar");
+
+ b.Property("InstanceId")
+ .HasColumnType("uuid")
+ .HasColumnName("instance_id");
+
+ b.Property("LastFetchedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_fetched_at");
+
+ b.Property("RemoteUserId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("remote_user_id");
+
+ b.Property("Username")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("username");
+
+ b.HasKey("Id")
+ .HasName("pk_users");
+
+ b.HasIndex("InstanceId")
+ .HasDatabaseName("ix_users_instance_id");
+
+ b.HasIndex("RemoteUserId", "InstanceId")
+ .IsUnique()
+ .HasDatabaseName("ix_users_remote_user_id_instance_id");
+
+ b.HasIndex("Username", "InstanceId")
+ .IsUnique()
+ .HasDatabaseName("ix_users_username_instance_id");
+
+ b.ToTable("users", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Core.Database.Instance", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("PrivateKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("private_key");
+
+ b.Property("PublicKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("public_key");
+
+ b.HasKey("Id")
+ .HasName("pk_instance");
+
+ b.ToTable("instance", (string)null);
+ });
+
+ modelBuilder.Entity("GuildUser", b =>
+ {
+ b.Property("GuildsId")
+ .HasColumnType("uuid")
+ .HasColumnName("guilds_id");
+
+ b.Property("UsersId")
+ .HasColumnType("uuid")
+ .HasColumnName("users_id");
+
+ b.HasKey("GuildsId", "UsersId")
+ .HasName("pk_guild_user");
+
+ b.HasIndex("UsersId")
+ .HasDatabaseName("ix_guild_user_users_id");
+
+ b.ToTable("guild_user", (string)null);
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.Guild", "Guild")
+ .WithMany("Channels")
+ .HasForeignKey("GuildId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_channels_guilds_guild_id");
+
+ b.Navigation("Guild");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.User", "Owner")
+ .WithMany("OwnedGuilds")
+ .HasForeignKey("OwnerId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_guilds_users_owner_id");
+
+ b.Navigation("Owner");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.User", "Author")
+ .WithMany()
+ .HasForeignKey("AuthorId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_messages_users_author_id");
+
+ b.HasOne("Foxchat.Chat.Database.Models.Channel", "Channel")
+ .WithMany()
+ .HasForeignKey("ChannelId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_messages_channels_channel_id");
+
+ b.Navigation("Author");
+
+ b.Navigation("Channel");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.IdentityInstance", "Instance")
+ .WithMany()
+ .HasForeignKey("InstanceId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_users_identity_instances_instance_id");
+
+ b.Navigation("Instance");
+ });
+
+ modelBuilder.Entity("GuildUser", b =>
+ {
+ b.HasOne("Foxchat.Chat.Database.Models.Guild", null)
+ .WithMany()
+ .HasForeignKey("GuildsId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_guild_user_guilds_guilds_id");
+
+ b.HasOne("Foxchat.Chat.Database.Models.User", null)
+ .WithMany()
+ .HasForeignKey("UsersId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired()
+ .HasConstraintName("fk_guild_user_users_users_id");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b =>
+ {
+ b.Navigation("Channels");
+ });
+
+ modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b =>
+ {
+ b.Navigation("OwnedGuilds");
+ });
+#pragma warning restore 612, 618
+ }
+ }
+}
diff --git a/Foxchat.Chat/Program.cs b/Foxchat.Chat/Program.cs
index 68e4115..fa2bbb6 100644
--- a/Foxchat.Chat/Program.cs
+++ b/Foxchat.Chat/Program.cs
@@ -3,16 +3,28 @@ using Serilog;
using Foxchat.Core;
using Foxchat.Chat;
using Foxchat.Chat.Database;
+using Foxchat.Chat.Extensions;
+using Foxchat.Core.Extensions;
+using Newtonsoft.Json;
var builder = WebApplication.CreateBuilder(args);
var config = builder.AddConfiguration("chat.ini");
-builder.AddSerilog(config.LogEventLevel);
+builder.AddSerilog();
await BuildInfo.ReadBuildInfo();
Log.Information("Starting Foxchat.Chat {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash);
+// Set the default converter to snake case as we use it in a couple places.
+JsonConvert.DefaultSettings = () => new JsonSerializerSettings
+{
+ ContractResolver = new DefaultContractResolver
+ {
+ NamingStrategy = new SnakeCaseNamingStrategy()
+ }
+};
+
builder.Services
.AddControllers()
.AddNewtonsoftJson(options =>
@@ -23,6 +35,8 @@ builder.Services
builder.Services
.AddCoreServices()
+ .AddChatServices()
+ .AddCustomMiddleware()
.AddEndpointsApiExplorer()
.AddSwaggerGen();
@@ -33,12 +47,11 @@ app.UseRouting();
app.UseSwagger();
app.UseSwaggerUI();
app.UseCors();
-app.UseAuthentication();
-app.UseAuthorization();
+app.UseCustomMiddleware();
app.MapControllers();
using (var scope = app.Services.CreateScope())
-using (var context = scope.ServiceProvider.GetRequiredService())
+await using (var context = scope.ServiceProvider.GetRequiredService())
{
Log.Information("Initializing instance keypair...");
if (await context.InitializeInstanceAsync())
diff --git a/Foxchat.Chat/Services/UserResolverService.cs b/Foxchat.Chat/Services/UserResolverService.cs
new file mode 100644
index 0000000..87f56eb
--- /dev/null
+++ b/Foxchat.Chat/Services/UserResolverService.cs
@@ -0,0 +1,35 @@
+using Foxchat.Chat.Database;
+using Foxchat.Chat.Database.Models;
+using Foxchat.Core.Federation;
+using Foxchat.Core.Models;
+using Microsoft.EntityFrameworkCore;
+
+namespace Foxchat.Chat.Services;
+
+public class UserResolverService(ILogger logger, ChatContext db, RequestSigningService requestSigningService)
+{
+ public async Task ResolveUserAsync(IdentityInstance instance, string userId)
+ {
+ var user = await db.Users.FirstOrDefaultAsync(u => u.InstanceId == instance.Id && u.RemoteUserId == userId);
+ if (user != null)
+ {
+ // TODO: update user if it's been long enough
+ return user;
+ }
+
+ var userResponse = await requestSigningService.RequestAsync(HttpMethod.Get, instance.Domain,
+ $"/_fox/ident/users/{userId}");
+
+ user = new User
+ {
+ Instance = instance,
+ Username = userResponse.Username,
+ RemoteUserId = userResponse.Id,
+ Avatar = userResponse.AvatarUrl
+ };
+
+ db.Add(user);
+ await db.SaveChangesAsync();
+ return user;
+ }
+}
\ No newline at end of file
diff --git a/Foxchat.Chat/chat.ini b/Foxchat.Chat/chat.ini
index 21a32a0..906acdb 100644
--- a/Foxchat.Chat/chat.ini
+++ b/Foxchat.Chat/chat.ini
@@ -2,9 +2,6 @@ Host = localhost
Port = 7610
Domain = chat.fox.localhost
-; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal
-LogEventLevel = Debug
-
[Database]
; The database URL in ADO.NET format.
Url = "Host=localhost;Database=foxchat_cs_chat;Username=foxchat;Password=password"
@@ -13,3 +10,11 @@ Url = "Host=localhost;Database=foxchat_cs_chat;Username=foxchat;Password=passwor
Timeout = 5
; The maximum number of open connections. Defaults to 50.
MaxPoolSize = 500
+
+[Logging]
+; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal
+LogEventLevel = Debug
+; Whether to log SQL queries.
+LogQueries = true
+; Optional logging to Seq
+SeqLogUrl = http://localhost:5341
\ No newline at end of file
diff --git a/Foxchat.Core/CoreConfig.cs b/Foxchat.Core/CoreConfig.cs
index 40a97b4..236af8a 100644
--- a/Foxchat.Core/CoreConfig.cs
+++ b/Foxchat.Core/CoreConfig.cs
@@ -11,9 +11,7 @@ public class CoreConfig
public string Address => $"{(Secure ? "https" : "http")}://{Host}:{Port}";
- public LogEventLevel LogEventLevel { get; set; } = LogEventLevel.Debug;
- public string? SeqLogUrl { get; set; }
-
+ public LoggingConfig Logging { get; set; } = new();
public DatabaseConfig Database { get; set; } = new();
public class DatabaseConfig
@@ -22,4 +20,11 @@ public class CoreConfig
public int? Timeout { get; set; }
public int? MaxPoolSize { get; set; }
}
-}
+
+ public class LoggingConfig
+ {
+ public LogEventLevel LogEventLevel { get; set; } = LogEventLevel.Debug;
+ public string? SeqLogUrl { get; set; }
+ public bool LogQueries { get; set; } = false;
+ }
+}
\ No newline at end of file
diff --git a/Foxchat.Core/Database/IDatabaseContext.cs b/Foxchat.Core/Database/IDatabaseContext.cs
index 9e2a707..a8d8ec5 100644
--- a/Foxchat.Core/Database/IDatabaseContext.cs
+++ b/Foxchat.Core/Database/IDatabaseContext.cs
@@ -16,11 +16,11 @@ public abstract class IDatabaseContext : DbContext
var publicKey = rsa.ExportRSAPublicKeyPem();
var privateKey = rsa.ExportRSAPrivateKeyPem();
- await Instance.AddAsync(new Instance
+ Instance.Add(new Instance
{
PublicKey = publicKey!,
PrivateKey = privateKey!,
- }, ct);
+ });
await SaveChangesAsync(ct);
return true;
diff --git a/Foxchat.Core/UlidConverter.cs b/Foxchat.Core/Database/UlidConverter.cs
similarity index 100%
rename from Foxchat.Core/UlidConverter.cs
rename to Foxchat.Core/Database/UlidConverter.cs
diff --git a/Foxchat.Core/Extensions/HttpContextExtensions.cs b/Foxchat.Core/Extensions/HttpContextExtensions.cs
new file mode 100644
index 0000000..f00c8e4
--- /dev/null
+++ b/Foxchat.Core/Extensions/HttpContextExtensions.cs
@@ -0,0 +1,40 @@
+using System.Diagnostics.CodeAnalysis;
+using Foxchat.Core.Federation;
+using Microsoft.AspNetCore.Http;
+
+namespace Foxchat.Core.Extensions;
+
+public static class HttpContextExtensions
+{
+ public static bool ExtractRequestData(this HttpContext ctx, [NotNullWhen(true)] out string? signature,
+ [NotNullWhen(true)] out string? domain, [NotNullWhen(true)] out SignatureData? data)
+ {
+ signature = null;
+ domain = null;
+ data = null;
+
+ if (!ctx.Request.Headers.TryGetValue(RequestSigningService.SIGNATURE_HEADER, out var encodedSignature))
+ return false;
+ if (!ctx.Request.Headers.TryGetValue(RequestSigningService.DATE_HEADER, out var date))
+ return false;
+ if (!ctx.Request.Headers.TryGetValue(RequestSigningService.SERVER_HEADER, out var server))
+ return false;
+ var time = RequestSigningService.ParseTime(date.ToString());
+ string? userId = null;
+ if (ctx.Request.Headers.TryGetValue(RequestSigningService.USER_HEADER, out var userIdHeader))
+ userId = userIdHeader;
+ var host = ctx.Request.Headers.Host.ToString();
+
+ signature = encodedSignature.ToString();
+ domain = server.ToString();
+ data = new SignatureData(
+ time,
+ host,
+ ctx.Request.Path,
+ (int?)ctx.Request.Headers.ContentLength,
+ userId
+ );
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/Foxchat.Core/ServiceCollectionExtensions.cs b/Foxchat.Core/Extensions/ServiceCollectionExtensions.cs
similarity index 81%
rename from Foxchat.Core/ServiceCollectionExtensions.cs
rename to Foxchat.Core/Extensions/ServiceCollectionExtensions.cs
index f5d4893..a41dd0b 100644
--- a/Foxchat.Core/ServiceCollectionExtensions.cs
+++ b/Foxchat.Core/Extensions/ServiceCollectionExtensions.cs
@@ -7,30 +7,32 @@ using NodaTime;
using Serilog;
using Serilog.Events;
-namespace Foxchat.Core;
+namespace Foxchat.Core.Extensions;
public static class ServiceCollectionExtensions
{
///
/// Adds Serilog to this service collection. This method also initializes Serilog so it should be called as early as possible, before any log calls.
///
- public static void AddSerilog(this WebApplicationBuilder builder, LogEventLevel level)
+ public static void AddSerilog(this WebApplicationBuilder builder)
{
var config = builder.Configuration.Get() ?? new();
var logCfg = new LoggerConfiguration()
.Enrich.FromLogContext()
- .MinimumLevel.Is(level)
+ .MinimumLevel.Is(config.Logging.LogEventLevel)
// ASP.NET's built in request logs are extremely verbose, so we use Serilog's instead.
- // Serilog doesn't disable the built in logs so we do it here.
+ // Serilog doesn't disable the built-in logs, so we do it here.
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
+ .MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command",
+ config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
.WriteTo.Console();
- if (config.SeqLogUrl != null)
- logCfg.WriteTo.Seq(config.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose);
+ if (config.Logging.SeqLogUrl != null)
+ logCfg.WriteTo.Seq(config.Logging.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose);
Log.Logger = logCfg.CreateLogger();
@@ -54,9 +56,9 @@ public static class ServiceCollectionExtensions
return services;
}
- public static T AddConfiguration(this WebApplicationBuilder builder, string? configFile = null) where T : class, new()
+ public static T AddConfiguration(this WebApplicationBuilder builder, string? configFile = null)
+ where T : class, new()
{
-
builder.Configuration.Sources.Clear();
builder.Configuration.AddConfiguration(configFile);
@@ -76,4 +78,4 @@ public static class ServiceCollectionExtensions
.AddIniFile(file, optional: false, reloadOnChange: true)
.AddEnvironmentVariables();
}
-}
+}
\ No newline at end of file
diff --git a/Foxchat.Core/Federation/RequestSigningService.Client.cs b/Foxchat.Core/Federation/RequestSigningService.Client.cs
index 36de79e..a38e89b 100644
--- a/Foxchat.Core/Federation/RequestSigningService.Client.cs
+++ b/Foxchat.Core/Federation/RequestSigningService.Client.cs
@@ -1,5 +1,4 @@
using System.Net.Http.Headers;
-using Foxchat.Core.Models;
using Newtonsoft.Json;
using Newtonsoft.Json.Serialization;
@@ -15,14 +14,6 @@ public partial class RequestSigningService
public const string SIGNATURE_HEADER = "X-Foxchat-Signature";
public const string USER_HEADER = "X-Foxchat-User";
- private static readonly JsonSerializerSettings _jsonSerializerSettings = new()
- {
- ContractResolver = new DefaultContractResolver
- {
- NamingStrategy = new SnakeCaseNamingStrategy()
- }
- };
-
public async Task RequestAsync(HttpMethod method, string domain, string requestPath, string? userId = null, object? body = null)
{
var request = BuildHttpRequest(method, domain, requestPath, userId, body);
@@ -30,17 +21,17 @@ public partial class RequestSigningService
if (!resp.IsSuccessStatusCode)
{
var error = await resp.Content.ReadAsStringAsync();
- throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", DeserializeObject(error));
+ throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned an error", JsonConvert.DeserializeObject(error));
}
var bodyString = await resp.Content.ReadAsStringAsync();
- return DeserializeObject(bodyString)
+ return JsonConvert.DeserializeObject(bodyString)
?? throw new ApiError.OutgoingFederationError($"Request to {domain}{requestPath} returned invalid response body");
}
private HttpRequestMessage BuildHttpRequest(HttpMethod method, string domain, string requestPath, string? userId = null, object? bodyData = null)
{
- var body = bodyData != null ? SerializeObject(bodyData) : null;
+ var body = bodyData != null ? JsonConvert.SerializeObject(bodyData) : null;
var now = _clock.GetCurrentInstant();
var url = $"https://{domain}{requestPath}";
@@ -59,7 +50,4 @@ public partial class RequestSigningService
return request;
}
-
- public static string SerializeObject(object data) => JsonConvert.SerializeObject(data, _jsonSerializerSettings);
- public static T? DeserializeObject(string data) => JsonConvert.DeserializeObject(data, _jsonSerializerSettings);
}
diff --git a/Foxchat.Core/Federation/RequestSigningService.cs b/Foxchat.Core/Federation/RequestSigningService.cs
index 07b0536..2185277 100644
--- a/Foxchat.Core/Federation/RequestSigningService.cs
+++ b/Foxchat.Core/Federation/RequestSigningService.cs
@@ -31,23 +31,21 @@ public partial class RequestSigningService(ILogger logger, IClock clock, IDataba
}
public bool VerifySignature(
- string publicKey, string encodedSignature, string dateHeader, string host, string requestPath, int? contentLength, string? userId)
+ string publicKey, string encodedSignature, SignatureData data)
{
+ if (data.Host != _config.Domain)
+ throw new ApiError.IncomingFederationError("Request is not for this instance");
+
+ var now = _clock.GetCurrentInstant();
+ if (now + Duration.FromMinutes(1) < data.Time)
+ throw new ApiError.IncomingFederationError("Request was made in the future");
+ if (now - Duration.FromMinutes(1) > data.Time)
+ throw new ApiError.IncomingFederationError("Request was made too long ago");
+
var rsa = RSA.Create();
rsa.ImportFromPem(publicKey);
- var now = _clock.GetCurrentInstant();
- var time = ParseTime(dateHeader);
- if ((now + Duration.FromMinutes(1)) < time)
- {
- throw new ApiError.IncomingFederationError("Request was made in the future");
- }
- else if ((now - Duration.FromMinutes(1)) > time)
- {
- throw new ApiError.IncomingFederationError("Request was made too long ago");
- }
-
- var plaintext = GeneratePlaintext(new SignatureData(time, host, requestPath, contentLength, userId));
+ var plaintext = GeneratePlaintext(data);
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
var hash = SHA256.HashData(plaintextBytes);
@@ -71,7 +69,9 @@ public partial class RequestSigningService(ILogger logger, IClock clock, IDataba
return $"{time}:{data.Host}:{data.RequestPath}:{contentLength}:{userId}";
}
- private static readonly InstantPattern _pattern = InstantPattern.Create("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.GetCultureInfo("en-US"));
+ private static readonly InstantPattern _pattern =
+ InstantPattern.Create("ddd, dd MMM yyyy HH:mm:ss 'GMT'", CultureInfo.GetCultureInfo("en-US"));
+
private static string FormatTime(Instant time) => _pattern.Format(time);
- private static Instant ParseTime(string header) => _pattern.Parse(header).GetValueOrThrow();
-}
+ public static Instant ParseTime(string header) => _pattern.Parse(header).GetValueOrThrow();
+}
\ No newline at end of file
diff --git a/Foxchat.Core/Federation/SignatureData.cs b/Foxchat.Core/Federation/SignatureData.cs
index 54878f2..025ed19 100644
--- a/Foxchat.Core/Federation/SignatureData.cs
+++ b/Foxchat.Core/Federation/SignatureData.cs
@@ -8,4 +8,13 @@ public record SignatureData(
string RequestPath,
int? ContentLength,
string? UserId
-);
+)
+{
+ public static readonly SignatureData Empty = new(
+ Instant.MinValue,
+ string.Empty,
+ string.Empty,
+ null,
+ null
+ );
+}
\ No newline at end of file
diff --git a/Foxchat.Core/FoxchatError.cs b/Foxchat.Core/FoxchatError.cs
index 7fcd201..d2f5304 100644
--- a/Foxchat.Core/FoxchatError.cs
+++ b/Foxchat.Core/FoxchatError.cs
@@ -2,31 +2,31 @@ using System.Net;
namespace Foxchat.Core;
-public class FoxchatError(string message) : Exception(message)
+public class FoxchatError(string message, Exception? inner = null) : Exception(message)
{
- public class DatabaseError(string message) : FoxchatError(message);
- public class UnknownEntityError(Type entityType) : FoxchatError($"Entity of type {entityType.Name} not found");
+ public Exception? Inner => inner;
+
+ public class DatabaseError(string message, Exception? inner = null) : FoxchatError(message, inner);
+ public class UnknownEntityError(Type entityType, Exception? inner = null) : FoxchatError($"Entity of type {entityType.Name} not found", inner);
}
public class ApiError(string message, HttpStatusCode? statusCode = null) : FoxchatError(message)
{
public readonly HttpStatusCode StatusCode = statusCode ?? HttpStatusCode.InternalServerError;
-
public class Unauthorized(string message) : ApiError(message, statusCode: HttpStatusCode.Unauthorized);
-
public class Forbidden(string message, IEnumerable? scopes = null) : ApiError(message, statusCode: HttpStatusCode.Forbidden)
{
public readonly string[] Scopes = scopes?.ToArray() ?? [];
}
-
public class BadRequest(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest);
+ public class NotFound(string message) : ApiError(message, statusCode: HttpStatusCode.NotFound);
public class IncomingFederationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest);
public class OutgoingFederationError(
- string message, Models.ApiError? innerError = null
+ string message, Models.Http.ApiError? innerError = null
) : ApiError(message, statusCode: HttpStatusCode.InternalServerError)
{
- public Models.ApiError? InnerError => innerError;
+ public Models.Http.ApiError? InnerError => innerError;
}
public class AuthenticationError(string message) : ApiError(message, statusCode: HttpStatusCode.BadRequest);
diff --git a/Foxchat.Core/Middleware/ErrorHandlerMiddleware.cs b/Foxchat.Core/Middleware/ErrorHandlerMiddleware.cs
new file mode 100644
index 0000000..0f34dd1
--- /dev/null
+++ b/Foxchat.Core/Middleware/ErrorHandlerMiddleware.cs
@@ -0,0 +1,86 @@
+using System.Net;
+using Foxchat.Core.Models.Http;
+using Microsoft.AspNetCore.Http;
+using Newtonsoft.Json;
+using HttpApiError = Foxchat.Core.Models.Http.ApiError;
+
+namespace Foxchat.Core.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 ?? "";
+ 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);
+ }
+
+ 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.OutgoingFederationError ofe)
+ {
+ await ctx.Response.WriteAsync(JsonConvert.SerializeObject(new HttpApiError
+ {
+ Status = (int)ofe.StatusCode,
+ Code = ErrorCode.OutgoingFederationError,
+ Message = ofe.Message,
+ OriginalError = ofe.InnerError
+ }));
+ return;
+ }
+ else 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 FoxchatError 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",
+ }));
+ }
+ }
+}
\ No newline at end of file
diff --git a/Foxchat.Core/Models/ApiError.cs b/Foxchat.Core/Models/ApiError.cs
deleted file mode 100644
index a9ac71d..0000000
--- a/Foxchat.Core/Models/ApiError.cs
+++ /dev/null
@@ -1,17 +0,0 @@
-namespace Foxchat.Core.Models;
-
-public record ApiError(int Status, ErrorCode Code, string Message);
-
-public enum ErrorCode
-{
- INTERNAL_SERVER_ERROR,
- OBJECT_NOT_FOUND,
- INVALID_SERVER,
- INVALID_HEADER,
- INVALID_DATE,
- INVALID_SIGNATURE,
- MISSING_SIGNATURE,
- GUILD_NOT_FOUND,
- UNAUTHORIZED,
- INVALID_REST_EVENT,
-}
diff --git a/Foxchat.Core/Models/Channels.cs b/Foxchat.Core/Models/Channels.cs
new file mode 100644
index 0000000..b84c114
--- /dev/null
+++ b/Foxchat.Core/Models/Channels.cs
@@ -0,0 +1,8 @@
+namespace Foxchat.Core.Models;
+
+public static class Channels
+{
+ public record Channel(string Id, string GuildId, string Name, string? Topic);
+
+ public record PartialChannel(string Id, string Name);
+}
\ No newline at end of file
diff --git a/Foxchat.Core/Models/Guilds.cs b/Foxchat.Core/Models/Guilds.cs
new file mode 100644
index 0000000..ac0a6a4
--- /dev/null
+++ b/Foxchat.Core/Models/Guilds.cs
@@ -0,0 +1,14 @@
+using Newtonsoft.Json;
+
+namespace Foxchat.Core.Models;
+
+public static class Guilds
+{
+ public record Guild(
+ string Id,
+ string Name,
+ IEnumerable OwnerIds,
+ [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
+ IEnumerable? Channels
+ );
+}
\ No newline at end of file
diff --git a/Foxchat.Core/Models/Http/ApiError.cs b/Foxchat.Core/Models/Http/ApiError.cs
new file mode 100644
index 0000000..7d5df22
--- /dev/null
+++ b/Foxchat.Core/Models/Http/ApiError.cs
@@ -0,0 +1,28 @@
+using Newtonsoft.Json;
+using Newtonsoft.Json.Converters;
+
+namespace Foxchat.Core.Models.Http;
+
+public record ApiError
+{
+ 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 ApiError? OriginalError { get; init; }
+ [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
+ public string[]? Scopes { get; init; }
+}
+
+public enum ErrorCode
+{
+ InternalServerError,
+ Unauthorized,
+ Forbidden,
+ BadRequest,
+ OutgoingFederationError,
+ AuthenticationError,
+ // TODO: more specific API error codes
+ GenericApiError,
+}
diff --git a/Foxchat.Core/Models/Http/Apps.cs b/Foxchat.Core/Models/Http/AppsApi.cs
similarity index 93%
rename from Foxchat.Core/Models/Http/Apps.cs
rename to Foxchat.Core/Models/Http/AppsApi.cs
index 8b670f4..909ce4d 100644
--- a/Foxchat.Core/Models/Http/Apps.cs
+++ b/Foxchat.Core/Models/Http/AppsApi.cs
@@ -1,6 +1,6 @@
namespace Foxchat.Core.Models.Http;
-public static class Apps
+public static class AppsApi
{
public record CreateRequest(string Name, string[] Scopes, string[] RedirectUris);
public record CreateResponse(Ulid Id, string ClientId, string ClientSecret, string Name, string[] Scopes, string[] RedirectUris);
diff --git a/Foxchat.Core/Models/Http/GuildsApi.cs b/Foxchat.Core/Models/Http/GuildsApi.cs
new file mode 100644
index 0000000..e9f7eaf
--- /dev/null
+++ b/Foxchat.Core/Models/Http/GuildsApi.cs
@@ -0,0 +1,6 @@
+namespace Foxchat.Core.Models.Http;
+
+public static class GuildsApi
+{
+ public record CreateGuildRequest(string Name);
+}
\ No newline at end of file
diff --git a/Foxchat.Core/Models/Http/Hello.cs b/Foxchat.Core/Models/Http/Hello.cs
index 69e0b74..1fc2b4b 100644
--- a/Foxchat.Core/Models/Http/Hello.cs
+++ b/Foxchat.Core/Models/Http/Hello.cs
@@ -4,6 +4,6 @@ public static class Hello
{
public record HelloRequest(string Host);
public record HelloResponse(string PublicKey, string Host);
- public record NodeInfo(string Software, string PublicKey);
+ public record NodeInfo(NodeSoftware Software, string PublicKey);
public record NodeSoftware(string Name, string? Version);
}
diff --git a/Foxchat.Core/Models/Users.cs b/Foxchat.Core/Models/Users.cs
new file mode 100644
index 0000000..6914a44
--- /dev/null
+++ b/Foxchat.Core/Models/Users.cs
@@ -0,0 +1,8 @@
+namespace Foxchat.Core.Models;
+
+public static class Users
+{
+ public record User(string Id, string Username, string Instance, string? AvatarUrl);
+
+ public record PartialUser(string Id, string Username, string Instance);
+}
\ No newline at end of file
diff --git a/Foxchat.Identity/Controllers/NodeController.cs b/Foxchat.Identity/Controllers/NodeController.cs
index 4c47503..0f83de9 100644
--- a/Foxchat.Identity/Controllers/NodeController.cs
+++ b/Foxchat.Identity/Controllers/NodeController.cs
@@ -1,3 +1,4 @@
+using Foxchat.Core;
using Foxchat.Core.Models.Http;
using Foxchat.Identity.Database;
using Foxchat.Identity.Services;
@@ -7,15 +8,16 @@ namespace Foxchat.Identity.Controllers;
[ApiController]
[Route("/_fox/ident/node")]
-public class NodeController(IdentityContext db, ChatInstanceResolverService chatInstanceResolverService) : ControllerBase
+public class NodeController(IdentityContext db, ChatInstanceResolverService chatInstanceResolverService)
+ : ControllerBase
{
- public const string SOFTWARE_NAME = "Foxchat.NET.Identity";
+ private const string SoftwareName = "Foxchat.NET.Identity";
[HttpGet]
public async Task GetNode()
{
var instance = await db.GetInstanceAsync();
- return Ok(new Hello.NodeInfo(SOFTWARE_NAME, instance.PublicKey));
+ return Ok(new Hello.NodeInfo(new Hello.NodeSoftware(SoftwareName, BuildInfo.Version), instance.PublicKey));
}
[HttpGet("{domain}")]
@@ -24,4 +26,4 @@ public class NodeController(IdentityContext db, ChatInstanceResolverService chat
var instance = await chatInstanceResolverService.ResolveChatInstanceAsync(domain);
return Ok(instance);
}
-}
+}
\ No newline at end of file
diff --git a/Foxchat.Identity/Controllers/Oauth/AppsController.cs b/Foxchat.Identity/Controllers/Oauth/AppsController.cs
index 113a3b7..1db1b74 100644
--- a/Foxchat.Identity/Controllers/Oauth/AppsController.cs
+++ b/Foxchat.Identity/Controllers/Oauth/AppsController.cs
@@ -1,27 +1,28 @@
using Foxchat.Core;
using Foxchat.Core.Models.Http;
-using Foxchat.Identity.Authorization;
+using Foxchat.Identity.Middleware;
using Foxchat.Identity.Database;
using Foxchat.Identity.Database.Models;
+using Foxchat.Identity.Utils;
using Microsoft.AspNetCore.Mvc;
namespace Foxchat.Identity.Controllers.Oauth;
[ApiController]
-[Authenticate]
+[ClientAuthenticate]
[Route("/_fox/ident/oauth/apps")]
public class AppsController(ILogger logger, IdentityContext db) : ControllerBase
{
[HttpPost]
- public async Task CreateApplication([FromBody] Apps.CreateRequest req)
+ public async Task CreateApplication([FromBody] AppsApi.CreateRequest req)
{
var app = Application.Create(req.Name, req.Scopes, req.RedirectUris);
- await db.AddAsync(app);
+ db.Add(app);
await db.SaveChangesAsync();
logger.Information("Created new application {Name} with ID {Id} and client ID {ClientId}", app.Name, app.Id, app.ClientId);
- return Ok(new Apps.CreateResponse(
+ return Ok(new AppsApi.CreateResponse(
app.Id, app.ClientId, app.ClientSecret, app.Name, app.Scopes, app.RedirectUris
));
}
@@ -29,11 +30,9 @@ public class AppsController(ILogger logger, IdentityContext db) : ControllerBase
[HttpGet]
public IActionResult GetSelfApp([FromQuery(Name = "with_secret")] bool withSecret)
{
- var token = HttpContext.GetToken();
- if (token is not { Account: null }) throw new ApiError.Forbidden("This endpoint requires a client token.");
- var app = token.Application;
+ var app = HttpContext.GetApplicationOrThrow();
- return Ok(new Apps.GetSelfResponse(
+ return Ok(new AppsApi.GetSelfResponse(
app.Id,
app.ClientId,
withSecret ? app.ClientSecret : null,
diff --git a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs
new file mode 100644
index 0000000..06a6a6e
--- /dev/null
+++ b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs
@@ -0,0 +1,84 @@
+using Foxchat.Identity.Middleware;
+using Foxchat.Identity.Database;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Identity;
+using Foxchat.Identity.Database.Models;
+using Foxchat.Core;
+using System.Diagnostics;
+using Foxchat.Identity.Utils;
+using NodaTime;
+using Microsoft.EntityFrameworkCore;
+
+namespace Foxchat.Identity.Controllers.Oauth;
+
+[ApiController]
+[ClientAuthenticate]
+[Route("/_fox/ident/oauth/password")]
+public class PasswordAuthController(ILogger logger, IdentityContext db, IClock clock) : ControllerBase
+{
+ private readonly PasswordHasher _passwordHasher = new();
+
+ [HttpPost("register")]
+ public async Task Register([FromBody] RegisterRequest req)
+ {
+ var app = HttpContext.GetApplicationOrThrow();
+ var appToken =
+ HttpContext.GetToken() ??
+ throw new UnreachableException(); // GetApplicationOrThrow already gets the token and throws if it's null
+ var appScopes = appToken.Scopes.ExpandScopes();
+
+ if (req.Scopes.Except(appScopes).Any())
+ throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token",
+ req.Scopes.Except(appScopes));
+
+ var acct = new Account
+ {
+ Username = req.Username,
+ Email = req.Email,
+ Role = Account.AccountRole.User
+ };
+
+ db.Add(acct);
+ var hashedPassword = await Task.Run(() => _passwordHasher.HashPassword(acct, req.Password));
+ acct.Password = hashedPassword;
+ // TODO: make token expiry configurable
+ var (tokenStr, token) = Token.Create(acct, app, req.Scopes, clock.GetCurrentInstant() + Duration.FromDays(365));
+ db.Add(token);
+ await db.SaveChangesAsync();
+
+ return Ok(new AuthResponse(acct.Id, acct.Username, acct.Email, tokenStr));
+ }
+
+ [HttpPost("login")]
+ public async Task Login([FromBody] LoginRequest req)
+ {
+ var app = HttpContext.GetApplicationOrThrow();
+ var appToken = HttpContext.GetToken() ?? throw new UnreachableException();
+ var appScopes = appToken.Scopes.ExpandScopes();
+
+ if (req.Scopes.Except(appScopes).Any())
+ throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token",
+ req.Scopes.Except(appScopes));
+
+ var acct = await db.Accounts.FirstOrDefaultAsync(a => a.Email == req.Email)
+ ?? throw new ApiError.NotFound("No user with that email found, or password is incorrect");
+
+ var pwResult = await Task.Run(() => _passwordHasher.VerifyHashedPassword(acct, acct.Password, req.Password));
+ if (pwResult == PasswordVerificationResult.Failed)
+ throw new ApiError.NotFound("No user with that email found, or password is incorrect");
+ if (pwResult == PasswordVerificationResult.SuccessRehashNeeded)
+ acct.Password = await Task.Run(() => _passwordHasher.HashPassword(acct, req.Password));
+
+ var (tokenStr, token) = Token.Create(acct, app, req.Scopes, clock.GetCurrentInstant() + Duration.FromDays(365));
+ db.Add(token);
+ await db.SaveChangesAsync();
+
+ return Ok(new AuthResponse(acct.Id, acct.Username, acct.Email, tokenStr));
+ }
+
+ public record RegisterRequest(string Username, string Password, string Email, string[] Scopes);
+
+ public record LoginRequest(string Email, string Password, string[] Scopes);
+
+ public record AuthResponse(Ulid Id, string Username, string Email, string Token);
+}
\ No newline at end of file
diff --git a/Foxchat.Identity/Controllers/Oauth/TokenController.cs b/Foxchat.Identity/Controllers/Oauth/TokenController.cs
index 9a8806a..20c5924 100644
--- a/Foxchat.Identity/Controllers/Oauth/TokenController.cs
+++ b/Foxchat.Identity/Controllers/Oauth/TokenController.cs
@@ -1,6 +1,7 @@
using Foxchat.Core;
using Foxchat.Identity.Database;
using Foxchat.Identity.Database.Models;
+using Foxchat.Identity.Utils;
using Microsoft.AspNetCore.Mvc;
using NodaTime;
@@ -14,18 +15,20 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) :
public async Task PostToken([FromBody] PostTokenRequest req)
{
var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret);
+ var appScopes = app.Scopes.ExpandScopes();
var scopes = req.Scope.Split(' ');
- if (app.Scopes.Except(scopes).Any())
+ if (scopes.Except(appScopes).Any())
{
- throw new ApiError.BadRequest("Invalid or unauthorized scopes");
+ throw new ApiError.Forbidden("Invalid or unauthorized scopes", scopes.Except(appScopes));
}
switch (req.GrantType)
{
- case "client_credentials":
+ case OauthUtils.ClientCredentials:
return await HandleClientCredentialsAsync(app, scopes);
- case "authorization_code":
+ case OauthUtils.AuthorizationCode:
+ // TODO
break;
default:
throw new ApiError.BadRequest("Unknown grant_type");
@@ -38,16 +41,9 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) :
{
// TODO: make this configurable
var expiry = clock.GetCurrentInstant() + Duration.FromDays(365);
- var (token, hash) = Token.Generate();
- var tokenObj = new Token
- {
- Hash = hash,
- Scopes = scopes,
- Expires = expiry,
- ApplicationId = app.Id
- };
+ var (token, tokenObj) = Token.Create(null, app, scopes, expiry);
- await db.AddAsync(tokenObj);
+ db.Add(tokenObj);
await db.SaveChangesAsync();
logger.Debug("Created token with scopes {Scopes} for application {ApplicationId}", scopes, app.Id);
diff --git a/Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs b/Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs
new file mode 100644
index 0000000..9b5a8d4
--- /dev/null
+++ b/Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs
@@ -0,0 +1,21 @@
+using Foxchat.Core.Federation;
+using Foxchat.Core.Models;
+using Foxchat.Core.Models.Http;
+using Foxchat.Identity.Middleware;
+using Foxchat.Identity.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Foxchat.Identity.Controllers.Proxy;
+
+[Route("/_fox/proxy/guilds")]
+public class GuildsProxyController(
+ ILogger logger,
+ ChatInstanceResolverService chatInstanceResolverService,
+ RequestSigningService requestSigningService)
+ : ProxyControllerBase(logger, chatInstanceResolverService, requestSigningService)
+{
+ [Authorize("chat_client")]
+ [HttpPost]
+ public Task CreateGuild([FromBody] GuildsApi.CreateGuildRequest req) =>
+ Proxy(HttpMethod.Post, req);
+}
\ No newline at end of file
diff --git a/Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs b/Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs
new file mode 100644
index 0000000..4728ca4
--- /dev/null
+++ b/Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs
@@ -0,0 +1,38 @@
+using Foxchat.Core;
+using Foxchat.Core.Federation;
+using Foxchat.Identity.Middleware;
+using Foxchat.Identity.Services;
+using Microsoft.AspNetCore.Mvc;
+
+namespace Foxchat.Identity.Controllers.Proxy;
+
+[ApiController]
+[ClientAuthenticate]
+public class ProxyControllerBase(
+ ILogger logger,
+ ChatInstanceResolverService chatInstanceResolverService,
+ RequestSigningService requestSigningService) : ControllerBase
+{
+ internal async Task Proxy(HttpMethod method, object? body = null) where TResponse : class
+ {
+ var acct = HttpContext.GetAccountOrThrow();
+
+ var path = HttpContext.Request.Path.ToString();
+ if (!path.StartsWith("/_fox/proxy"))
+ throw new FoxchatError("Proxy used for endpoint that does not start with /_fox/proxy");
+ path = $"/_fox/chat/{path[12..]}";
+
+ if (!HttpContext.Request.Headers.TryGetValue(RequestSigningService.SERVER_HEADER, out var serverHeader))
+ throw new ApiError.BadRequest($"Invalid or missing {RequestSigningService.SERVER_HEADER} header.");
+ var server = serverHeader.ToString();
+
+ logger.Debug("Proxying {Method} request to {Domain}{Path}", method, server, path);
+
+ // Identity instances always initiate federation, so we have to make sure the instance knows about us.
+ // This also serves as a way to make sure the instance being requested actually exists.
+ await chatInstanceResolverService.ResolveChatInstanceAsync(serverHeader.ToString());
+
+ var resp = await requestSigningService.RequestAsync(method, server, path, acct.Id.ToString(), body);
+ return Ok(resp);
+ }
+}
\ No newline at end of file
diff --git a/Foxchat.Identity/Controllers/UsersController.cs b/Foxchat.Identity/Controllers/UsersController.cs
new file mode 100644
index 0000000..6302183
--- /dev/null
+++ b/Foxchat.Identity/Controllers/UsersController.cs
@@ -0,0 +1,52 @@
+using Foxchat.Core;
+using Foxchat.Core.Models;
+using Foxchat.Identity.Database;
+using Foxchat.Identity.Database.Models;
+using Foxchat.Identity.Middleware;
+using Foxchat.Identity.Utils;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Newtonsoft.Json;
+
+namespace Foxchat.Identity.Controllers;
+
+[ApiController]
+[ClientAuthenticate]
+[Route("/_fox/ident/users")]
+public class UsersController(ILogger logger, InstanceConfig config, IdentityContext db) : ControllerBase
+{
+ [HttpGet("{id}")]
+ public async Task GetUser(Ulid id)
+ {
+ var user = await db.Accounts.FirstOrDefaultAsync(a => a.Id == id);
+ if (user == null) throw new ApiError.NotFound("User not found.");
+
+ return Ok(new Users.User(user.Id.ToString(), user.Username, config.Domain, null));
+ }
+
+ [HttpGet("@me")]
+ [Authorize("identify")]
+ public IActionResult GetMe()
+ {
+ var acct = HttpContext.GetAccountOrThrow();
+ var token = HttpContext.GetToken()!;
+ var showEmail = token.Scopes.ExpandScopes().Contains("email");
+
+ return Ok(new MeUser(
+ acct.Id,
+ acct.Username,
+ acct.Role,
+ null,
+ showEmail ? acct.Email : null
+ ));
+ }
+
+ public record MeUser(
+ Ulid Id,
+ string Username,
+ Account.AccountRole Role,
+ string? AvatarUrl,
+ [JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
+ string? Email
+ );
+}
\ No newline at end of file
diff --git a/Foxchat.Identity/Database/IdentityContext.cs b/Foxchat.Identity/Database/IdentityContext.cs
index b8c7bca..efc4850 100644
--- a/Foxchat.Identity/Database/IdentityContext.cs
+++ b/Foxchat.Identity/Database/IdentityContext.cs
@@ -1,5 +1,6 @@
using Foxchat.Core;
using Foxchat.Core.Database;
+using Foxchat.Core.Extensions;
using Foxchat.Identity.Database.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
@@ -10,15 +11,16 @@ namespace Foxchat.Identity.Database;
public class IdentityContext : IDatabaseContext
{
private readonly NpgsqlDataSource _dataSource;
+ private readonly ILoggerFactory? _loggerFactory;
+ public override DbSet Instance { get; set; }
public DbSet Accounts { get; set; }
public DbSet ChatInstances { get; set; }
- public override DbSet Instance { get; set; }
public DbSet Applications { get; set; }
public DbSet Tokens { get; set; }
public DbSet GuildAccounts { get; set; }
- public IdentityContext(InstanceConfig config)
+ public IdentityContext(InstanceConfig config, ILoggerFactory? loggerFactory)
{
var connString = new NpgsqlConnectionStringBuilder(config.Database.Url)
{
@@ -29,12 +31,14 @@ public class IdentityContext : IDatabaseContext
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString);
dataSourceBuilder.UseNodaTime();
_dataSource = dataSourceBuilder.Build();
+ _loggerFactory = loggerFactory;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
- => optionsBuilder
- .UseNpgsql(_dataSource, o => o.UseNodaTime())
- .UseSnakeCaseNamingConvention();
+ => optionsBuilder
+ .UseNpgsql(_dataSource, o => o.UseNodaTime())
+ .UseSnakeCaseNamingConvention()
+ .UseLoggerFactory(_loggerFactory);
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
@@ -55,6 +59,7 @@ public class IdentityContext : IDatabaseContext
}
}
+// ReSharper disable once UnusedType.Global
public class DesignTimeIdentityContextFactory : IDesignTimeDbContextFactory
{
public IdentityContext CreateDbContext(string[] args)
@@ -66,6 +71,6 @@ public class DesignTimeIdentityContextFactory : IDesignTimeDbContextFactory() ?? new();
- return new IdentityContext(config);
+ return new IdentityContext(config, null);
}
}
diff --git a/Foxchat.Identity/Database/Models/Token.cs b/Foxchat.Identity/Database/Models/Token.cs
index 1ecbfb3..4a0c2d4 100644
--- a/Foxchat.Identity/Database/Models/Token.cs
+++ b/Foxchat.Identity/Database/Models/Token.cs
@@ -24,4 +24,17 @@ public class Token : BaseModel
return (token, hash);
}
+
+ public static (string, Token) Create(Account? account, Application application, string[] scopes, Instant expires)
+ {
+ var (token, hash) = Generate();
+ return (token, new()
+ {
+ Hash = hash,
+ Scopes = scopes,
+ Expires = expires,
+ Account = account,
+ Application = application,
+ });
+ }
}
diff --git a/Foxchat.Identity/Extensions/WebApplicationExtensions.cs b/Foxchat.Identity/Extensions/WebApplicationExtensions.cs
index 510a7c4..63628ce 100644
--- a/Foxchat.Identity/Extensions/WebApplicationExtensions.cs
+++ b/Foxchat.Identity/Extensions/WebApplicationExtensions.cs
@@ -1,4 +1,5 @@
-using Foxchat.Identity.Authorization;
+using Foxchat.Core.Middleware;
+using Foxchat.Identity.Middleware;
namespace Foxchat.Identity.Extensions;
@@ -7,14 +8,16 @@ public static class WebApplicationExtensions
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services)
{
return services
- .AddScoped()
- .AddScoped();
+ .AddScoped()
+ .AddScoped()
+ .AddScoped();
}
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app)
{
return app
- .UseMiddleware()
- .UseMiddleware();
+ .UseMiddleware()
+ .UseMiddleware()
+ .UseMiddleware();
}
}
diff --git a/Foxchat.Identity/Authorization/AuthenticationMiddleware.cs b/Foxchat.Identity/Middleware/ClientAuthenticationMiddleware.cs
similarity index 79%
rename from Foxchat.Identity/Authorization/AuthenticationMiddleware.cs
rename to Foxchat.Identity/Middleware/ClientAuthenticationMiddleware.cs
index 8b89399..4ad5b2c 100644
--- a/Foxchat.Identity/Authorization/AuthenticationMiddleware.cs
+++ b/Foxchat.Identity/Middleware/ClientAuthenticationMiddleware.cs
@@ -6,9 +6,9 @@ using Foxchat.Identity.Database.Models;
using Microsoft.EntityFrameworkCore;
using NodaTime;
-namespace Foxchat.Identity.Authorization;
+namespace Foxchat.Identity.Middleware;
-public class AuthenticationMiddleware(
+public class ClientAuthenticationMiddleware(
IdentityContext db,
IClock clock
) : IMiddleware
@@ -16,7 +16,7 @@ public class AuthenticationMiddleware(
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
var endpoint = ctx.GetEndpoint();
- var metadata = endpoint?.Metadata.GetMetadata();
+ var metadata = endpoint?.Metadata.GetMetadata();
if (metadata == null)
{
@@ -71,7 +71,14 @@ public static class HttpContextExtensions
return token as Token;
return null;
}
+
+ public static Application GetApplicationOrThrow(this HttpContext context)
+ {
+ var token = context.GetToken();
+ if (token is not { Account: null }) throw new ApiError.Forbidden("This endpoint requires a client token.");
+ return token.Application;
+ }
}
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
-public class AuthenticateAttribute : Attribute;
+public class ClientAuthenticateAttribute : Attribute;
diff --git a/Foxchat.Identity/Authorization/AuthorizationMiddleware.cs b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs
similarity index 75%
rename from Foxchat.Identity/Authorization/AuthorizationMiddleware.cs
rename to Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs
index 2bb8203..2e6499d 100644
--- a/Foxchat.Identity/Authorization/AuthorizationMiddleware.cs
+++ b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs
@@ -1,11 +1,11 @@
-using System.Net;
using Foxchat.Core;
using Foxchat.Identity.Database;
+using Foxchat.Identity.Utils;
using NodaTime;
-namespace Foxchat.Identity.Authorization;
+namespace Foxchat.Identity.Middleware;
-public class AuthorizationMiddleware(
+public class ClientAuthorizationMiddleware(
IdentityContext db,
IClock clock
) : IMiddleware
@@ -22,10 +22,10 @@ public class AuthorizationMiddleware(
}
var token = ctx.GetToken();
- if (token == null || token.Expires > clock.GetCurrentInstant())
+ if (token == null || token.Expires < clock.GetCurrentInstant())
throw new ApiError.Unauthorized("This endpoint requires an authenticated user.");
- if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes).Any())
- throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.Scopes));
+ 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);
}
diff --git a/Foxchat.Identity/Program.cs b/Foxchat.Identity/Program.cs
index ae53e12..035feb1 100644
--- a/Foxchat.Identity/Program.cs
+++ b/Foxchat.Identity/Program.cs
@@ -1,20 +1,31 @@
using Newtonsoft.Json.Serialization;
using Serilog;
using Foxchat.Core;
+using Foxchat.Core.Extensions;
using Foxchat.Identity;
using Foxchat.Identity.Database;
using Foxchat.Identity.Services;
using Foxchat.Identity.Extensions;
+using Newtonsoft.Json;
var builder = WebApplication.CreateBuilder(args);
var config = builder.AddConfiguration("identity.ini");
-builder.AddSerilog(config.LogEventLevel);
+builder.AddSerilog();
await BuildInfo.ReadBuildInfo();
Log.Information("Starting Foxchat.Identity {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash);
+// Set the default converter to snake case as we use it in a couple places.
+JsonConvert.DefaultSettings = () => new JsonSerializerSettings
+{
+ ContractResolver = new DefaultContractResolver
+ {
+ NamingStrategy = new SnakeCaseNamingStrategy()
+ }
+};
+
builder.Services
.AddControllers()
.AddNewtonsoftJson(options =>
@@ -41,7 +52,7 @@ app.UseCustomMiddleware();
app.MapControllers();
using (var scope = app.Services.CreateScope())
-using (var context = scope.ServiceProvider.GetRequiredService())
+await using (var context = scope.ServiceProvider.GetRequiredService())
{
Log.Information("Initializing instance keypair...");
if (await context.InitializeInstanceAsync())
diff --git a/Foxchat.Identity/Services/ChatInstanceResolverService.cs b/Foxchat.Identity/Services/ChatInstanceResolverService.cs
index 686bfd0..0881321 100644
--- a/Foxchat.Identity/Services/ChatInstanceResolverService.cs
+++ b/Foxchat.Identity/Services/ChatInstanceResolverService.cs
@@ -31,7 +31,7 @@ public class ChatInstanceResolverService(ILogger logger, RequestSigningService r
PublicKey = resp.PublicKey,
Status = ChatInstance.InstanceStatus.Active,
};
- await db.AddAsync(instance);
+ db.Add(instance);
await db.SaveChangesAsync();
return instance;
diff --git a/Foxchat.Identity/Utils/OauthUtils.cs b/Foxchat.Identity/Utils/OauthUtils.cs
index 16256ce..d6d5b2c 100644
--- a/Foxchat.Identity/Utils/OauthUtils.cs
+++ b/Foxchat.Identity/Utils/OauthUtils.cs
@@ -1,8 +1,15 @@
+using Foxchat.Core;
+using Foxchat.Identity.Middleware;
+using Foxchat.Identity.Database.Models;
+
namespace Foxchat.Identity.Utils;
public static class OauthUtils
{
- public static readonly string[] Scopes = ["identify", "chat_client"];
+ public const string ClientCredentials = "client_credentials";
+ public const string AuthorizationCode = "authorization_code";
+
+ public static readonly string[] Scopes = ["identify", "email", "guilds", "chat_client"];
private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"];
private const string OobUri = "urn:ietf:wg:oauth:2.0:oob";
@@ -20,4 +27,8 @@ public static class OauthUtils
return false;
}
}
-}
+
+ public static string[] ExpandScopes(this string[] scopes) => scopes.Contains("chat_client")
+ ? Scopes
+ : scopes;
+}
\ No newline at end of file
diff --git a/Foxchat.Identity/identity.ini b/Foxchat.Identity/identity.ini
index d4b3c40..7f4172c 100644
--- a/Foxchat.Identity/identity.ini
+++ b/Foxchat.Identity/identity.ini
@@ -2,11 +2,6 @@ Host = localhost
Port = 7611
Domain = id.fox.localhost
-; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal
-LogEventLevel = Debug
-; Optional logging to Seq
-SeqLogUrl = http://localhost:5341
-
[Database]
; The database URL in ADO.NET format.
Url = "Host=localhost;Database=foxchat_cs_ident;Username=foxchat;Password=password"
@@ -15,3 +10,11 @@ Url = "Host=localhost;Database=foxchat_cs_ident;Username=foxchat;Password=passwo
Timeout = 5
; The maximum number of open connections. Defaults to 50.
MaxPoolSize = 500
+
+[Logging]
+; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal
+LogEventLevel = Debug
+; Whether to log SQL queries.
+LogQueries = true
+; Optional logging to Seq
+SeqLogUrl = http://localhost:5341