chat: add initial GuildsController

This commit is contained in:
sam 2024-05-21 20:14:52 +02:00
parent 7b4cbd4fb7
commit 727f2f6ba2
23 changed files with 248 additions and 38 deletions

View file

@ -2,3 +2,6 @@
# CS9113: Parameter is unread. # CS9113: Parameter is unread.
dotnet_diagnostic.CS9113.severity = silent dotnet_diagnostic.CS9113.severity = silent
# EntityFramework.ModelValidation.UnlimitedStringLength
resharper_entity_framework_model_validation_unlimited_string_length_highlighting=none

44
.gitignore vendored
View file

@ -1,3 +1,47 @@
bin/ bin/
obj/ obj/
.version .version
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
# User-specific stuff
.idea/**/workspace.xml
.idea/**/tasks.xml
.idea/**/usage.statistics.xml
.idea/**/dictionaries
.idea/**/shelf
.idea/**/discord.xml
# Generated files
.idea/**/contentModel.xml
# Sensitive or high-churn files
.idea/**/dataSources/
.idea/**/dataSources.ids
.idea/**/dataSources.local.xml
.idea/**/sqlDataSources.xml
.idea/**/dynamic.xml
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Gradle
.idea/**/gradle.xml
.idea/**/libraries
# CMake
cmake-build-*/
# File-based project format
*.iws
# Editor-based Rest Client
.idea/httpRequests
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
!.vscode/*.code-snippets

View file

@ -0,0 +1,47 @@
using Foxchat.Chat.Database;
using Foxchat.Chat.Database.Models;
using Foxchat.Chat.Middleware;
using Foxchat.Chat.Services;
using Foxchat.Core.Models;
using Foxchat.Core.Models.Http;
using Microsoft.AspNetCore.Mvc;
using ApiError = Foxchat.Core.ApiError;
namespace Foxchat.Chat.Controllers.Api;
[ApiController]
[Route("/_fox/chat/guilds")]
public class GuildsController(ILogger logger, ChatContext db, UserResolverService userResolverService) : ControllerBase
{
[HttpPost]
public async Task<IActionResult> CreateGuild([FromBody] GuildsApi.CreateGuildRequest req)
{
var (instance, sig) = HttpContext.GetSignatureOrThrow();
if (sig.UserId == null) throw new ApiError.IncomingFederationError("This endpoint requires a user ID.");
var user = await userResolverService.ResolveUserAsync(instance, sig.UserId);
var guild = new Guild
{
Name = req.Name,
Owner = user,
};
db.Add(guild);
guild.Users.Add(user);
var defaultChannel = new Channel
{
Guild = guild,
Name = "general"
};
db.Add(defaultChannel);
await db.SaveChangesAsync();
return Ok(new Guilds.Guild(
guild.Id.ToString(),
guild.Name,
[user.Id.ToString()],
[new Channels.PartialChannel(defaultChannel.Id.ToString(), defaultChannel.Name)])
);
}
}

View file

@ -10,7 +10,7 @@ using ApiError = Foxchat.Core.ApiError;
namespace Foxchat.Chat.Controllers; namespace Foxchat.Chat.Controllers;
[ApiController] [ApiController]
[Unauthenticated] [ServerUnauthenticated]
[Route("/_fox/chat/hello")] [Route("/_fox/chat/hello")]
public class HelloController( public class HelloController(
ILogger logger, ILogger logger,
@ -27,6 +27,8 @@ public class HelloController(
if (!HttpContext.ExtractRequestData(out var signature, out var domain, out var signatureData)) if (!HttpContext.ExtractRequestData(out var signature, out var domain, out var signatureData))
throw new ApiError.IncomingFederationError("This endpoint requires signed requests."); throw new ApiError.IncomingFederationError("This endpoint requires signed requests.");
if (domain != req.Host)
throw new ApiError.IncomingFederationError("Host is invalid.");
if (!requestSigningService.VerifySignature(node.PublicKey, signature, signatureData)) if (!requestSigningService.VerifySignature(node.PublicKey, signature, signatureData))
throw new ApiError.IncomingFederationError("Signature is not valid."); throw new ApiError.IncomingFederationError("Signature is not valid.");

View file

@ -1,3 +1,6 @@
using Foxchat.Core.Models;
using NodaTime;
namespace Foxchat.Chat.Database.Models; namespace Foxchat.Chat.Database.Models;
public class User : BaseModel public class User : BaseModel
@ -8,6 +11,7 @@ public class User : BaseModel
public string Username { get; init; } = null!; public string Username { get; init; } = null!;
public string? Avatar { get; set; } public string? Avatar { get; set; }
public Instant LastFetchedAt { get; set; }
public List<Guild> Guilds { get; } = []; public List<Guild> Guilds { get; } = [];
public List<Guild> OwnedGuilds { get; } = []; public List<Guild> OwnedGuilds { get; } = [];

View file

@ -1,4 +1,6 @@
using Foxchat.Chat.Middleware; using Foxchat.Chat.Middleware;
using Foxchat.Chat.Services;
using Foxchat.Core.Middleware;
namespace Foxchat.Chat.Extensions; namespace Foxchat.Chat.Extensions;
@ -7,12 +9,20 @@ public static class WebApplicationExtensions
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) public static IServiceCollection AddCustomMiddleware(this IServiceCollection services)
{ {
return services return services
.AddScoped<AuthenticationMiddleware>(); .AddScoped<ErrorHandlerMiddleware>()
.AddScoped<ServerAuthenticationMiddleware>();
} }
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app)
{ {
return app return app
.UseMiddleware<AuthenticationMiddleware>(); .UseMiddleware<ErrorHandlerMiddleware>()
.UseMiddleware<ServerAuthenticationMiddleware>();
}
public static IServiceCollection AddChatServices(this IServiceCollection services)
{
return services
.AddScoped<UserResolverService>();
} }
} }

View file

@ -7,14 +7,14 @@ using Microsoft.EntityFrameworkCore;
namespace Foxchat.Chat.Middleware; namespace Foxchat.Chat.Middleware;
public class AuthenticationMiddleware(ILogger logger, ChatContext db, RequestSigningService requestSigningService) public class ServerAuthenticationMiddleware(ILogger logger, ChatContext db, RequestSigningService requestSigningService)
: IMiddleware : IMiddleware
{ {
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{ {
var endpoint = ctx.GetEndpoint(); var endpoint = ctx.GetEndpoint();
// Endpoints require server authentication by default, unless they have the [Unauthenticated] attribute. // Endpoints require server authentication by default, unless they have the [Unauthenticated] attribute.
var metadata = endpoint?.Metadata.GetMetadata<UnauthenticatedAttribute>(); var metadata = endpoint?.Metadata.GetMetadata<ServerUnauthenticatedAttribute>();
if (metadata != null) if (metadata != null)
{ {
await next(ctx); await next(ctx);
@ -41,8 +41,11 @@ public class AuthenticationMiddleware(ILogger logger, ChatContext db, RequestSig
} }
} }
/// <summary>
/// Attribute to be put on controllers or methods to indicate that it does <i>not</i> require a signed request.
/// </summary>
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class UnauthenticatedAttribute : Attribute; public class ServerUnauthenticatedAttribute : Attribute;
public static class HttpContextExtensions public static class HttpContextExtensions
{ {

View file

@ -34,6 +34,7 @@ builder.Services
builder.Services builder.Services
.AddCoreServices<ChatContext>() .AddCoreServices<ChatContext>()
.AddChatServices()
.AddCustomMiddleware() .AddCustomMiddleware()
.AddEndpointsApiExplorer() .AddEndpointsApiExplorer()
.AddSwaggerGen(); .AddSwaggerGen();

View file

@ -0,0 +1,35 @@
using Foxchat.Chat.Database;
using Foxchat.Chat.Database.Models;
using Foxchat.Core.Federation;
using Foxchat.Core.Models;
using Microsoft.EntityFrameworkCore;
namespace Foxchat.Chat.Services;
public class UserResolverService(ILogger logger, ChatContext db, RequestSigningService requestSigningService)
{
public async Task<User> ResolveUserAsync(IdentityInstance instance, string userId)
{
var user = await db.Users.FirstOrDefaultAsync(u => u.InstanceId == instance.Id && u.RemoteUserId == userId);
if (user != null)
{
// TODO: update user if it's been long enough
return user;
}
var userResponse = await requestSigningService.RequestAsync<Users.User>(HttpMethod.Get, instance.Domain,
$"/_fox/ident/users/{userId}");
user = new User
{
Instance = instance,
Username = userResponse.Username,
RemoteUserId = userResponse.Id,
Avatar = userResponse.AvatarUrl
};
db.Add(user);
await db.SaveChangesAsync();
return user;
}
}

View file

@ -1,3 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using Foxchat.Core.Federation; using Foxchat.Core.Federation;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@ -5,11 +6,12 @@ namespace Foxchat.Core.Extensions;
public static class HttpContextExtensions public static class HttpContextExtensions
{ {
public static bool ExtractRequestData(this HttpContext ctx, out string signature, out string domain, out SignatureData data) public static bool ExtractRequestData(this HttpContext ctx, [NotNullWhen(true)] out string? signature,
[NotNullWhen(true)] out string? domain, [NotNullWhen(true)] out SignatureData? data)
{ {
signature = string.Empty; signature = null;
domain = string.Empty; domain = null;
data = SignatureData.Empty; data = null;
if (!ctx.Request.Headers.TryGetValue(RequestSigningService.SIGNATURE_HEADER, out var encodedSignature)) if (!ctx.Request.Headers.TryGetValue(RequestSigningService.SIGNATURE_HEADER, out var encodedSignature))
return false; return false;

View file

@ -33,18 +33,17 @@ public partial class RequestSigningService(ILogger logger, IClock clock, IDataba
public bool VerifySignature( public bool VerifySignature(
string publicKey, string encodedSignature, SignatureData data) string publicKey, string encodedSignature, SignatureData data)
{ {
var rsa = RSA.Create(); if (data.Host != _config.Domain)
rsa.ImportFromPem(publicKey); throw new ApiError.IncomingFederationError("Request is not for this instance");
var now = _clock.GetCurrentInstant(); var now = _clock.GetCurrentInstant();
if ((now + Duration.FromMinutes(1)) < data.Time) if (now + Duration.FromMinutes(1) < data.Time)
{
throw new ApiError.IncomingFederationError("Request was made in the future"); throw new ApiError.IncomingFederationError("Request was made in the future");
} if (now - Duration.FromMinutes(1) > data.Time)
else if ((now - Duration.FromMinutes(1)) > data.Time)
{
throw new ApiError.IncomingFederationError("Request was made too long ago"); throw new ApiError.IncomingFederationError("Request was made too long ago");
}
var rsa = RSA.Create();
rsa.ImportFromPem(publicKey);
var plaintext = GeneratePlaintext(data); var plaintext = GeneratePlaintext(data);
var plaintextBytes = Encoding.UTF8.GetBytes(plaintext); var plaintextBytes = Encoding.UTF8.GetBytes(plaintext);
@ -70,7 +69,9 @@ public partial class RequestSigningService(ILogger logger, IClock clock, IDataba
return $"{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 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 string FormatTime(Instant time) => _pattern.Format(time);
public static Instant ParseTime(string header) => _pattern.Parse(header).GetValueOrThrow(); public static Instant ParseTime(string header) => _pattern.Parse(header).GetValueOrThrow();
} }

View file

@ -1,11 +1,10 @@
using System.Net; using System.Net;
using Foxchat.Core;
using Foxchat.Core.Models.Http; using Foxchat.Core.Models.Http;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json; using Newtonsoft.Json;
using ApiError = Foxchat.Core.ApiError;
using HttpApiError = Foxchat.Core.Models.Http.ApiError; using HttpApiError = Foxchat.Core.Models.Http.ApiError;
namespace Foxchat.Identity.Middleware; namespace Foxchat.Core.Middleware;
public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware
{ {
@ -23,7 +22,8 @@ public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware
if (ctx.Response.HasStarted) if (ctx.Response.HasStarted)
{ {
logger.Error(e, "Error in {ClassName} ({Path}) after response started being sent", typeName, ctx.Request.Path); logger.Error(e, "Error in {ClassName} ({Path}) after response started being sent", typeName,
ctx.Request.Path);
} }
if (e is ApiError ae) if (e is ApiError ae)

View file

@ -0,0 +1,8 @@
namespace Foxchat.Core.Models;
public static class Channels
{
public record Channel(string Id, string GuildId, string Name, string? Topic);
public record PartialChannel(string Id, string Name);
}

View file

@ -0,0 +1,14 @@
using Newtonsoft.Json;
namespace Foxchat.Core.Models;
public static class Guilds
{
public record Guild(
string Id,
string Name,
IEnumerable<string> OwnerIds,
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
IEnumerable<Channels.PartialChannel>? Channels
);
}

View file

@ -1,6 +1,6 @@
namespace Foxchat.Core.Models.Http; namespace Foxchat.Core.Models.Http;
public static class Apps public static class AppsApi
{ {
public record CreateRequest(string Name, string[] Scopes, string[] RedirectUris); public record CreateRequest(string Name, string[] Scopes, string[] RedirectUris);
public record CreateResponse(Ulid Id, string ClientId, string ClientSecret, string Name, string[] Scopes, string[] RedirectUris); public record CreateResponse(Ulid Id, string ClientId, string ClientSecret, string Name, string[] Scopes, string[] RedirectUris);

View file

@ -0,0 +1,6 @@
namespace Foxchat.Core.Models.Http;
public static class GuildsApi
{
public record CreateGuildRequest(string Name);
}

View file

@ -0,0 +1,8 @@
namespace Foxchat.Core.Models;
public static class Users
{
public record User(string Id, string Username, string Instance, string? AvatarUrl);
public record PartialUser(string Id, string Username, string Instance);
}

View file

@ -9,12 +9,12 @@ using Microsoft.AspNetCore.Mvc;
namespace Foxchat.Identity.Controllers.Oauth; namespace Foxchat.Identity.Controllers.Oauth;
[ApiController] [ApiController]
[Authenticate] [ClientAuthenticate]
[Route("/_fox/ident/oauth/apps")] [Route("/_fox/ident/oauth/apps")]
public class AppsController(ILogger logger, IdentityContext db) : ControllerBase public class AppsController(ILogger logger, IdentityContext db) : ControllerBase
{ {
[HttpPost] [HttpPost]
public async Task<IActionResult> CreateApplication([FromBody] Apps.CreateRequest req) public async Task<IActionResult> CreateApplication([FromBody] AppsApi.CreateRequest req)
{ {
var app = Application.Create(req.Name, req.Scopes, req.RedirectUris); var app = Application.Create(req.Name, req.Scopes, req.RedirectUris);
db.Add(app); db.Add(app);
@ -22,7 +22,7 @@ public class AppsController(ILogger logger, IdentityContext db) : ControllerBase
logger.Information("Created new application {Name} with ID {Id} and client ID {ClientId}", app.Name, app.Id, app.ClientId); logger.Information("Created new application {Name} with ID {Id} and client ID {ClientId}", app.Name, app.Id, app.ClientId);
return Ok(new Apps.CreateResponse( return Ok(new AppsApi.CreateResponse(
app.Id, app.ClientId, app.ClientSecret, app.Name, app.Scopes, app.RedirectUris app.Id, app.ClientId, app.ClientSecret, app.Name, app.Scopes, app.RedirectUris
)); ));
} }
@ -32,7 +32,7 @@ public class AppsController(ILogger logger, IdentityContext db) : ControllerBase
{ {
var app = HttpContext.GetApplicationOrThrow(); var app = HttpContext.GetApplicationOrThrow();
return Ok(new Apps.GetSelfResponse( return Ok(new AppsApi.GetSelfResponse(
app.Id, app.Id,
app.ClientId, app.ClientId,
withSecret ? app.ClientSecret : null, withSecret ? app.ClientSecret : null,

View file

@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore;
namespace Foxchat.Identity.Controllers.Oauth; namespace Foxchat.Identity.Controllers.Oauth;
[ApiController] [ApiController]
[Authenticate] [ClientAuthenticate]
[Route("/_fox/ident/oauth/password")] [Route("/_fox/ident/oauth/password")]
public class PasswordAuthController(ILogger logger, IdentityContext db, IClock clock) : ControllerBase public class PasswordAuthController(ILogger logger, IdentityContext db, IClock clock) : ControllerBase
{ {

View file

@ -0,0 +1,21 @@
using Foxchat.Core;
using Foxchat.Core.Models;
using Foxchat.Identity.Database;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxchat.Identity.Controllers;
[ApiController]
[Route("/_fox/ident/users")]
public class UsersController(ILogger logger, InstanceConfig config, IdentityContext db) : ControllerBase
{
[HttpGet("{id}")]
public async Task<IActionResult> GetUser(Ulid id)
{
var user = await db.Accounts.FirstOrDefaultAsync(a => a.Id == id);
if (user == null) throw new ApiError.NotFound("User not found.");
return Ok(new Users.User(user.Id.ToString(), user.Username, config.Domain, null));
}
}

View file

@ -1,3 +1,4 @@
using Foxchat.Core.Middleware;
using Foxchat.Identity.Middleware; using Foxchat.Identity.Middleware;
namespace Foxchat.Identity.Extensions; namespace Foxchat.Identity.Extensions;
@ -8,15 +9,15 @@ public static class WebApplicationExtensions
{ {
return services return services
.AddScoped<ErrorHandlerMiddleware>() .AddScoped<ErrorHandlerMiddleware>()
.AddScoped<AuthenticationMiddleware>() .AddScoped<ClientAuthenticationMiddleware>()
.AddScoped<AuthorizationMiddleware>(); .AddScoped<ClientAuthorizationMiddleware>();
} }
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app) public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app)
{ {
return app return app
.UseMiddleware<ErrorHandlerMiddleware>() .UseMiddleware<ErrorHandlerMiddleware>()
.UseMiddleware<AuthenticationMiddleware>() .UseMiddleware<ClientAuthenticationMiddleware>()
.UseMiddleware<AuthorizationMiddleware>(); .UseMiddleware<ClientAuthorizationMiddleware>();
} }
} }

View file

@ -8,7 +8,7 @@ using NodaTime;
namespace Foxchat.Identity.Middleware; namespace Foxchat.Identity.Middleware;
public class AuthenticationMiddleware( public class ClientAuthenticationMiddleware(
IdentityContext db, IdentityContext db,
IClock clock IClock clock
) : IMiddleware ) : IMiddleware
@ -16,7 +16,7 @@ public class AuthenticationMiddleware(
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next) public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{ {
var endpoint = ctx.GetEndpoint(); var endpoint = ctx.GetEndpoint();
var metadata = endpoint?.Metadata.GetMetadata<AuthenticateAttribute>(); var metadata = endpoint?.Metadata.GetMetadata<ClientAuthenticateAttribute>();
if (metadata == null) if (metadata == null)
{ {
@ -81,4 +81,4 @@ public static class HttpContextExtensions
} }
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)] [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthenticateAttribute : Attribute; public class ClientAuthenticateAttribute : Attribute;

View file

@ -4,7 +4,7 @@ using NodaTime;
namespace Foxchat.Identity.Middleware; namespace Foxchat.Identity.Middleware;
public class AuthorizationMiddleware( public class ClientAuthorizationMiddleware(
IdentityContext db, IdentityContext db,
IClock clock IClock clock
) : IMiddleware ) : IMiddleware