chat: add initial GuildsController
This commit is contained in:
parent
7b4cbd4fb7
commit
727f2f6ba2
23 changed files with 248 additions and 38 deletions
|
@ -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
44
.gitignore
vendored
|
@ -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
|
||||
|
|
47
Foxchat.Chat/Controllers/Api/GuildsController.cs
Normal file
47
Foxchat.Chat/Controllers/Api/GuildsController.cs
Normal 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)])
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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.");
|
||||
|
|
|
@ -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; } = [];
|
||||
|
|
|
@ -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>();
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
|
@ -34,6 +34,7 @@ builder.Services
|
|||
|
||||
builder.Services
|
||||
.AddCoreServices<ChatContext>()
|
||||
.AddChatServices()
|
||||
.AddCustomMiddleware()
|
||||
.AddEndpointsApiExplorer()
|
||||
.AddSwaggerGen();
|
||||
|
|
35
Foxchat.Chat/Services/UserResolverService.cs
Normal file
35
Foxchat.Chat/Services/UserResolverService.cs
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
|
@ -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)
|
8
Foxchat.Core/Models/Channels.cs
Normal file
8
Foxchat.Core/Models/Channels.cs
Normal 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);
|
||||
}
|
14
Foxchat.Core/Models/Guilds.cs
Normal file
14
Foxchat.Core/Models/Guilds.cs
Normal 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
|
||||
);
|
||||
}
|
|
@ -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);
|
6
Foxchat.Core/Models/Http/GuildsApi.cs
Normal file
6
Foxchat.Core/Models/Http/GuildsApi.cs
Normal file
|
@ -0,0 +1,6 @@
|
|||
namespace Foxchat.Core.Models.Http;
|
||||
|
||||
public static class GuildsApi
|
||||
{
|
||||
public record CreateGuildRequest(string Name);
|
||||
}
|
8
Foxchat.Core/Models/Users.cs
Normal file
8
Foxchat.Core/Models/Users.cs
Normal 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);
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
{
|
||||
|
|
21
Foxchat.Identity/Controllers/UsersController.cs
Normal file
21
Foxchat.Identity/Controllers/UsersController.cs
Normal 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));
|
||||
}
|
||||
}
|
|
@ -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>();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -4,7 +4,7 @@ using NodaTime;
|
|||
|
||||
namespace Foxchat.Identity.Middleware;
|
||||
|
||||
public class AuthorizationMiddleware(
|
||||
public class ClientAuthorizationMiddleware(
|
||||
IdentityContext db,
|
||||
IClock clock
|
||||
) : IMiddleware
|
Loading…
Reference in a new issue