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

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

View file

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

View file

@ -1,4 +1,6 @@
using Foxchat.Chat.Middleware;
using Foxchat.Chat.Services;
using Foxchat.Core.Middleware;
namespace Foxchat.Chat.Extensions;
@ -7,12 +9,20 @@ public static class WebApplicationExtensions
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services)
{
return services
.AddScoped<AuthenticationMiddleware>();
.AddScoped<ErrorHandlerMiddleware>()
.AddScoped<ServerAuthenticationMiddleware>();
}
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder 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;
public class AuthenticationMiddleware(ILogger logger, ChatContext db, RequestSigningService requestSigningService)
public class ServerAuthenticationMiddleware(ILogger logger, ChatContext db, RequestSigningService requestSigningService)
: IMiddleware
{
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
var endpoint = ctx.GetEndpoint();
// Endpoints require server authentication by default, unless they have the [Unauthenticated] attribute.
var metadata = endpoint?.Metadata.GetMetadata<UnauthenticatedAttribute>();
var metadata = endpoint?.Metadata.GetMetadata<ServerUnauthenticatedAttribute>();
if (metadata != null)
{
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)]
public class UnauthenticatedAttribute : Attribute;
public class ServerUnauthenticatedAttribute : Attribute;
public static class HttpContextExtensions
{

View file

@ -34,6 +34,7 @@ builder.Services
builder.Services
.AddCoreServices<ChatContext>()
.AddChatServices()
.AddCustomMiddleware()
.AddEndpointsApiExplorer()
.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 Microsoft.AspNetCore.Http;
@ -5,11 +6,12 @@ namespace Foxchat.Core.Extensions;
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;
domain = string.Empty;
data = SignatureData.Empty;
signature = null;
domain = null;
data = null;
if (!ctx.Request.Headers.TryGetValue(RequestSigningService.SIGNATURE_HEADER, out var encodedSignature))
return false;

View file

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

View file

@ -1,11 +1,10 @@
using System.Net;
using Foxchat.Core;
using Foxchat.Core.Models.Http;
using Microsoft.AspNetCore.Http;
using Newtonsoft.Json;
using ApiError = Foxchat.Core.ApiError;
using HttpApiError = Foxchat.Core.Models.Http.ApiError;
namespace Foxchat.Identity.Middleware;
namespace Foxchat.Core.Middleware;
public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware
{
@ -23,7 +22,8 @@ public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware
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)

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;
public static class Apps
public static class AppsApi
{
public record CreateRequest(string Name, string[] Scopes, string[] RedirectUris);
public record CreateResponse(Ulid Id, string ClientId, string ClientSecret, string Name, string[] Scopes, string[] RedirectUris);

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;
[ApiController]
[Authenticate]
[ClientAuthenticate]
[Route("/_fox/ident/oauth/apps")]
public class AppsController(ILogger logger, IdentityContext db) : ControllerBase
{
[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);
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);
return Ok(new Apps.CreateResponse(
return Ok(new AppsApi.CreateResponse(
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();
return Ok(new Apps.GetSelfResponse(
return Ok(new AppsApi.GetSelfResponse(
app.Id,
app.ClientId,
withSecret ? app.ClientSecret : null,

View file

@ -11,7 +11,7 @@ using Microsoft.EntityFrameworkCore;
namespace Foxchat.Identity.Controllers.Oauth;
[ApiController]
[Authenticate]
[ClientAuthenticate]
[Route("/_fox/ident/oauth/password")]
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;
namespace Foxchat.Identity.Extensions;
@ -8,15 +9,15 @@ public static class WebApplicationExtensions
{
return services
.AddScoped<ErrorHandlerMiddleware>()
.AddScoped<AuthenticationMiddleware>()
.AddScoped<AuthorizationMiddleware>();
.AddScoped<ClientAuthenticationMiddleware>()
.AddScoped<ClientAuthorizationMiddleware>();
}
public static IApplicationBuilder UseCustomMiddleware(this IApplicationBuilder app)
{
return app
.UseMiddleware<ErrorHandlerMiddleware>()
.UseMiddleware<AuthenticationMiddleware>()
.UseMiddleware<AuthorizationMiddleware>();
.UseMiddleware<ClientAuthenticationMiddleware>()
.UseMiddleware<ClientAuthorizationMiddleware>();
}
}

View file

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