Compare commits

...

4 commits

15 changed files with 149 additions and 50 deletions

View file

@ -5,6 +5,7 @@ using Foxchat.Chat.Services;
using Foxchat.Core.Models;
using Foxchat.Core.Models.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ApiError = Foxchat.Core.ApiError;
namespace Foxchat.Chat.Controllers.Api;
@ -16,10 +17,9 @@ public class GuildsController(ILogger logger, ChatContext db, UserResolverServic
[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 (instance, _, userId) = HttpContext.GetSignatureWithUser();
var user = await userResolverService.ResolveUserAsync(instance, sig.UserId);
var user = await userResolverService.ResolveUserAsync(instance, userId);
var guild = new Guild
{
@ -38,10 +38,47 @@ public class GuildsController(ILogger logger, ChatContext db, UserResolverServic
await db.SaveChangesAsync();
return Ok(new Guilds.Guild(
guild.Id.ToString(),
guild.Name,
guild.Id.ToString(),
guild.Name,
[user.Id.ToString()],
[new Channels.PartialChannel(defaultChannel.Id.ToString(), defaultChannel.Name)])
);
}
[HttpGet("{id}")]
public async Task<IActionResult> GetGuild(Ulid id)
{
var (instance, _, userId) = HttpContext.GetSignatureWithUser();
var guild = await db.Guilds
.Include(g => g.Channels)
.FirstOrDefaultAsync(g =>
g.Id == id && g.Users.Any(u => u.RemoteUserId == userId && u.InstanceId == instance.Id));
if (guild == null) throw new ApiError.NotFound("Guild not found");
return Ok(new Guilds.Guild(
guild.Id.ToString(),
guild.Name,
[guild.OwnerId.ToString()],
guild.Channels.Select(c => new Channels.PartialChannel(c.Id.ToString(), c.Name))
));
}
[HttpGet("@me")]
public async Task<IActionResult> GetUserGuilds()
{
var (instance, _, userId) = HttpContext.GetSignatureWithUser();
var guilds = await db.Guilds
.Include(g => g.Channels)
.Where(g => g.Users.Any(u => u.RemoteUserId == userId && u.InstanceId == instance.Id))
.ToListAsync();
var guildResponses = guilds.Select(g => new Guilds.Guild(
g.Id.ToString(),
g.Name,
[g.OwnerId.ToString()],
g.Channels.Select(c => new Channels.PartialChannel(c.Id.ToString(), c.Name))
));
return Ok(guildResponses);
}
}

View file

@ -1,6 +1,7 @@
using Foxchat.Chat.Database.Models;
using Foxchat.Core;
using Foxchat.Core.Database;
using Foxchat.Core.Extensions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
using Npgsql;
@ -10,6 +11,7 @@ namespace Foxchat.Chat.Database;
public class ChatContext : IDatabaseContext
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILoggerFactory? _loggerFactory;
public override DbSet<Instance> Instance { get; set; }
public DbSet<IdentityInstance> IdentityInstances { get; set; }
@ -18,7 +20,7 @@ public class ChatContext : IDatabaseContext
public DbSet<Channel> Channels { get; set; }
public DbSet<Message> Messages { get; set; }
public ChatContext(InstanceConfig config)
public ChatContext(InstanceConfig config, ILoggerFactory? loggerFactory)
{
var connString = new NpgsqlConnectionStringBuilder(config.Database.Url)
{
@ -29,12 +31,14 @@ public class ChatContext : IDatabaseContext
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString);
dataSourceBuilder.UseNodaTime();
_dataSource = dataSourceBuilder.Build();
_loggerFactory = loggerFactory;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseNpgsql(_dataSource, o => o.UseNodaTime())
.UseSnakeCaseNamingConvention();
.UseSnakeCaseNamingConvention()
.UseLoggerFactory(_loggerFactory);
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
@ -73,6 +77,6 @@ public class DesignTimeIdentityContextFactory : IDesignTimeDbContextFactory<Chat
// Get the configuration as our config class
.Get<InstanceConfig>() ?? new();
return new ChatContext(config);
return new ChatContext(config, null);
}
}

View file

@ -76,4 +76,11 @@ public static class HttpContextExtensions
return ((IdentityInstance, SignatureData))obj!;
}
public static (IdentityInstance, SignatureData, string) GetSignatureWithUser(this HttpContext ctx)
{
var (instance, sig) = ctx.GetSignatureOrThrow();
if (sig.UserId == null) throw new ApiError.IncomingFederationError("This endpoint requires a user ID.");
return (instance, sig, sig.UserId);
}
}

View file

@ -4,13 +4,14 @@ using Foxchat.Core;
using Foxchat.Chat;
using Foxchat.Chat.Database;
using Foxchat.Chat.Extensions;
using Foxchat.Core.Extensions;
using Newtonsoft.Json;
var builder = WebApplication.CreateBuilder(args);
var config = builder.AddConfiguration<InstanceConfig>("chat.ini");
builder.AddSerilog(config.LogEventLevel);
builder.AddSerilog();
await BuildInfo.ReadBuildInfo();
Log.Information("Starting Foxchat.Chat {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash);

View file

@ -2,9 +2,6 @@ Host = localhost
Port = 7610
Domain = chat.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_chat;Username=foxchat;Password=password"
@ -13,3 +10,11 @@ Url = "Host=localhost;Database=foxchat_cs_chat;Username=foxchat;Password=passwor
Timeout = 5
; The maximum number of open connections. Defaults to 50.
MaxPoolSize = 500
[Logging]
; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal
LogEventLevel = Debug
; Whether to log SQL queries.
LogQueries = true
; Optional logging to Seq
SeqLogUrl = http://localhost:5341

View file

@ -11,9 +11,7 @@ public class CoreConfig
public string Address => $"{(Secure ? "https" : "http")}://{Host}:{Port}";
public LogEventLevel LogEventLevel { get; set; } = LogEventLevel.Debug;
public string? SeqLogUrl { get; set; }
public LoggingConfig Logging { get; set; } = new();
public DatabaseConfig Database { get; set; } = new();
public class DatabaseConfig
@ -22,4 +20,11 @@ public class CoreConfig
public int? Timeout { get; set; }
public int? MaxPoolSize { get; set; }
}
}
public class LoggingConfig
{
public LogEventLevel LogEventLevel { get; set; } = LogEventLevel.Debug;
public string? SeqLogUrl { get; set; }
public bool LogQueries { get; set; } = false;
}
}

View file

@ -7,30 +7,32 @@ using NodaTime;
using Serilog;
using Serilog.Events;
namespace Foxchat.Core;
namespace Foxchat.Core.Extensions;
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds Serilog to this service collection. This method also initializes Serilog so it should be called as early as possible, before any log calls.
/// </summary>
public static void AddSerilog(this WebApplicationBuilder builder, LogEventLevel level)
public static void AddSerilog(this WebApplicationBuilder builder)
{
var config = builder.Configuration.Get<CoreConfig>() ?? new();
var logCfg = new LoggerConfiguration()
.Enrich.FromLogContext()
.MinimumLevel.Is(level)
.MinimumLevel.Is(config.Logging.LogEventLevel)
// 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.
// Serilog doesn't disable the built-in logs, so we do it here.
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
.MinimumLevel.Override("Microsoft.EntityFrameworkCore.Database.Command",
config.Logging.LogQueries ? LogEventLevel.Information : LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Hosting", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Mvc", LogEventLevel.Warning)
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
.WriteTo.Console();
if (config.SeqLogUrl != null)
logCfg.WriteTo.Seq(config.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose);
if (config.Logging.SeqLogUrl != null)
logCfg.WriteTo.Seq(config.Logging.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose);
Log.Logger = logCfg.CreateLogger();
@ -54,9 +56,9 @@ public static class ServiceCollectionExtensions
return services;
}
public static T AddConfiguration<T>(this WebApplicationBuilder builder, string? configFile = null) where T : class, new()
public static T AddConfiguration<T>(this WebApplicationBuilder builder, string? configFile = null)
where T : class, new()
{
builder.Configuration.Sources.Clear();
builder.Configuration.AddConfiguration(configFile);
@ -76,4 +78,4 @@ public static class ServiceCollectionExtensions
.AddIniFile(file, optional: false, reloadOnChange: true)
.AddEnvironmentVariables();
}
}
}

View file

@ -25,7 +25,7 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c
var appToken =
HttpContext.GetToken() ??
throw new UnreachableException(); // GetApplicationOrThrow already gets the token and throws if it's null
var appScopes = appToken.ExpandScopes();
var appScopes = appToken.Scopes.ExpandScopes();
if (req.Scopes.Except(appScopes).Any())
throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token",
@ -54,7 +54,7 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c
{
var app = HttpContext.GetApplicationOrThrow();
var appToken = HttpContext.GetToken() ?? throw new UnreachableException();
var appScopes = appToken.ExpandScopes();
var appScopes = appToken.Scopes.ExpandScopes();
if (req.Scopes.Except(appScopes).Any())
throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token",

View file

@ -15,7 +15,7 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) :
public async Task<IActionResult> PostToken([FromBody] PostTokenRequest req)
{
var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret);
var appScopes = app.ExpandScopes();
var appScopes = app.Scopes.ExpandScopes();
var scopes = req.Scope.Split(' ');
if (scopes.Except(appScopes).Any())
@ -25,9 +25,9 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) :
switch (req.GrantType)
{
case "client_credentials":
case OauthUtils.ClientCredentials:
return await HandleClientCredentialsAsync(app, scopes);
case "authorization_code":
case OauthUtils.AuthorizationCode:
// TODO
break;
default:

View file

@ -1,12 +1,17 @@
using Foxchat.Core;
using Foxchat.Core.Models;
using Foxchat.Identity.Database;
using Foxchat.Identity.Database.Models;
using Foxchat.Identity.Middleware;
using Foxchat.Identity.Utils;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
namespace Foxchat.Identity.Controllers;
[ApiController]
[ClientAuthenticate]
[Route("/_fox/ident/users")]
public class UsersController(ILogger logger, InstanceConfig config, IdentityContext db) : ControllerBase
{
@ -18,4 +23,30 @@ public class UsersController(ILogger logger, InstanceConfig config, IdentityCont
return Ok(new Users.User(user.Id.ToString(), user.Username, config.Domain, null));
}
[HttpGet("@me")]
[Authorize("identify")]
public IActionResult GetMe()
{
var acct = HttpContext.GetAccountOrThrow();
var token = HttpContext.GetToken()!;
var showEmail = token.Scopes.ExpandScopes().Contains("email");
return Ok(new MeUser(
acct.Id,
acct.Username,
acct.Role,
null,
showEmail ? acct.Email : null
));
}
public record MeUser(
Ulid Id,
string Username,
Account.AccountRole Role,
string? AvatarUrl,
[JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
string? Email
);
}

View file

@ -1,5 +1,6 @@
using Foxchat.Core;
using Foxchat.Core.Database;
using Foxchat.Core.Extensions;
using Foxchat.Identity.Database.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Design;
@ -10,6 +11,7 @@ namespace Foxchat.Identity.Database;
public class IdentityContext : IDatabaseContext
{
private readonly NpgsqlDataSource _dataSource;
private readonly ILoggerFactory? _loggerFactory;
public override DbSet<Instance> Instance { get; set; }
public DbSet<Account> Accounts { get; set; }
@ -18,7 +20,7 @@ public class IdentityContext : IDatabaseContext
public DbSet<Token> Tokens { get; set; }
public DbSet<GuildAccount> GuildAccounts { get; set; }
public IdentityContext(InstanceConfig config)
public IdentityContext(InstanceConfig config, ILoggerFactory? loggerFactory)
{
var connString = new NpgsqlConnectionStringBuilder(config.Database.Url)
{
@ -29,12 +31,14 @@ public class IdentityContext : IDatabaseContext
var dataSourceBuilder = new NpgsqlDataSourceBuilder(connString);
dataSourceBuilder.UseNodaTime();
_dataSource = dataSourceBuilder.Build();
_loggerFactory = loggerFactory;
}
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseNpgsql(_dataSource, o => o.UseNodaTime())
.UseSnakeCaseNamingConvention();
=> optionsBuilder
.UseNpgsql(_dataSource, o => o.UseNodaTime())
.UseSnakeCaseNamingConvention()
.UseLoggerFactory(_loggerFactory);
protected override void ConfigureConventions(ModelConfigurationBuilder configurationBuilder)
{
@ -67,6 +71,6 @@ public class DesignTimeIdentityContextFactory : IDesignTimeDbContextFactory<Iden
// Get the configuration as our config class
.Get<InstanceConfig>() ?? new();
return new IdentityContext(config);
return new IdentityContext(config, null);
}
}

View file

@ -24,8 +24,8 @@ public class ClientAuthorizationMiddleware(
var token = ctx.GetToken();
if (token == null || token.Expires < clock.GetCurrentInstant())
throw new ApiError.Unauthorized("This endpoint requires an authenticated user.");
if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.ExpandScopes()).Any())
throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.ExpandScopes()));
if (attribute.Scopes.Length > 0 && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any())
throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.Scopes.ExpandScopes()));
await next(ctx);
}

View file

@ -1,6 +1,7 @@
using Newtonsoft.Json.Serialization;
using Serilog;
using Foxchat.Core;
using Foxchat.Core.Extensions;
using Foxchat.Identity;
using Foxchat.Identity.Database;
using Foxchat.Identity.Services;
@ -11,7 +12,7 @@ var builder = WebApplication.CreateBuilder(args);
var config = builder.AddConfiguration<InstanceConfig>("identity.ini");
builder.AddSerilog(config.LogEventLevel);
builder.AddSerilog();
await BuildInfo.ReadBuildInfo();
Log.Information("Starting Foxchat.Identity {Version} ({Hash})", BuildInfo.Version, BuildInfo.Hash);

View file

@ -6,7 +6,10 @@ namespace Foxchat.Identity.Utils;
public static class OauthUtils
{
public static readonly string[] Scopes = ["identify", "chat_client"];
public const string ClientCredentials = "client_credentials";
public const string AuthorizationCode = "authorization_code";
public static readonly string[] Scopes = ["identify", "email", "guilds", "chat_client"];
private static readonly string[] ForbiddenSchemes = ["javascript", "file", "data", "mailto", "tel"];
private const string OobUri = "urn:ietf:wg:oauth:2.0:oob";
@ -25,11 +28,7 @@ public static class OauthUtils
}
}
public static string[] ExpandScopes(this Token token) => token.Scopes.Contains("chat_client")
public static string[] ExpandScopes(this string[] scopes) => scopes.Contains("chat_client")
? Scopes
: token.Scopes;
public static string[] ExpandScopes(this Application app) => app.Scopes.Contains("chat_client")
? Scopes
: app.Scopes;
: scopes;
}

View file

@ -2,11 +2,6 @@ Host = localhost
Port = 7611
Domain = id.fox.localhost
; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal
LogEventLevel = Debug
; Optional logging to Seq
SeqLogUrl = http://localhost:5341
[Database]
; The database URL in ADO.NET format.
Url = "Host=localhost;Database=foxchat_cs_ident;Username=foxchat;Password=password"
@ -15,3 +10,11 @@ Url = "Host=localhost;Database=foxchat_cs_ident;Username=foxchat;Password=passwo
Timeout = 5
; The maximum number of open connections. Defaults to 50.
MaxPoolSize = 500
[Logging]
; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal
LogEventLevel = Debug
; Whether to log SQL queries.
LogQueries = true
; Optional logging to Seq
SeqLogUrl = http://localhost:5341