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