diff --git a/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs
new file mode 100644
index 0000000..745ab75
--- /dev/null
+++ b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.Designer.cs
@@ -0,0 +1,329 @@
+//
+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
+ {
+ ///
+ 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("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("GuildId")
+ .HasColumnType("uuid")
+ .HasColumnName("guild_id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("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("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Name")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("name");
+
+ b.Property("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("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("BaseUrl")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("base_url");
+
+ b.Property("Domain")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("domain");
+
+ b.Property("PublicKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("public_key");
+
+ b.Property("Reason")
+ .HasColumnType("text")
+ .HasColumnName("reason");
+
+ b.Property("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("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AuthorId")
+ .HasColumnType("uuid")
+ .HasColumnName("author_id");
+
+ b.Property("ChannelId")
+ .HasColumnType("uuid")
+ .HasColumnName("channel_id");
+
+ b.Property("Content")
+ .HasColumnType("text")
+ .HasColumnName("content");
+
+ b.Property("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("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("Avatar")
+ .HasColumnType("text")
+ .HasColumnName("avatar");
+
+ b.Property("InstanceId")
+ .HasColumnType("uuid")
+ .HasColumnName("instance_id");
+
+ b.Property("LastFetchedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_fetched_at");
+
+ b.Property("RemoteUserId")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("remote_user_id");
+
+ b.Property("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("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasColumnName("id");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("PrivateKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("private_key");
+
+ b.Property("PublicKey")
+ .IsRequired()
+ .HasColumnType("text")
+ .HasColumnName("public_key");
+
+ b.HasKey("Id")
+ .HasName("pk_instance");
+
+ b.ToTable("instance", (string)null);
+ });
+
+ modelBuilder.Entity("GuildUser", b =>
+ {
+ b.Property("GuildsId")
+ .HasColumnType("uuid")
+ .HasColumnName("guilds_id");
+
+ b.Property("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
+ }
+ }
+}
diff --git a/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.cs b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.cs
new file mode 100644
index 0000000..f4a99ba
--- /dev/null
+++ b/Foxchat.Chat/Migrations/20240521191115_AddLastFetchedAtToUsers.cs
@@ -0,0 +1,30 @@
+using Microsoft.EntityFrameworkCore.Migrations;
+using NodaTime;
+
+#nullable disable
+
+namespace Foxchat.Chat.Migrations
+{
+ ///
+ public partial class AddLastFetchedAtToUsers : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.AddColumn(
+ name: "last_fetched_at",
+ table: "users",
+ type: "timestamp with time zone",
+ nullable: false,
+ defaultValue: NodaTime.Instant.FromUnixTimeTicks(0L));
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropColumn(
+ name: "last_fetched_at",
+ table: "users");
+ }
+ }
+}
diff --git a/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs b/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs
index f560113..184e756 100644
--- a/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs
+++ b/Foxchat.Chat/Migrations/ChatContextModelSnapshot.cs
@@ -162,6 +162,10 @@ namespace Foxchat.Chat.Migrations
.HasColumnType("uuid")
.HasColumnName("instance_id");
+ b.Property("LastFetchedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("last_fetched_at");
+
b.Property("RemoteUserId")
.IsRequired()
.HasColumnType("text")
diff --git a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs
index 796edb9..a4f5080 100644
--- a/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs
+++ b/Foxchat.Identity/Controllers/Oauth/PasswordAuthController.cs
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Identity;
using Foxchat.Identity.Database.Models;
using Foxchat.Core;
using System.Diagnostics;
+using Foxchat.Identity.Utils;
using NodaTime;
using Microsoft.EntityFrameworkCore;
@@ -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.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.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");
diff --git a/Foxchat.Identity/Controllers/Oauth/TokenController.cs b/Foxchat.Identity/Controllers/Oauth/TokenController.cs
index b01ee77..ed7dfc8 100644
--- a/Foxchat.Identity/Controllers/Oauth/TokenController.cs
+++ b/Foxchat.Identity/Controllers/Oauth/TokenController.cs
@@ -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,11 +15,12 @@ public class TokenController(ILogger logger, IdentityContext db, IClock clock) :
public async Task PostToken([FromBody] PostTokenRequest req)
{
var app = await db.GetApplicationAsync(req.ClientId, req.ClientSecret);
+ var appScopes = app.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)
diff --git a/Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs b/Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs
new file mode 100644
index 0000000..9b5a8d4
--- /dev/null
+++ b/Foxchat.Identity/Controllers/Proxy/GuildsProxyController.cs
@@ -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 CreateGuild([FromBody] GuildsApi.CreateGuildRequest req) =>
+ Proxy(HttpMethod.Post, req);
+}
\ No newline at end of file
diff --git a/Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs b/Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs
new file mode 100644
index 0000000..4728ca4
--- /dev/null
+++ b/Foxchat.Identity/Controllers/Proxy/ProxyControllerBase.cs
@@ -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 Proxy(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 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(method, server, path, acct.Id.ToString(), body);
+ return Ok(resp);
+ }
+}
\ No newline at end of file
diff --git a/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs
index 92df517..701fb05 100644
--- a/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs
+++ b/Foxchat.Identity/Middleware/ClientAuthorizationMiddleware.cs
@@ -1,5 +1,6 @@
using Foxchat.Core;
using Foxchat.Identity.Database;
+using Foxchat.Identity.Utils;
using NodaTime;
namespace Foxchat.Identity.Middleware;
@@ -21,10 +22,10 @@ public class ClientAuthorizationMiddleware(
}
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.ExpandScopes()).Any())
+ throw new ApiError.Forbidden("This endpoint requires ungranted scopes.", attribute.Scopes.Except(token.ExpandScopes()));
await next(ctx);
}
diff --git a/Foxchat.Identity/Utils/OauthUtils.cs b/Foxchat.Identity/Utils/OauthUtils.cs
index 26188bb..bf698a2 100644
--- a/Foxchat.Identity/Utils/OauthUtils.cs
+++ b/Foxchat.Identity/Utils/OauthUtils.cs
@@ -24,4 +24,12 @@ public static class OauthUtils
return false;
}
}
-}
+
+ public static string[] ExpandScopes(this Token token) => token.Scopes.Contains("chat_client")
+ ? Scopes
+ : token.Scopes;
+
+ public static string[] ExpandScopes(this Application app) => app.Scopes.Contains("chat_client")
+ ? Scopes
+ : app.Scopes;
+}
\ No newline at end of file