From f6629fbb3361634bab002e47607c742c01b64e84 Mon Sep 17 00:00:00 2001 From: sam Date: Sat, 11 May 2024 15:26:47 +0200 Subject: [PATCH] init --- .editorconfig | 4 + .gitignore | 3 + Foxchat.Core/BuildInfo.cs | 24 ++ Foxchat.Core/CoreConfig.cs | 24 ++ Foxchat.Core/Database/IDatabaseContext.cs | 44 +++ Foxchat.Core/Database/Instance.cs | 8 + .../RequestSigningService.Client.cs | 72 +++++ .../Federation/RequestSigningService.cs | 78 ++++++ Foxchat.Core/Federation/SignatureData.cs | 11 + Foxchat.Core/Foxchat.Core.csproj | 27 ++ Foxchat.Core/Models/Hello.cs | 6 + Foxchat.Core/ServiceCollectionExtensions.cs | 75 ++++++ Foxchat.Core/UlidConverter.cs | 10 + .../Authorization/AuthenticationHandler.cs | 9 + .../Controllers/NodeController.cs | 27 ++ Foxchat.Identity/Database/IdentityContext.cs | 63 +++++ Foxchat.Identity/Database/Models/Account.cs | 21 ++ .../Database/Models/ChatInstance.cs | 19 ++ .../Database/Models/GuildAccount.cs | 10 + Foxchat.Identity/Database/Models/Token.cs | 8 + Foxchat.Identity/Foxchat.Identity.csproj | 29 ++ Foxchat.Identity/GlobalUsing.cs | 3 + Foxchat.Identity/InstanceConfig.cs | 7 + .../20240512225835_Init.Designer.cs | 253 ++++++++++++++++++ .../Migrations/20240512225835_Init.cs | 181 +++++++++++++ .../IdentityContextModelSnapshot.cs | 250 +++++++++++++++++ Foxchat.Identity/Program.cs | 55 ++++ .../Services/ChatInstanceResolverService.cs | 39 +++ Foxchat.Identity/identity.ini | 15 ++ Foxchat.sln | 28 ++ LICENSE | 201 ++++++++++++++ build_info.sh | 4 + 32 files changed, 1608 insertions(+) create mode 100644 .editorconfig create mode 100644 .gitignore create mode 100644 Foxchat.Core/BuildInfo.cs create mode 100644 Foxchat.Core/CoreConfig.cs create mode 100644 Foxchat.Core/Database/IDatabaseContext.cs create mode 100644 Foxchat.Core/Database/Instance.cs create mode 100644 Foxchat.Core/Federation/RequestSigningService.Client.cs create mode 100644 Foxchat.Core/Federation/RequestSigningService.cs create mode 100644 Foxchat.Core/Federation/SignatureData.cs create mode 100644 Foxchat.Core/Foxchat.Core.csproj create mode 100644 Foxchat.Core/Models/Hello.cs create mode 100644 Foxchat.Core/ServiceCollectionExtensions.cs create mode 100644 Foxchat.Core/UlidConverter.cs create mode 100644 Foxchat.Identity/Authorization/AuthenticationHandler.cs create mode 100644 Foxchat.Identity/Controllers/NodeController.cs create mode 100644 Foxchat.Identity/Database/IdentityContext.cs create mode 100644 Foxchat.Identity/Database/Models/Account.cs create mode 100644 Foxchat.Identity/Database/Models/ChatInstance.cs create mode 100644 Foxchat.Identity/Database/Models/GuildAccount.cs create mode 100644 Foxchat.Identity/Database/Models/Token.cs create mode 100644 Foxchat.Identity/Foxchat.Identity.csproj create mode 100644 Foxchat.Identity/GlobalUsing.cs create mode 100644 Foxchat.Identity/InstanceConfig.cs create mode 100644 Foxchat.Identity/Migrations/20240512225835_Init.Designer.cs create mode 100644 Foxchat.Identity/Migrations/20240512225835_Init.cs create mode 100644 Foxchat.Identity/Migrations/IdentityContextModelSnapshot.cs create mode 100644 Foxchat.Identity/Program.cs create mode 100644 Foxchat.Identity/Services/ChatInstanceResolverService.cs create mode 100644 Foxchat.Identity/identity.ini create mode 100644 Foxchat.sln create mode 100644 LICENSE create mode 100755 build_info.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9fec5aa --- /dev/null +++ b/.editorconfig @@ -0,0 +1,4 @@ +[*.cs] + +# CS9113: Parameter is unread. +dotnet_diagnostic.CS9113.severity = silent diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cd1b080 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +bin/ +obj/ +.version diff --git a/Foxchat.Core/BuildInfo.cs b/Foxchat.Core/BuildInfo.cs new file mode 100644 index 0000000..4d620b9 --- /dev/null +++ b/Foxchat.Core/BuildInfo.cs @@ -0,0 +1,24 @@ +namespace Foxchat.Core; + +public static class BuildInfo +{ + public static string Hash { get; private set; } = "(unknown)"; + public static string Version { get; private set; } = "(unknown)"; + + public static async Task ReadBuildInfo() + { + await using var stream = typeof(BuildInfo).Assembly.GetManifestResourceStream("version"); + if (stream == null) return; + + using var reader = new StreamReader(stream); + var data = (await reader.ReadToEndAsync()).Trim().Split("\n"); + + Hash = data[0]; + var dirty = data[2] == "dirty"; + + var versionData = data[1].Split("-"); + Version = versionData[0]; + if (versionData[1] != "0" || dirty) Version += $"+{versionData[2]}"; + if (dirty) Version += ".dirty"; + } +} diff --git a/Foxchat.Core/CoreConfig.cs b/Foxchat.Core/CoreConfig.cs new file mode 100644 index 0000000..39e784c --- /dev/null +++ b/Foxchat.Core/CoreConfig.cs @@ -0,0 +1,24 @@ +using Serilog.Events; + +namespace Foxchat.Core; + +public class CoreConfig +{ + public string Host { get; set; } = "localhost"; + public int Port { get; set; } = 3000; + public bool Secure { get; set; } = false; + public string Domain { get; set; } = null!; + + public string Address => $"{(Secure ? "https" : "http")}://{Host}:{Port}"; + + public LogEventLevel LogEventLevel { get; set; } = LogEventLevel.Debug; + + public DatabaseConfig Database { get; set; } = new(); + + public class DatabaseConfig + { + public string Url { get; set; } = string.Empty; + public int? Timeout { get; set; } + public int? MaxPoolSize { get; set; } + } +} diff --git a/Foxchat.Core/Database/IDatabaseContext.cs b/Foxchat.Core/Database/IDatabaseContext.cs new file mode 100644 index 0000000..be8691d --- /dev/null +++ b/Foxchat.Core/Database/IDatabaseContext.cs @@ -0,0 +1,44 @@ +using System.Security.Cryptography; +using Microsoft.EntityFrameworkCore; + +namespace Foxchat.Core.Database; + +public abstract class IDatabaseContext : DbContext +{ + public virtual DbSet Instance { get; set; } + + public async ValueTask InitializeInstanceAsync(CancellationToken ct = default) + { + var instance = await Instance.Where(i => i.Id == 1).FirstOrDefaultAsync(ct); + if (instance != null) return false; + + var rsa = RSA.Create(); + var publicKey = rsa.ExportRSAPublicKeyPem(); + var privateKey = rsa.ExportRSAPrivateKeyPem(); + + await Instance.AddAsync(new Instance + { + PublicKey = publicKey!, + PrivateKey = privateKey!, + }, ct); + + await SaveChangesAsync(ct); + return true; + } + + public async Task GetInstanceAsync(CancellationToken ct = default) + { + var instance = await Instance.FirstOrDefaultAsync(ct) + ?? throw new Exception("GetInstanceAsync called without Instance being initialized"); // TODO: replace this with specific exception type + return instance; + } + + public async Task GetInstanceKeysAsync(CancellationToken ct = default) + { + var instance = await GetInstanceAsync(ct); + + var rsa = RSA.Create(); + rsa.ImportFromPem(instance.PrivateKey); + return rsa; + } +} diff --git a/Foxchat.Core/Database/Instance.cs b/Foxchat.Core/Database/Instance.cs new file mode 100644 index 0000000..1da3697 --- /dev/null +++ b/Foxchat.Core/Database/Instance.cs @@ -0,0 +1,8 @@ +namespace Foxchat.Core.Database; + +public class Instance +{ + public int Id { get; init; } + public string PublicKey { get; set; } = null!; + public string PrivateKey { get; set; } = null!; +} diff --git a/Foxchat.Core/Federation/RequestSigningService.Client.cs b/Foxchat.Core/Federation/RequestSigningService.Client.cs new file mode 100644 index 0000000..090777d --- /dev/null +++ b/Foxchat.Core/Federation/RequestSigningService.Client.cs @@ -0,0 +1,72 @@ +using System.Net.Http.Headers; +using Newtonsoft.Json; +using Newtonsoft.Json.Serialization; + +namespace Foxchat.Core.Federation; + +public partial class RequestSigningService +{ + public const string USER_AGENT_HEADER = "User-Agent"; + public const string USER_AGENT = "Foxchat.NET"; + public const string DATE_HEADER = "Date"; + public const string CONTENT_LENGTH_HEADER = "Content-Length"; + public const string CONTENT_TYPE_HEADER = "Content-Type"; + public const string CONTENT_TYPE = "application/json; charset=utf-8"; + + public const string SERVER_HEADER = "X-Foxchat-Server"; + 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); + _logger.Debug("Content length in header: '{ContentLength}'", request.Headers.Where(c => c.Key == "Content-Length")); + var resp = await _httpClient.SendAsync(request); + if (!resp.IsSuccessStatusCode) + { + var error = await resp.Content.ReadAsStringAsync(); + _logger.Error("Received {Status}, body: {Error}", resp.StatusCode, error); + // TODO: replace this with specific exception type + throw new Exception("oh no a request error"); + } + + var bodyString = await resp.Content.ReadAsStringAsync(); + // TODO: replace this with specific exception type + return DeserializeObject(bodyString) ?? throw new Exception("oh no invalid json"); + } + + private HttpRequestMessage BuildHttpRequest(HttpMethod method, string domain, string requestPath, string? userId = null, object? bodyData = null) + { + var body = bodyData != null ? SerializeObject(bodyData) : null; + + var now = _clock.GetCurrentInstant(); + var url = $"https://{domain}{requestPath}"; + var signature = GenerateSignature(new SignatureData(now, domain, requestPath, body?.Length, userId)); + + var request = new HttpRequestMessage(method, url); + request.Headers.Clear(); + request.Headers.Add(USER_AGENT_HEADER, USER_AGENT); + request.Headers.Add(DATE_HEADER, FormatTime(now)); + request.Headers.Add(SERVER_HEADER, _config.Domain); + request.Headers.Add(SIGNATURE_HEADER, signature); + if (userId != null) + request.Headers.Add(USER_HEADER, userId); + if (body != null) + { + request.Content = new StringContent(body, new MediaTypeHeaderValue("application/json", "utf-8")); + } + + 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 new file mode 100644 index 0000000..88e7bbd --- /dev/null +++ b/Foxchat.Core/Federation/RequestSigningService.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using Foxchat.Core.Database; +using Microsoft.AspNetCore.WebUtilities; +using NodaTime; +using NodaTime.Text; +using Serilog; + +namespace Foxchat.Core.Federation; + +public partial class RequestSigningService(ILogger logger, IClock clock, IDatabaseContext context, CoreConfig config) +{ + private readonly ILogger _logger = logger.ForContext(); + private readonly IClock _clock = clock; + private readonly CoreConfig _config = config; + private readonly RSA _rsa = context.GetInstanceKeysAsync().GetAwaiter().GetResult(); + private readonly HttpClient _httpClient = new(); + + public string GenerateSignature(SignatureData data) + { + var plaintext = GeneratePlaintext(data); + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var hash = SHA256.HashData(plaintextBytes); + + var formatter = new RSAPKCS1SignatureFormatter(_rsa); + formatter.SetHashAlgorithm(nameof(SHA256)); + var signature = formatter.CreateSignature(hash); + + _logger.Debug("Generated signature for {Host} {RequestPath}: {Signature}", data.Host, data.RequestPath, WebEncoders.Base64UrlEncode(signature)); + return WebEncoders.Base64UrlEncode(signature); + } + + public bool VerifySignature( + string publicKey, string encodedSignature, string dateHeader, string host, string requestPath, int? contentLength, string? userId) + { + var rsa = RSA.Create(); + rsa.ImportFromPem(publicKey); + + var now = _clock.GetCurrentInstant(); + var time = ParseTime(dateHeader); + if ((now + Duration.FromMinutes(1)) < time) + { + // TODO: replace this with specific exception type + throw new Exception("Request was made in the future"); + } + else if ((now - Duration.FromMinutes(1)) > time) + { + // TODO: replace this with specific exception type + throw new Exception("Request was made too long ago"); + } + + var plaintext = GeneratePlaintext(new SignatureData(time, host, requestPath, contentLength, userId)); + var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); + var hash = SHA256.HashData(plaintextBytes); + var signature = WebEncoders.Base64UrlDecode(encodedSignature); + + var deformatter = new RSAPKCS1SignatureDeformatter(rsa); + deformatter.SetHashAlgorithm(nameof(SHA256)); + + return deformatter.VerifySignature(hash, signature); + } + + private static string GeneratePlaintext(SignatureData data) + { + var time = FormatTime(data.Time); + var contentLength = data.ContentLength != null ? data.ContentLength.ToString() : ""; + var userId = data.UserId ?? ""; + + Log.Information("Plaintext string: {Plaintext}", $"{time}:{data.Host}:{data.RequestPath}:{contentLength}:{userId}"); + + 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 string FormatTime(Instant time) => _pattern.Format(time); + private static Instant ParseTime(string header) => _pattern.Parse(header).GetValueOrThrow(); +} diff --git a/Foxchat.Core/Federation/SignatureData.cs b/Foxchat.Core/Federation/SignatureData.cs new file mode 100644 index 0000000..a8d95be --- /dev/null +++ b/Foxchat.Core/Federation/SignatureData.cs @@ -0,0 +1,11 @@ +using NodaTime; + +namespace Foxchat.Core.Federation; + +public record SignatureData( + Instant Time, + string Host, + string RequestPath, + int? ContentLength, + string? UserId +) { } diff --git a/Foxchat.Core/Foxchat.Core.csproj b/Foxchat.Core/Foxchat.Core.csproj new file mode 100644 index 0000000..bbf8d72 --- /dev/null +++ b/Foxchat.Core/Foxchat.Core.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + diff --git a/Foxchat.Core/Models/Hello.cs b/Foxchat.Core/Models/Hello.cs new file mode 100644 index 0000000..5d175ef --- /dev/null +++ b/Foxchat.Core/Models/Hello.cs @@ -0,0 +1,6 @@ +namespace Foxchat.Core.Models; + +public record HelloRequest(string Host); +public record HelloResponse(string PublicKey, string Host); +public record NodeInfo(string Software, string PublicKey); +public record NodeSoftware(string Name, string? Version); diff --git a/Foxchat.Core/ServiceCollectionExtensions.cs b/Foxchat.Core/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..4f0dd8a --- /dev/null +++ b/Foxchat.Core/ServiceCollectionExtensions.cs @@ -0,0 +1,75 @@ +using Foxchat.Core.Database; +using Foxchat.Core.Federation; +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using NodaTime; +using Serilog; +using Serilog.Events; +using Serilog.Sinks.SystemConsole.Themes; + +namespace Foxchat.Core; + +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 IServiceCollection AddSerilog(this IServiceCollection services, LogEventLevel level) + { + var logCfg = new LoggerConfiguration() + .Enrich.FromLogContext() + .MinimumLevel.Is(level) + // 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. + .MinimumLevel.Override("Microsoft", LogEventLevel.Information) + .MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning) + .MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning) + .WriteTo.Console(theme: AnsiConsoleTheme.Code); + + Log.Logger = logCfg.CreateLogger(); + + // AddSerilog doesn't seem to add an ILogger to the service collection, so add that manually. + return services.AddSerilog().AddSingleton(Log.Logger); + } + + /// + /// Adds the core Foxchat services to this service collection. + /// + public static IServiceCollection AddCoreServices(this IServiceCollection services) where T : IDatabaseContext + { + services.AddDbContext(); + + // NodaTime recommends only depending on the IClock interface, not the singleton. + services.AddSingleton(SystemClock.Instance); + // Some core services rely on an IDatabaseContext, not the server-specific context type. + services.AddScoped(); + services.AddSingleton(); + + return services; + } + + public static T AddConfiguration(this WebApplicationBuilder builder, string? configFile = null) where T : class, new() + { + + builder.Configuration.Sources.Clear(); + builder.Configuration.AddConfiguration(configFile); + + var config = builder.Configuration.Get() ?? new(); + var coreConfig = builder.Configuration.Get() ?? new(); + builder.Services.AddSingleton(config); + builder.Services.AddSingleton(coreConfig); + return config; + } + + public static IConfigurationBuilder AddConfiguration(this IConfigurationBuilder builder, string? configFile = null) + { + var file = Environment.GetEnvironmentVariable("FOXCHAT_CONFIG_FILE") ?? configFile ?? "config.ini"; + + return builder + .SetBasePath(Directory.GetCurrentDirectory()) + .AddIniFile(file, optional: false, reloadOnChange: true) + .AddEnvironmentVariables(); + } +} diff --git a/Foxchat.Core/UlidConverter.cs b/Foxchat.Core/UlidConverter.cs new file mode 100644 index 0000000..f4af4cd --- /dev/null +++ b/Foxchat.Core/UlidConverter.cs @@ -0,0 +1,10 @@ +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using NUlid; + +namespace Foxchat.Core; + +public class UlidConverter() : ValueConverter( + convertToProviderExpression: x => x.ToGuid(), + convertFromProviderExpression: x => new Ulid(x) +) +{ } diff --git a/Foxchat.Identity/Authorization/AuthenticationHandler.cs b/Foxchat.Identity/Authorization/AuthenticationHandler.cs new file mode 100644 index 0000000..92f2112 --- /dev/null +++ b/Foxchat.Identity/Authorization/AuthenticationHandler.cs @@ -0,0 +1,9 @@ +namespace Foxchat.Identity.Authorization; + +public static class AuthenticationHandlerExtensions +{ + public static void AddAuthenticationHandler(this IServiceCollection services) + { + + } +} \ No newline at end of file diff --git a/Foxchat.Identity/Controllers/NodeController.cs b/Foxchat.Identity/Controllers/NodeController.cs new file mode 100644 index 0000000..b94fa71 --- /dev/null +++ b/Foxchat.Identity/Controllers/NodeController.cs @@ -0,0 +1,27 @@ +using Foxchat.Core.Models; +using Foxchat.Identity.Database; +using Foxchat.Identity.Services; +using Microsoft.AspNetCore.Mvc; + +namespace Foxchat.Identity.Controllers; + +[ApiController] +[Route("/_fox/ident/node")] +public class NodeController(IdentityContext context, ChatInstanceResolverService chatInstanceResolverService) : ControllerBase +{ + public const string SOFTWARE_NAME = "Foxchat.NET.Identity"; + + [HttpGet] + public async Task GetNode() + { + var instance = await context.GetInstanceAsync(); + return Ok(new NodeInfo(SOFTWARE_NAME, instance.PublicKey)); + } + + [HttpGet("{domain}")] + public async Task GetChatNode(string domain) + { + var instance = await chatInstanceResolverService.ResolveChatInstanceAsync(domain); + return Ok(instance); + } +} diff --git a/Foxchat.Identity/Database/IdentityContext.cs b/Foxchat.Identity/Database/IdentityContext.cs new file mode 100644 index 0000000..c276029 --- /dev/null +++ b/Foxchat.Identity/Database/IdentityContext.cs @@ -0,0 +1,63 @@ +using Foxchat.Core; +using Foxchat.Core.Database; +using Foxchat.Identity.Database.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Foxchat.Identity.Database; + +public class IdentityContext : IDatabaseContext +{ + private readonly string _connString; + + public DbSet Accounts { get; set; } + public DbSet ChatInstances { get; set; } + public override DbSet Instance { get; set; } + public DbSet Tokens { get; set; } + public DbSet GuildAccounts { get; set; } + + public IdentityContext(InstanceConfig config) + { + _connString = new Npgsql.NpgsqlConnectionStringBuilder(config.Database.Url) + { + Timeout = config.Database.Timeout ?? 5, + MaxPoolSize = config.Database.MaxPoolSize ?? 50, + }.ConnectionString; + } + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) + => optionsBuilder + .UseNpgsql(_connString) + .UseSnakeCaseNamingConvention(); + + protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder) + { + // ULIDs are stored as UUIDs in the database + configurationBuilder.Properties().HaveConversion(); + } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasIndex(a => a.Username).IsUnique(); + modelBuilder.Entity().HasIndex(a => a.Email).IsUnique(); + + modelBuilder.Entity().HasIndex(i => i.Domain).IsUnique(); + + modelBuilder.Entity().HasKey(g => new { g.ChatInstanceId, g.GuildId, g.AccountId }); + } +} + +public class DesignTimeIdentityContextFactory : IDesignTimeDbContextFactory +{ + public IdentityContext CreateDbContext(string[] args) + { + // Read the configuration file + var config = new ConfigurationBuilder() + .AddConfiguration("identity.ini") + .Build() + // Get the configuration as our config class + .Get() ?? new(); + + return new IdentityContext(config); + } +} diff --git a/Foxchat.Identity/Database/Models/Account.cs b/Foxchat.Identity/Database/Models/Account.cs new file mode 100644 index 0000000..d639e27 --- /dev/null +++ b/Foxchat.Identity/Database/Models/Account.cs @@ -0,0 +1,21 @@ +namespace Foxchat.Identity.Database.Models; + +public class Account +{ + public Ulid Id { get; init; } = Ulid.NewUlid(); + public string Username { get; set; } = null!; + public string Email { get; set; } = null!; + public string Password { get; set; } = null!; + public AccountRole Role { get; set; } + + public string? Avatar { get; set; } + + public List Tokens { get; } = []; + public List ChatInstances { get; } = []; + + public enum AccountRole + { + User, + Admin, + } +} diff --git a/Foxchat.Identity/Database/Models/ChatInstance.cs b/Foxchat.Identity/Database/Models/ChatInstance.cs new file mode 100644 index 0000000..12e572f --- /dev/null +++ b/Foxchat.Identity/Database/Models/ChatInstance.cs @@ -0,0 +1,19 @@ +namespace Foxchat.Identity.Database.Models; + +public class ChatInstance +{ + public Ulid Id { get; init; } = Ulid.NewUlid(); + public string Domain { get; init; } = null!; + public string BaseUrl { get; set; } = null!; + public string PublicKey { get; set; } = null!; + public InstanceStatus Status { get; set; } + public string? Reason { get; set; } + + public List Accounts { get; } = []; + + public enum InstanceStatus + { + Active, + Suspended, + } +} diff --git a/Foxchat.Identity/Database/Models/GuildAccount.cs b/Foxchat.Identity/Database/Models/GuildAccount.cs new file mode 100644 index 0000000..98b647c --- /dev/null +++ b/Foxchat.Identity/Database/Models/GuildAccount.cs @@ -0,0 +1,10 @@ +namespace Foxchat.Identity.Database.Models; + +public class GuildAccount +{ + public Ulid ChatInstanceId { get; init; } + public ChatInstance ChatInstance { get; init; } = null!; + public string GuildId { get; init; } = null!; + public Ulid AccountId { get; init; } + public Account Account { get; init; } = null!; +} \ No newline at end of file diff --git a/Foxchat.Identity/Database/Models/Token.cs b/Foxchat.Identity/Database/Models/Token.cs new file mode 100644 index 0000000..c32678e --- /dev/null +++ b/Foxchat.Identity/Database/Models/Token.cs @@ -0,0 +1,8 @@ +namespace Foxchat.Identity.Database.Models; + +public class Token +{ + public Ulid Id { get; init; } = Ulid.NewUlid(); + public Ulid AccountId { get; set; } + public Account Account { get; set; } = null!; +} \ No newline at end of file diff --git a/Foxchat.Identity/Foxchat.Identity.csproj b/Foxchat.Identity/Foxchat.Identity.csproj new file mode 100644 index 0000000..96ce4a8 --- /dev/null +++ b/Foxchat.Identity/Foxchat.Identity.csproj @@ -0,0 +1,29 @@ + + + + net8.0 + enable + enable + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + diff --git a/Foxchat.Identity/GlobalUsing.cs b/Foxchat.Identity/GlobalUsing.cs new file mode 100644 index 0000000..e9ade6a --- /dev/null +++ b/Foxchat.Identity/GlobalUsing.cs @@ -0,0 +1,3 @@ +global using ILogger = Serilog.ILogger; +global using Log = Serilog.Log; +global using NUlid; diff --git a/Foxchat.Identity/InstanceConfig.cs b/Foxchat.Identity/InstanceConfig.cs new file mode 100644 index 0000000..30483c6 --- /dev/null +++ b/Foxchat.Identity/InstanceConfig.cs @@ -0,0 +1,7 @@ +using Foxchat.Core; + +namespace Foxchat.Identity; + +public class InstanceConfig : CoreConfig +{ +} diff --git a/Foxchat.Identity/Migrations/20240512225835_Init.Designer.cs b/Foxchat.Identity/Migrations/20240512225835_Init.Designer.cs new file mode 100644 index 0000000..70efec8 --- /dev/null +++ b/Foxchat.Identity/Migrations/20240512225835_Init.Designer.cs @@ -0,0 +1,253 @@ +// +using System; +using Foxchat.Identity.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxchat.Identity.Migrations +{ + [DbContext(typeof(IdentityContext))] + [Migration("20240512225835_Init")] + partial class Init + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AccountChatInstance", b => + { + b.Property("AccountsId") + .HasColumnType("uuid") + .HasColumnName("accounts_id"); + + b.Property("ChatInstancesId") + .HasColumnType("uuid") + .HasColumnName("chat_instances_id"); + + b.HasKey("AccountsId", "ChatInstancesId") + .HasName("pk_account_chat_instance"); + + b.HasIndex("ChatInstancesId") + .HasDatabaseName("ix_account_chat_instance_chat_instances_id"); + + b.ToTable("account_chat_instance", (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("Foxchat.Identity.Database.Models.Account", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_accounts_username"); + + b.ToTable("accounts", (string)null); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.ChatInstance", 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_chat_instances"); + + b.HasIndex("Domain") + .IsUnique() + .HasDatabaseName("ix_chat_instances_domain"); + + b.ToTable("chat_instances", (string)null); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.GuildAccount", b => + { + b.Property("ChatInstanceId") + .HasColumnType("uuid") + .HasColumnName("chat_instance_id"); + + b.Property("GuildId") + .HasColumnType("text") + .HasColumnName("guild_id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.HasKey("ChatInstanceId", "GuildId", "AccountId") + .HasName("pk_guild_accounts"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_guild_accounts_account_id"); + + b.ToTable("guild_accounts", (string)null); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.Token", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.HasKey("Id") + .HasName("pk_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_tokens_account_id"); + + b.ToTable("tokens", (string)null); + }); + + modelBuilder.Entity("AccountChatInstance", b => + { + b.HasOne("Foxchat.Identity.Database.Models.Account", null) + .WithMany() + .HasForeignKey("AccountsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_chat_instance_accounts_accounts_id"); + + b.HasOne("Foxchat.Identity.Database.Models.ChatInstance", null) + .WithMany() + .HasForeignKey("ChatInstancesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_chat_instance_chat_instances_chat_instances_id"); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.GuildAccount", b => + { + b.HasOne("Foxchat.Identity.Database.Models.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_accounts_accounts_account_id"); + + b.HasOne("Foxchat.Identity.Database.Models.ChatInstance", "ChatInstance") + .WithMany() + .HasForeignKey("ChatInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_accounts_chat_instances_chat_instance_id"); + + b.Navigation("Account"); + + b.Navigation("ChatInstance"); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.Token", b => + { + b.HasOne("Foxchat.Identity.Database.Models.Account", "Account") + .WithMany("Tokens") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tokens_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.Account", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Foxchat.Identity/Migrations/20240512225835_Init.cs b/Foxchat.Identity/Migrations/20240512225835_Init.cs new file mode 100644 index 0000000..5cc0f69 --- /dev/null +++ b/Foxchat.Identity/Migrations/20240512225835_Init.cs @@ -0,0 +1,181 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxchat.Identity.Migrations +{ + /// + public partial class Init : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "accounts", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + username = table.Column(type: "text", nullable: false), + email = table.Column(type: "text", nullable: false), + password = table.Column(type: "text", nullable: false), + role = table.Column(type: "integer", nullable: false), + avatar = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_accounts", x => x.id); + }); + + migrationBuilder.CreateTable( + name: "chat_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_chat_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: "tokens", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + account_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_tokens", x => x.id); + table.ForeignKey( + name: "fk_tokens_accounts_account_id", + column: x => x.account_id, + principalTable: "accounts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "account_chat_instance", + columns: table => new + { + accounts_id = table.Column(type: "uuid", nullable: false), + chat_instances_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_account_chat_instance", x => new { x.accounts_id, x.chat_instances_id }); + table.ForeignKey( + name: "fk_account_chat_instance_accounts_accounts_id", + column: x => x.accounts_id, + principalTable: "accounts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_account_chat_instance_chat_instances_chat_instances_id", + column: x => x.chat_instances_id, + principalTable: "chat_instances", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "guild_accounts", + columns: table => new + { + chat_instance_id = table.Column(type: "uuid", nullable: false), + guild_id = table.Column(type: "text", nullable: false), + account_id = table.Column(type: "uuid", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("pk_guild_accounts", x => new { x.chat_instance_id, x.guild_id, x.account_id }); + table.ForeignKey( + name: "fk_guild_accounts_accounts_account_id", + column: x => x.account_id, + principalTable: "accounts", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "fk_guild_accounts_chat_instances_chat_instance_id", + column: x => x.chat_instance_id, + principalTable: "chat_instances", + principalColumn: "id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "ix_account_chat_instance_chat_instances_id", + table: "account_chat_instance", + column: "chat_instances_id"); + + migrationBuilder.CreateIndex( + name: "ix_accounts_email", + table: "accounts", + column: "email", + 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_accounts_username ON accounts (lower(username))"); + + migrationBuilder.CreateIndex( + name: "ix_chat_instances_domain", + table: "chat_instances", + column: "domain", + unique: true); + + migrationBuilder.CreateIndex( + name: "ix_guild_accounts_account_id", + table: "guild_accounts", + column: "account_id"); + + migrationBuilder.CreateIndex( + name: "ix_tokens_account_id", + table: "tokens", + column: "account_id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "account_chat_instance"); + + migrationBuilder.DropTable( + name: "guild_accounts"); + + migrationBuilder.DropTable( + name: "instance"); + + migrationBuilder.DropTable( + name: "tokens"); + + migrationBuilder.DropTable( + name: "chat_instances"); + + migrationBuilder.DropTable( + name: "accounts"); + } + } +} diff --git a/Foxchat.Identity/Migrations/IdentityContextModelSnapshot.cs b/Foxchat.Identity/Migrations/IdentityContextModelSnapshot.cs new file mode 100644 index 0000000..4d7184d --- /dev/null +++ b/Foxchat.Identity/Migrations/IdentityContextModelSnapshot.cs @@ -0,0 +1,250 @@ +// +using System; +using Foxchat.Identity.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Foxchat.Identity.Migrations +{ + [DbContext(typeof(IdentityContext))] + partial class IdentityContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("AccountChatInstance", b => + { + b.Property("AccountsId") + .HasColumnType("uuid") + .HasColumnName("accounts_id"); + + b.Property("ChatInstancesId") + .HasColumnType("uuid") + .HasColumnName("chat_instances_id"); + + b.HasKey("AccountsId", "ChatInstancesId") + .HasName("pk_account_chat_instance"); + + b.HasIndex("ChatInstancesId") + .HasDatabaseName("ix_account_chat_instance_chat_instances_id"); + + b.ToTable("account_chat_instance", (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("Foxchat.Identity.Database.Models.Account", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Avatar") + .HasColumnType("text") + .HasColumnName("avatar"); + + b.Property("Email") + .IsRequired() + .HasColumnType("text") + .HasColumnName("email"); + + b.Property("Password") + .IsRequired() + .HasColumnType("text") + .HasColumnName("password"); + + b.Property("Role") + .HasColumnType("integer") + .HasColumnName("role"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text") + .HasColumnName("username"); + + b.HasKey("Id") + .HasName("pk_accounts"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_accounts_email"); + + b.HasIndex("Username") + .IsUnique() + .HasDatabaseName("ix_accounts_username"); + + b.ToTable("accounts", (string)null); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.ChatInstance", 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_chat_instances"); + + b.HasIndex("Domain") + .IsUnique() + .HasDatabaseName("ix_chat_instances_domain"); + + b.ToTable("chat_instances", (string)null); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.GuildAccount", b => + { + b.Property("ChatInstanceId") + .HasColumnType("uuid") + .HasColumnName("chat_instance_id"); + + b.Property("GuildId") + .HasColumnType("text") + .HasColumnName("guild_id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.HasKey("ChatInstanceId", "GuildId", "AccountId") + .HasName("pk_guild_accounts"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_guild_accounts_account_id"); + + b.ToTable("guild_accounts", (string)null); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.Token", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AccountId") + .HasColumnType("uuid") + .HasColumnName("account_id"); + + b.HasKey("Id") + .HasName("pk_tokens"); + + b.HasIndex("AccountId") + .HasDatabaseName("ix_tokens_account_id"); + + b.ToTable("tokens", (string)null); + }); + + modelBuilder.Entity("AccountChatInstance", b => + { + b.HasOne("Foxchat.Identity.Database.Models.Account", null) + .WithMany() + .HasForeignKey("AccountsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_chat_instance_accounts_accounts_id"); + + b.HasOne("Foxchat.Identity.Database.Models.ChatInstance", null) + .WithMany() + .HasForeignKey("ChatInstancesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_account_chat_instance_chat_instances_chat_instances_id"); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.GuildAccount", b => + { + b.HasOne("Foxchat.Identity.Database.Models.Account", "Account") + .WithMany() + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_accounts_accounts_account_id"); + + b.HasOne("Foxchat.Identity.Database.Models.ChatInstance", "ChatInstance") + .WithMany() + .HasForeignKey("ChatInstanceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_guild_accounts_chat_instances_chat_instance_id"); + + b.Navigation("Account"); + + b.Navigation("ChatInstance"); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.Token", b => + { + b.HasOne("Foxchat.Identity.Database.Models.Account", "Account") + .WithMany("Tokens") + .HasForeignKey("AccountId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_tokens_accounts_account_id"); + + b.Navigation("Account"); + }); + + modelBuilder.Entity("Foxchat.Identity.Database.Models.Account", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Foxchat.Identity/Program.cs b/Foxchat.Identity/Program.cs new file mode 100644 index 0000000..53acc5f --- /dev/null +++ b/Foxchat.Identity/Program.cs @@ -0,0 +1,55 @@ +using Newtonsoft.Json.Serialization; +using Serilog; +using Foxchat.Core; +using Foxchat.Identity; +using Foxchat.Identity.Database; +using Foxchat.Identity.Services; + +var builder = WebApplication.CreateBuilder(args); + +var config = builder.AddConfiguration("identity.ini"); + +builder.Services.AddSerilog(config.LogEventLevel); + +await BuildInfo.ReadBuildInfo(); +Log.Information("Starting Foxchat.Identity {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash); + +builder.Services + .AddControllers() + .AddNewtonsoftJson(options => + options.SerializerSettings.ContractResolver = new DefaultContractResolver + { + NamingStrategy = new SnakeCaseNamingStrategy() + }); + +builder.Services + .AddCoreServices() + .AddScoped() + .AddEndpointsApiExplorer() + .AddSwaggerGen(); + +var app = builder.Build(); + +app.UseSerilogRequestLogging(); +app.UseRouting(); +app.UseSwagger(); +app.UseSwaggerUI(); +app.UseCors(); +app.UseAuthentication(); +app.UseAuthorization(); +app.MapControllers(); + +using (var scope = app.Services.CreateScope()) +using (var context = scope.ServiceProvider.GetRequiredService()) +{ + Log.Information("Initializing instance keypair..."); + if (await context.InitializeInstanceAsync()) + { + Log.Information("Initialized instance keypair"); + } +} + +app.Urls.Clear(); +app.Urls.Add(config.Address); + +app.Run(); diff --git a/Foxchat.Identity/Services/ChatInstanceResolverService.cs b/Foxchat.Identity/Services/ChatInstanceResolverService.cs new file mode 100644 index 0000000..cc9177f --- /dev/null +++ b/Foxchat.Identity/Services/ChatInstanceResolverService.cs @@ -0,0 +1,39 @@ +using Foxchat.Core.Federation; +using Foxchat.Core.Models; +using Foxchat.Identity.Database; +using Foxchat.Identity.Database.Models; +using Microsoft.EntityFrameworkCore; + +namespace Foxchat.Identity.Services; + +public class ChatInstanceResolverService(ILogger logger, RequestSigningService requestSigningService, IdentityContext context, InstanceConfig config) +{ + private readonly ILogger _logger = logger.ForContext(); + + public async Task ResolveChatInstanceAsync(string domain) + { + var instance = await context.ChatInstances.Where(c => c.Domain == domain).FirstOrDefaultAsync(); + if (instance != null) return instance; + + _logger.Information("Unknown chat instance {Domain}, fetching its data", domain); + + var resp = await requestSigningService.RequestAsync( + HttpMethod.Post, + domain, "/_fox/chat/hello", + userId: null, + body: new HelloRequest(config.Domain) + ); + + instance = new ChatInstance + { + Domain = domain, + BaseUrl = $"https://{domain}", + PublicKey = resp.PublicKey, + Status = ChatInstance.InstanceStatus.Active, + }; + await context.AddAsync(instance); + await context.SaveChangesAsync(); + + return instance; + } +} diff --git a/Foxchat.Identity/identity.ini b/Foxchat.Identity/identity.ini new file mode 100644 index 0000000..7e6ba08 --- /dev/null +++ b/Foxchat.Identity/identity.ini @@ -0,0 +1,15 @@ +Host = localhost +Port = 7611 +Domain = id.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_ident;Username=foxchat;Password=password" + +; The timeout for opening new connections. Defaults to 5. +Timeout = 5 +; The maximum number of open connections. Defaults to 50. +MaxPoolSize = 500 diff --git a/Foxchat.sln b/Foxchat.sln new file mode 100644 index 0000000..7d8b9aa --- /dev/null +++ b/Foxchat.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Foxchat.Identity", "Foxchat.Identity\Foxchat.Identity.csproj", "{29265F09-5312-41B5-86C4-3B9EBF155F93}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Foxchat.Core", "Foxchat.Core\Foxchat.Core.csproj", "{06352B8B-628C-4476-9F78-83F326B05B16}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {29265F09-5312-41B5-86C4-3B9EBF155F93}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {29265F09-5312-41B5-86C4-3B9EBF155F93}.Debug|Any CPU.Build.0 = Debug|Any CPU + {29265F09-5312-41B5-86C4-3B9EBF155F93}.Release|Any CPU.ActiveCfg = Release|Any CPU + {29265F09-5312-41B5-86C4-3B9EBF155F93}.Release|Any CPU.Build.0 = Release|Any CPU + {06352B8B-628C-4476-9F78-83F326B05B16}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {06352B8B-628C-4476-9F78-83F326B05B16}.Debug|Any CPU.Build.0 = Debug|Any CPU + {06352B8B-628C-4476-9F78-83F326B05B16}.Release|Any CPU.ActiveCfg = Release|Any CPU + {06352B8B-628C-4476-9F78-83F326B05B16}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/build_info.sh b/build_info.sh new file mode 100755 index 0000000..cc7ee0b --- /dev/null +++ b/build_info.sh @@ -0,0 +1,4 @@ +#!/bin/sh +(git rev-parse HEAD && + git describe --tags --long && + if test -z "$(git ls-files --exclude-standard --modified --deleted --others)"; then echo clean; else echo dirty; fi) > ../.version