Compare commits
6 commits
v0.0.2.pro
...
main
Author | SHA1 | Date | |
---|---|---|---|
2919413118 | |||
8bd118ea67 | |||
00a54f4f8b | |||
6aed05af06 | |||
b95fb76cd4 | |||
727f2f6ba2 |
37 changed files with 827 additions and 82 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
|
||||
|
|
84
Foxchat.Chat/Controllers/Api/GuildsController.cs
Normal file
84
Foxchat.Chat/Controllers/Api/GuildsController.cs
Normal file
|
@ -0,0 +1,84 @@
|
|||
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 Microsoft.EntityFrameworkCore;
|
||||
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, _, userId) = HttpContext.GetSignatureWithUser();
|
||||
|
||||
var user = await userResolverService.ResolveUserAsync(instance, 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)])
|
||||
);
|
||||
}
|
||||
|
||||
[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);
|
||||
}
|
||||
}
|
|
@ -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,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);
|
||||
}
|
||||
}
|
|
@ -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
|
||||
{
|
||||
|
@ -73,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);
|
||||
}
|
||||
}
|
329
Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs
generated
Normal file
329
Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs
generated
Normal file
|
@ -0,0 +1,329 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using Foxchat.Chat.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using NodaTime;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Foxchat.Chat.Migrations
|
||||
{
|
||||
[DbContext(typeof(ChatContext))]
|
||||
[Migration("20240521191115_AddLastFetchedAtToUsers")]
|
||||
partial class AddLastFetchedAtToUsers
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.5")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("GuildId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("guild_id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<string>("Topic")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("topic");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_channels");
|
||||
|
||||
b.HasIndex("GuildId")
|
||||
.HasDatabaseName("ix_channels_guild_id");
|
||||
|
||||
b.ToTable("channels", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("name");
|
||||
|
||||
b.Property<Guid>("OwnerId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("owner_id");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_guilds");
|
||||
|
||||
b.HasIndex("OwnerId")
|
||||
.HasDatabaseName("ix_guilds_owner_id");
|
||||
|
||||
b.ToTable("guilds", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxchat.Chat.Database.Models.IdentityInstance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("BaseUrl")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("base_url");
|
||||
|
||||
b.Property<string>("Domain")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("domain");
|
||||
|
||||
b.Property<string>("PublicKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("public_key");
|
||||
|
||||
b.Property<string>("Reason")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("reason");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("status");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_identity_instances");
|
||||
|
||||
b.HasIndex("Domain")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_identity_instances_domain");
|
||||
|
||||
b.ToTable("identity_instances", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<Guid>("AuthorId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("author_id");
|
||||
|
||||
b.Property<Guid>("ChannelId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("channel_id");
|
||||
|
||||
b.Property<string>("Content")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("content");
|
||||
|
||||
b.Property<Instant?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("updated_at");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_messages");
|
||||
|
||||
b.HasIndex("AuthorId")
|
||||
.HasDatabaseName("ix_messages_author_id");
|
||||
|
||||
b.HasIndex("ChannelId")
|
||||
.HasDatabaseName("ix_messages_channel_id");
|
||||
|
||||
b.ToTable("messages", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("id");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("avatar");
|
||||
|
||||
b.Property<Guid>("InstanceId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("instance_id");
|
||||
|
||||
b.Property<Instant>("LastFetchedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_fetched_at");
|
||||
|
||||
b.Property<string>("RemoteUserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("remote_user_id");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("username");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_users");
|
||||
|
||||
b.HasIndex("InstanceId")
|
||||
.HasDatabaseName("ix_users_instance_id");
|
||||
|
||||
b.HasIndex("RemoteUserId", "InstanceId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_remote_user_id_instance_id");
|
||||
|
||||
b.HasIndex("Username", "InstanceId")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("ix_users_username_instance_id");
|
||||
|
||||
b.ToTable("users", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxchat.Core.Database.Instance", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer")
|
||||
.HasColumnName("id");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
|
||||
|
||||
b.Property<string>("PrivateKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("private_key");
|
||||
|
||||
b.Property<string>("PublicKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
.HasColumnName("public_key");
|
||||
|
||||
b.HasKey("Id")
|
||||
.HasName("pk_instance");
|
||||
|
||||
b.ToTable("instance", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GuildUser", b =>
|
||||
{
|
||||
b.Property<Guid>("GuildsId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("guilds_id");
|
||||
|
||||
b.Property<Guid>("UsersId")
|
||||
.HasColumnType("uuid")
|
||||
.HasColumnName("users_id");
|
||||
|
||||
b.HasKey("GuildsId", "UsersId")
|
||||
.HasName("pk_guild_user");
|
||||
|
||||
b.HasIndex("UsersId")
|
||||
.HasDatabaseName("ix_guild_user_users_id");
|
||||
|
||||
b.ToTable("guild_user", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxchat.Chat.Database.Models.Channel", b =>
|
||||
{
|
||||
b.HasOne("Foxchat.Chat.Database.Models.Guild", "Guild")
|
||||
.WithMany("Channels")
|
||||
.HasForeignKey("GuildId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_channels_guilds_guild_id");
|
||||
|
||||
b.Navigation("Guild");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b =>
|
||||
{
|
||||
b.HasOne("Foxchat.Chat.Database.Models.User", "Owner")
|
||||
.WithMany("OwnedGuilds")
|
||||
.HasForeignKey("OwnerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_guilds_users_owner_id");
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxchat.Chat.Database.Models.Message", b =>
|
||||
{
|
||||
b.HasOne("Foxchat.Chat.Database.Models.User", "Author")
|
||||
.WithMany()
|
||||
.HasForeignKey("AuthorId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_messages_users_author_id");
|
||||
|
||||
b.HasOne("Foxchat.Chat.Database.Models.Channel", "Channel")
|
||||
.WithMany()
|
||||
.HasForeignKey("ChannelId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_messages_channels_channel_id");
|
||||
|
||||
b.Navigation("Author");
|
||||
|
||||
b.Navigation("Channel");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b =>
|
||||
{
|
||||
b.HasOne("Foxchat.Chat.Database.Models.IdentityInstance", "Instance")
|
||||
.WithMany()
|
||||
.HasForeignKey("InstanceId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_users_identity_instances_instance_id");
|
||||
|
||||
b.Navigation("Instance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GuildUser", b =>
|
||||
{
|
||||
b.HasOne("Foxchat.Chat.Database.Models.Guild", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("GuildsId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_guild_user_guilds_guilds_id");
|
||||
|
||||
b.HasOne("Foxchat.Chat.Database.Models.User", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UsersId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired()
|
||||
.HasConstraintName("fk_guild_user_users_users_id");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxchat.Chat.Database.Models.Guild", b =>
|
||||
{
|
||||
b.Navigation("Channels");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Foxchat.Chat.Database.Models.User", b =>
|
||||
{
|
||||
b.Navigation("OwnedGuilds");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using NodaTime;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Foxchat.Chat.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddLastFetchedAtToUsers : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<Instant>(
|
||||
name: "last_fetched_at",
|
||||
table: "users",
|
||||
type: "timestamp with time zone",
|
||||
nullable: false,
|
||||
defaultValue: NodaTime.Instant.FromUnixTimeTicks(0L));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "last_fetched_at",
|
||||
table: "users");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -162,6 +162,10 @@ namespace Foxchat.Chat.Migrations
|
|||
.HasColumnType("uuid")
|
||||
.HasColumnName("instance_id");
|
||||
|
||||
b.Property<Instant>("LastFetchedAt")
|
||||
.HasColumnType("timestamp with time zone")
|
||||
.HasColumnName("last_fetched_at");
|
||||
|
||||
b.Property<string>("RemoteUserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text")
|
||||
|
|
|
@ -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);
|
||||
|
@ -34,6 +35,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;
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -5,13 +5,14 @@ using Microsoft.AspNetCore.Identity;
|
|||
using Foxchat.Identity.Database.Models;
|
||||
using Foxchat.Core;
|
||||
using System.Diagnostics;
|
||||
using Foxchat.Identity.Utils;
|
||||
using NodaTime;
|
||||
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
|
||||
{
|
||||
|
@ -24,10 +25,11 @@ 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.Scopes.ExpandScopes();
|
||||
|
||||
if (req.Scopes.Except(appToken.Scopes).Any())
|
||||
if (req.Scopes.Except(appScopes).Any())
|
||||
throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token",
|
||||
req.Scopes.Except(appToken.Scopes));
|
||||
req.Scopes.Except(appScopes));
|
||||
|
||||
var acct = new Account
|
||||
{
|
||||
|
@ -52,10 +54,11 @@ public class PasswordAuthController(ILogger logger, IdentityContext db, IClock c
|
|||
{
|
||||
var app = HttpContext.GetApplicationOrThrow();
|
||||
var appToken = HttpContext.GetToken() ?? throw new UnreachableException();
|
||||
var appScopes = appToken.Scopes.ExpandScopes();
|
||||
|
||||
if (req.Scopes.Except(appToken.Scopes).Any())
|
||||
if (req.Scopes.Except(appScopes).Any())
|
||||
throw new ApiError.Forbidden("Cannot request token scopes that are not allowed for this token",
|
||||
req.Scopes.Except(appToken.Scopes));
|
||||
req.Scopes.Except(appScopes));
|
||||
|
||||
var acct = await db.Accounts.FirstOrDefaultAsync(a => a.Email == req.Email)
|
||||
?? throw new ApiError.NotFound("No user with that email found, or password is incorrect");
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
using Foxchat.Core;
|
||||
using Foxchat.Identity.Database;
|
||||
using Foxchat.Identity.Database.Models;
|
||||
using Foxchat.Identity.Utils;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NodaTime;
|
||||
|
||||
|
@ -14,18 +15,19 @@ 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.Scopes.ExpandScopes();
|
||||
|
||||
var scopes = req.Scope.Split(' ');
|
||||
if (scopes.Except(app.Scopes).Any())
|
||||
if (scopes.Except(appScopes).Any())
|
||||
{
|
||||
throw new ApiError.BadRequest("Invalid or unauthorized scopes");
|
||||
throw new ApiError.Forbidden("Invalid or unauthorized scopes", scopes.Except(appScopes));
|
||||
}
|
||||
|
||||
switch (req.GrantType)
|
||||
{
|
||||
case "client_credentials":
|
||||
case OauthUtils.ClientCredentials:
|
||||
return await HandleClientCredentialsAsync(app, scopes);
|
||||
case "authorization_code":
|
||||
case OauthUtils.AuthorizationCode:
|
||||
// TODO
|
||||
break;
|
||||
default:
|
||||
|
|
21
Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs
Normal file
21
Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs
Normal file
|
@ -0,0 +1,21 @@
|
|||
using Foxchat.Core.Federation;
|
||||
using Foxchat.Core.Models;
|
||||
using Foxchat.Core.Models.Http;
|
||||
using Foxchat.Identity.Middleware;
|
||||
using Foxchat.Identity.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Foxchat.Identity.Controllers.Proxy;
|
||||
|
||||
[Route("/_fox/proxy/guilds")]
|
||||
public class GuildsProxyController(
|
||||
ILogger logger,
|
||||
ChatInstanceResolverService chatInstanceResolverService,
|
||||
RequestSigningService requestSigningService)
|
||||
: ProxyControllerBase(logger, chatInstanceResolverService, requestSigningService)
|
||||
{
|
||||
[Authorize("chat_client")]
|
||||
[HttpPost]
|
||||
public Task<IActionResult> CreateGuild([FromBody] GuildsApi.CreateGuildRequest req) =>
|
||||
Proxy<Guilds.Guild>(HttpMethod.Post, req);
|
||||
}
|
38
Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs
Normal file
38
Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs
Normal file
|
@ -0,0 +1,38 @@
|
|||
using Foxchat.Core;
|
||||
using Foxchat.Core.Federation;
|
||||
using Foxchat.Identity.Middleware;
|
||||
using Foxchat.Identity.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Foxchat.Identity.Controllers.Proxy;
|
||||
|
||||
[ApiController]
|
||||
[ClientAuthenticate]
|
||||
public class ProxyControllerBase(
|
||||
ILogger logger,
|
||||
ChatInstanceResolverService chatInstanceResolverService,
|
||||
RequestSigningService requestSigningService) : ControllerBase
|
||||
{
|
||||
internal async Task<IActionResult> Proxy<TResponse>(HttpMethod method, object? body = null) where TResponse : class
|
||||
{
|
||||
var acct = HttpContext.GetAccountOrThrow();
|
||||
|
||||
var path = HttpContext.Request.Path.ToString();
|
||||
if (!path.StartsWith("/_fox/proxy"))
|
||||
throw new FoxchatError("Proxy<T> used for endpoint that does not start with /_fox/proxy");
|
||||
path = $"/_fox/chat/{path[12..]}";
|
||||
|
||||
if (!HttpContext.Request.Headers.TryGetValue(RequestSigningService.SERVER_HEADER, out var serverHeader))
|
||||
throw new ApiError.BadRequest($"Invalid or missing {RequestSigningService.SERVER_HEADER} header.");
|
||||
var server = serverHeader.ToString();
|
||||
|
||||
logger.Debug("Proxying {Method} request to {Domain}{Path}", method, server, path);
|
||||
|
||||
// Identity instances always initiate federation, so we have to make sure the instance knows about us.
|
||||
// This also serves as a way to make sure the instance being requested actually exists.
|
||||
await chatInstanceResolverService.ResolveChatInstanceAsync(serverHeader.ToString());
|
||||
|
||||
var resp = await requestSigningService.RequestAsync<TResponse>(method, server, path, acct.Id.ToString(), body);
|
||||
return Ok(resp);
|
||||
}
|
||||
}
|
52
Foxchat.Identity/Controllers/UsersController.cs
Normal file
52
Foxchat.Identity/Controllers/UsersController.cs
Normal file
|
@ -0,0 +1,52 @@
|
|||
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
|
||||
{
|
||||
[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));
|
||||
}
|
||||
|
||||
[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
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
|
@ -1,10 +1,11 @@
|
|||
using Foxchat.Core;
|
||||
using Foxchat.Identity.Database;
|
||||
using Foxchat.Identity.Utils;
|
||||
using NodaTime;
|
||||
|
||||
namespace Foxchat.Identity.Middleware;
|
||||
|
||||
public class AuthorizationMiddleware(
|
||||
public class ClientAuthorizationMiddleware(
|
||||
IdentityContext db,
|
||||
IClock clock
|
||||
) : IMiddleware
|
||||
|
@ -21,10 +22,10 @@ public class AuthorizationMiddleware(
|
|||
}
|
||||
|
||||
var token = ctx.GetToken();
|
||||
if (token == null || token.Expires > clock.GetCurrentInstant())
|
||||
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.Scopes).Any())
|
||||
throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.Scopes));
|
||||
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);
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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";
|
||||
|
@ -24,4 +27,8 @@ public static class OauthUtils
|
|||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static string[] ExpandScopes(this string[] scopes) => scopes.Contains("chat_client")
|
||||
? Scopes
|
||||
: scopes;
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue