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/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..efbe8a1 100644
--- a/Foxchat.Chat/Database/ChatContext.cs
+++ b/Foxchat.Chat/Database/ChatContext.cs
@@ -1,3 +1,4 @@
+using Foxchat.Chat.Database.Models;
using Foxchat.Core;
using Foxchat.Core.Database;
using Microsoft.EntityFrameworkCore;
@@ -11,6 +12,11 @@ public class ChatContext : IDatabaseContext
private readonly NpgsqlDataSource _dataSource;
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)
{
@@ -26,9 +32,9 @@ public class ChatContext : IDatabaseContext
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
- => optionsBuilder
- .UseNpgsql(_dataSource, o => o.UseNodaTime())
- .UseSnakeCaseNamingConvention();
+ => optionsBuilder
+ .UseNpgsql(_dataSource, o => o.UseNodaTime())
+ .UseSnakeCaseNamingConvention();
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
@@ -38,20 +44,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);
}
-}
+}
\ 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..18a7054
--- /dev/null
+++ b/Foxchat.Chat/Database/Models/User.cs
@@ -0,0 +1,14 @@
+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 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..4900ba9
--- /dev/null
+++ b/Foxchat.Chat/Extensions/WebApplicationExtensions.cs
@@ -0,0 +1,18 @@
+using Foxchat.Chat.Middleware;
+
+namespace Foxchat.Chat.Extensions;
+
+public static class WebApplicationExtensions
+{
+ public static IServiceCollection AddCustomMiddleware(this IServiceCollection services)
+ {
+ return services
+ .AddScoped();
+ }
+
+ public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app)
+ {
+ return app
+ .UseMiddleware();
+ }
+}
\ No newline at end of file
diff --git a/Foxchat.Chat/Middleware/AuthenticationMiddleware.cs b/Foxchat.Chat/Middleware/AuthenticationMiddleware.cs
new file mode 100644
index 0000000..13955bd
--- /dev/null
+++ b/Foxchat.Chat/Middleware/AuthenticationMiddleware.cs
@@ -0,0 +1,106 @@
+using Foxchat.Chat.Database;
+using Foxchat.Chat.Database.Models;
+using Foxchat.Core;
+using Foxchat.Core.Federation;
+using Microsoft.EntityFrameworkCore;
+
+namespace Foxchat.Chat.Middleware;
+
+public class AuthenticationMiddleware(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 (!ExtractRequestData(ctx, 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.");
+ }
+
+ private bool ExtractRequestData(HttpContext ctx, out string signature, out string domain, out SignatureData data)
+ {
+ signature = string.Empty;
+ domain = string.Empty;
+ data = SignatureData.Empty;
+
+ 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;
+ }
+}
+
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+public class UnauthenticatedAttribute : 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!;
+ }
+}
\ 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/ChatContextModelSnapshot.cs b/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs
new file mode 100644
index 0000000..f560113
--- /dev/null
+++ b/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs
@@ -0,0 +1,322 @@
+//
+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("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..11ab291 100644
--- a/Foxchat.Chat/Program.cs
+++ b/Foxchat.Chat/Program.cs
@@ -3,6 +3,7 @@ using Serilog;
using Foxchat.Core;
using Foxchat.Chat;
using Foxchat.Chat.Database;
+using Newtonsoft.Json;
var builder = WebApplication.CreateBuilder(args);
@@ -13,6 +14,15 @@ builder.AddSerilog(config.LogEventLevel);
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 =>
@@ -38,7 +48,7 @@ app.UseAuthorization();
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.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/Federation/RequestSigningService.cs b/Foxchat.Core/Federation/RequestSigningService.cs
index 07b0536..54ec6bf 100644
--- a/Foxchat.Core/Federation/RequestSigningService.cs
+++ b/Foxchat.Core/Federation/RequestSigningService.cs
@@ -31,23 +31,22 @@ 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)
{
var rsa = RSA.Create();
rsa.ImportFromPem(publicKey);
var now = _clock.GetCurrentInstant();
- var time = ParseTime(dateHeader);
- if ((now + Duration.FromMinutes(1)) < time)
+ if ((now + Duration.FromMinutes(1)) < data.Time)
{
throw new ApiError.IncomingFederationError("Request was made in the future");
}
- else if ((now - Duration.FromMinutes(1)) > time)
+ else if ((now - Duration.FromMinutes(1)) > data.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);
@@ -73,5 +72,5 @@ public partial class RequestSigningService(ILogger logger, IClock clock, IDataba
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();
}
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.Identity/Database/IdentityContext.cs b/Foxchat.Identity/Database/IdentityContext.cs
index b8c7bca..e983fac 100644
--- a/Foxchat.Identity/Database/IdentityContext.cs
+++ b/Foxchat.Identity/Database/IdentityContext.cs
@@ -11,9 +11,9 @@ public class IdentityContext : IDatabaseContext
{
private readonly NpgsqlDataSource _dataSource;
+ 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; }
@@ -55,6 +55,7 @@ public class IdentityContext : IDatabaseContext
}
}
+// ReSharper disable once UnusedType.Global
public class DesignTimeIdentityContextFactory : IDesignTimeDbContextFactory
{
public IdentityContext CreateDbContext(string[] args)
diff --git a/Foxchat.Identity/Program.cs b/Foxchat.Identity/Program.cs
index 782ca94..8e0c47e 100644
--- a/Foxchat.Identity/Program.cs
+++ b/Foxchat.Identity/Program.cs
@@ -51,7 +51,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())