diff --git a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs
index a3854a6..1587f87 100644
--- a/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs
+++ b/Foxnouns.Backend/Controllers/Authentication/EmailAuthController.cs
@@ -36,6 +36,7 @@ public class EmailAuthController(
DatabaseContext db,
AuthService authService,
MailService mailService,
+ EmailRateLimiter rateLimiter,
KeyCacheService keyCacheService,
UserRendererService userRenderer,
IClock clock,
@@ -68,6 +69,9 @@ public class EmailAuthController(
return NoContent();
}
+ if (IsRateLimited())
+ return NoContent();
+
mailService.QueueAccountCreationEmail(req.Email, state);
return NoContent();
}
@@ -221,6 +225,9 @@ public class EmailAuthController(
return NoContent();
}
+ if (IsRateLimited())
+ return NoContent();
+
mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username);
return NoContent();
}
@@ -274,4 +281,34 @@ public class EmailAuthController(
if (!config.EmailAuth.Enabled)
throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
}
+
+ ///
+ /// Checks whether the context's IP address is rate limited from dispatching emails.
+ ///
+ private bool IsRateLimited()
+ {
+ if (HttpContext.Connection.RemoteIpAddress == null)
+ {
+ _logger.Information(
+ "No remote IP address in HTTP context for email-related request, ignoring as we can't rate limit it"
+ );
+ return true;
+ }
+
+ if (
+ !rateLimiter.IsLimited(
+ HttpContext.Connection.RemoteIpAddress.ToString(),
+ out Duration retryAfter
+ )
+ )
+ {
+ return false;
+ }
+
+ _logger.Information(
+ "IP address cannot send email until {RetryAfter}, ignoring",
+ retryAfter
+ );
+ return true;
+ }
}
diff --git a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs
index d95c622..3dcc817 100644
--- a/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs
+++ b/Foxnouns.Backend/Controllers/Authentication/FediverseAuthController.cs
@@ -161,20 +161,13 @@ public class FediverseAuthController(
[FromBody] FediverseCallbackRequest req
)
{
- await remoteAuthService.ValidateAddAccountStateAsync(
- req.State,
- CurrentUser!.Id,
- AuthType.Fediverse,
- req.Instance
- );
-
FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
FediverseAuthService.FediverseUser remoteUser =
await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code);
try
{
AuthMethod authMethod = await authService.AddAuthMethodAsync(
- CurrentUser.Id,
+ CurrentUser!.Id,
AuthType.Fediverse,
remoteUser.Id,
remoteUser.Username,
diff --git a/Foxnouns.Backend/Controllers/FlagsController.cs b/Foxnouns.Backend/Controllers/FlagsController.cs
index 0c35afd..c68fb96 100644
--- a/Foxnouns.Backend/Controllers/FlagsController.cs
+++ b/Foxnouns.Backend/Controllers/FlagsController.cs
@@ -34,7 +34,8 @@ public class FlagsController(
) : ApiControllerBase
{
[HttpGet]
- [Authorize("identify")]
+ [Limit(UsableBySuspendedUsers = true)]
+ [Authorize("user.read_flags")]
[ProducesResponseType>(statusCode: StatusCodes.Status200OK)]
public async Task GetFlagsAsync(CancellationToken ct = default)
{
@@ -50,7 +51,7 @@ public class FlagsController(
public const int MaxFlagCount = 500;
[HttpPost]
- [Authorize("user.update")]
+ [Authorize("user.update_flags")]
[ProducesResponseType(statusCode: StatusCodes.Status202Accepted)]
public async Task CreateFlagAsync([FromBody] CreateFlagRequest req)
{
@@ -79,7 +80,7 @@ public class FlagsController(
}
[HttpPatch("{id}")]
- [Authorize("user.update")]
+ [Authorize("user.create_flags")]
[ProducesResponseType(statusCode: StatusCodes.Status200OK)]
public async Task UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req)
{
@@ -104,7 +105,7 @@ public class FlagsController(
}
[HttpDelete("{id}")]
- [Authorize("user.update")]
+ [Authorize("user.update_flags")]
public async Task DeleteFlagAsync(Snowflake id)
{
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
diff --git a/Foxnouns.Backend/Controllers/MembersController.cs b/Foxnouns.Backend/Controllers/MembersController.cs
index 534c51f..9b94b30 100644
--- a/Foxnouns.Backend/Controllers/MembersController.cs
+++ b/Foxnouns.Backend/Controllers/MembersController.cs
@@ -44,6 +44,7 @@ public class MembersController(
[HttpGet]
[ProducesResponseType>(StatusCodes.Status200OK)]
+ [Limit(UsableBySuspendedUsers = true)]
public async Task GetMembersAsync(string userRef, CancellationToken ct = default)
{
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
@@ -52,6 +53,7 @@ public class MembersController(
[HttpGet("{memberRef}")]
[ProducesResponseType(StatusCodes.Status200OK)]
+ [Limit(UsableBySuspendedUsers = true)]
public async Task GetMemberAsync(
string userRef,
string memberRef,
diff --git a/Foxnouns.Backend/Controllers/UsersController.cs b/Foxnouns.Backend/Controllers/UsersController.cs
index 4a3be72..d567bdb 100644
--- a/Foxnouns.Backend/Controllers/UsersController.cs
+++ b/Foxnouns.Backend/Controllers/UsersController.cs
@@ -42,6 +42,7 @@ public class UsersController(
[HttpGet("{userRef}")]
[ProducesResponseType(statusCode: StatusCodes.Status200OK)]
+ [Limit(UsableBySuspendedUsers = true)]
public async Task GetUserAsync(string userRef, CancellationToken ct = default)
{
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
diff --git a/Foxnouns.Backend/Database/DatabaseContext.cs b/Foxnouns.Backend/Database/DatabaseContext.cs
index a5dede8..ddf7853 100644
--- a/Foxnouns.Backend/Database/DatabaseContext.cs
+++ b/Foxnouns.Backend/Database/DatabaseContext.cs
@@ -84,6 +84,9 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
modelBuilder.Entity().HasIndex(m => new { m.UserId, m.Name }).IsUnique();
modelBuilder.Entity().HasIndex(m => m.Sid).IsUnique();
modelBuilder.Entity().HasIndex(k => k.Key).IsUnique();
+ modelBuilder.Entity().HasIndex(d => d.Filename).IsUnique();
+
+ // Two indexes on auth_methods, one for fediverse auth and one for all other types.
modelBuilder
.Entity()
.HasIndex(m => new
@@ -94,7 +97,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
})
.HasFilter("fediverse_application_id IS NOT NULL")
.IsUnique();
- modelBuilder.Entity().HasIndex(d => d.Filename).IsUnique();
modelBuilder
.Entity()
diff --git a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs
index 790b9df..d804dfe 100644
--- a/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs
+++ b/Foxnouns.Backend/Database/DatabaseQueryExtensions.cs
@@ -31,6 +31,7 @@ public static class DatabaseQueryExtensions
{
if (userRef == "@me")
{
+ // Not filtering deleted users, as a suspended user should still be able to look at their own profile.
return token != null
? await context.Users.FirstAsync(u => u.Id == token.UserId, ct)
: throw new ApiError.Unauthorized(
@@ -43,14 +44,14 @@ public static class DatabaseQueryExtensions
if (Snowflake.TryParse(userRef, out Snowflake? snowflake))
{
user = await context
- .Users.Where(u => !u.Deleted)
+ .Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id))
.FirstOrDefaultAsync(u => u.Id == snowflake, ct);
if (user != null)
return user;
}
user = await context
- .Users.Where(u => !u.Deleted)
+ .Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id))
.FirstOrDefaultAsync(u => u.Username == userRef, ct);
if (user != null)
return user;
@@ -98,13 +99,14 @@ public static class DatabaseQueryExtensions
)
{
User user = await context.ResolveUserAsync(userRef, token, ct);
- return await context.ResolveMemberAsync(user.Id, memberRef, ct);
+ return await context.ResolveMemberAsync(user.Id, memberRef, token, ct);
}
public static async Task ResolveMemberAsync(
this DatabaseContext context,
Snowflake userId,
string memberRef,
+ Token? token = null,
CancellationToken ct = default
)
{
@@ -114,7 +116,8 @@ public static class DatabaseQueryExtensions
member = await context
.Members.Include(m => m.User)
.Include(m => m.ProfileFlags)
- .Where(m => !m.User.Deleted)
+ // Return members if their user isn't deleted or the user querying it is the member's owner
+ .Where(m => !m.User.Deleted || (token != null && token.UserId == m.UserId))
.FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct);
if (member != null)
return member;
@@ -123,7 +126,8 @@ public static class DatabaseQueryExtensions
member = await context
.Members.Include(m => m.User)
.Include(m => m.ProfileFlags)
- .Where(m => !m.User.Deleted)
+ // Return members if their user isn't deleted or the user querying it is the member's owner
+ .Where(m => !m.User.Deleted || (token != null && token.UserId == m.UserId))
.FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct);
if (member != null)
return member;
diff --git a/Foxnouns.Backend/Database/Migrations/20241211193653_AddSentEmailCache.cs b/Foxnouns.Backend/Database/Migrations/20241211193653_AddSentEmailCache.cs
new file mode 100644
index 0000000..e0fe00d
--- /dev/null
+++ b/Foxnouns.Backend/Database/Migrations/20241211193653_AddSentEmailCache.cs
@@ -0,0 +1,53 @@
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using NodaTime;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+
+#nullable disable
+
+namespace Foxnouns.Backend.Database.Migrations
+{
+ ///
+ [DbContext(typeof(DatabaseContext))]
+ [Migration("20241211193653_AddSentEmailCache")]
+ public partial class AddSentEmailCache : Migration
+ {
+ ///
+ protected override void Up(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.CreateTable(
+ name: "sent_emails",
+ columns: table => new
+ {
+ id = table
+ .Column(type: "integer", nullable: false)
+ .Annotation(
+ "Npgsql:ValueGenerationStrategy",
+ NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
+ ),
+ email = table.Column(type: "text", nullable: false),
+ sent_at = table.Column(
+ type: "timestamp with time zone",
+ nullable: false
+ ),
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("pk_sent_emails", x => x.id);
+ }
+ );
+
+ migrationBuilder.CreateIndex(
+ name: "ix_sent_emails_email_sent_at",
+ table: "sent_emails",
+ columns: new[] { "email", "sent_at" }
+ );
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(name: "sent_emails");
+ }
+ }
+}
diff --git a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs
index 4bd1ede..cfe2513 100644
--- a/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs
+++ b/Foxnouns.Backend/Database/Migrations/DatabaseContextModelSnapshot.cs
@@ -1,5 +1,4 @@
//
-using System;
using System.Collections.Generic;
using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models;
@@ -20,7 +19,7 @@ namespace Foxnouns.Backend.Database.Migrations
{
#pragma warning disable 612, 618
modelBuilder
- .HasAnnotation("ProductVersion", "8.0.7")
+ .HasAnnotation("ProductVersion", "9.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -46,12 +45,12 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("text")
.HasColumnName("name");
- b.Property("RedirectUris")
+ b.PrimitiveCollection("RedirectUris")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("redirect_uris");
- b.Property("Scopes")
+ b.PrimitiveCollection("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
@@ -193,7 +192,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("jsonb")
.HasColumnName("fields");
- b.Property("Links")
+ b.PrimitiveCollection("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
@@ -359,7 +358,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("boolean")
.HasColumnName("manually_expired");
- b.Property("Scopes")
+ b.PrimitiveCollection("Scopes")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("scopes");
@@ -428,7 +427,7 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("timestamp with time zone")
.HasColumnName("last_sid_reroll");
- b.Property("Links")
+ b.PrimitiveCollection("Links")
.IsRequired()
.HasColumnType("text[]")
.HasColumnName("links");
diff --git a/Foxnouns.Backend/Dto/Auth.cs b/Foxnouns.Backend/Dto/Auth.cs
index 05308a5..ea9e67d 100644
--- a/Foxnouns.Backend/Dto/Auth.cs
+++ b/Foxnouns.Backend/Dto/Auth.cs
@@ -59,4 +59,4 @@ public record EmailCallbackRequest(string State);
public record EmailChangePasswordRequest(string Current, string New);
-public record FediverseCallbackRequest(string Instance, string Code, string State);
+public record FediverseCallbackRequest(string Instance, string Code, string? State = null);
diff --git a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs
index 1d99830..d7e8784 100644
--- a/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs
+++ b/Foxnouns.Backend/Extensions/KeyCacheExtensions.cs
@@ -91,6 +91,34 @@ public static class KeyCacheExtensions
string state,
CancellationToken ct = default
) => await keyCacheService.GetKeyAsync($"add_account:{state}", true, ct);
+
+ public static async Task GenerateForgotPasswordStateAsync(
+ this KeyCacheService keyCacheService,
+ string email,
+ Snowflake userId,
+ CancellationToken ct = default
+ )
+ {
+ string state = AuthUtils.RandomToken();
+ await keyCacheService.SetKeyAsync(
+ $"forgot_password:{state}",
+ new ForgotPasswordState(email, userId),
+ Duration.FromHours(1),
+ ct
+ );
+ return state;
+ }
+
+ public static async Task GetForgotPasswordStateAsync(
+ this KeyCacheService keyCacheService,
+ string state,
+ CancellationToken ct = default
+ ) =>
+ await keyCacheService.GetKeyAsync(
+ $"forgot_password:{state}",
+ true,
+ ct
+ );
}
public record RegisterEmailState(
@@ -98,4 +126,6 @@ public record RegisterEmailState(
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId
);
+public record ForgotPasswordState(string Email, Snowflake UserId);
+
public record AddExtraAccountState(AuthType AuthType, Snowflake UserId, string? Instance = null);
diff --git a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs
index 30d97d8..41c9712 100644
--- a/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs
+++ b/Foxnouns.Backend/Extensions/WebApplicationExtensions.cs
@@ -110,6 +110,7 @@ public static class WebApplicationExtensions
.AddSingleton(SystemClock.Instance)
.AddSnowflakeGenerator()
.AddSingleton()
+ .AddSingleton()
.AddScoped()
.AddScoped()
.AddScoped()
diff --git a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs
index 1132dc1..908598a 100644
--- a/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs
+++ b/Foxnouns.Backend/Middleware/AuthorizationMiddleware.cs
@@ -14,6 +14,7 @@
// along with this program. If not, see .
using Foxnouns.Backend.Database.Models;
using Foxnouns.Backend.Utils;
+using Microsoft.AspNetCore.Mvc.ViewFeatures;
namespace Foxnouns.Backend.Middleware;
@@ -22,9 +23,11 @@ public class AuthorizationMiddleware : IMiddleware
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
{
Endpoint? endpoint = ctx.GetEndpoint();
- AuthorizeAttribute? attribute = endpoint?.Metadata.GetMetadata();
+ AuthorizeAttribute? authorizeAttribute =
+ endpoint?.Metadata.GetMetadata();
+ LimitAttribute? limitAttribute = endpoint?.Metadata.GetMetadata();
- if (attribute == null)
+ if (authorizeAttribute == null || authorizeAttribute.Scopes.Length == 0)
{
await next(ctx);
return;
@@ -39,24 +42,35 @@ public class AuthorizationMiddleware : IMiddleware
);
}
+ // Users who got suspended by a moderator can still access *some* endpoints.
if (
- attribute.Scopes.Length > 0
- && attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()
+ token.User.Deleted
+ && (limitAttribute?.UsableBySuspendedUsers != true || token.User.DeletedBy == null)
+ )
+ {
+ throw new ApiError.Forbidden("Deleted users cannot access this endpoint.");
+ }
+
+ if (
+ authorizeAttribute.Scopes.Length > 0
+ && authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()
)
{
throw new ApiError.Forbidden(
"This endpoint requires ungranted scopes.",
- attribute.Scopes.Except(token.Scopes.ExpandScopes()),
+ authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()),
ErrorCode.MissingScopes
);
}
- if (attribute.RequireAdmin && token.User.Role != UserRole.Admin)
+ if (limitAttribute?.RequireAdmin == true && token.User.Role != UserRole.Admin)
+ {
throw new ApiError.Forbidden("This endpoint can only be used by admins.");
+ }
+
if (
- attribute.RequireModerator
- && token.User.Role != UserRole.Admin
- && token.User.Role != UserRole.Moderator
+ limitAttribute?.RequireModerator == true
+ && token.User.Role is not (UserRole.Admin or UserRole.Moderator)
)
{
throw new ApiError.Forbidden("This endpoint can only be used by moderators.");
@@ -69,8 +83,13 @@ public class AuthorizationMiddleware : IMiddleware
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
public class AuthorizeAttribute(params string[] scopes) : Attribute
{
- public readonly bool RequireAdmin = scopes.Contains(":admin");
- public readonly bool RequireModerator = scopes.Contains(":moderator");
-
- public readonly string[] Scopes = scopes.Except([":admin", ":moderator"]).ToArray();
+ public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray();
+}
+
+[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
+public class LimitAttribute : Attribute
+{
+ public bool UsableBySuspendedUsers { get; init; }
+ public bool RequireAdmin { get; init; }
+ public bool RequireModerator { get; init; }
}
diff --git a/Foxnouns.Backend/Program.cs b/Foxnouns.Backend/Program.cs
index 3597ae7..66e57a6 100644
--- a/Foxnouns.Backend/Program.cs
+++ b/Foxnouns.Backend/Program.cs
@@ -66,9 +66,11 @@ builder
})
.ConfigureApiBehaviorOptions(options =>
{
- options.InvalidModelStateResponseFactory = actionContext => new BadRequestObjectResult(
- new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson()
- );
+ // the type isn't needed but without it, rider keeps complaining for no reason (it compiles just fine)
+ options.InvalidModelStateResponseFactory = (ActionContext actionContext) =>
+ new BadRequestObjectResult(
+ new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson()
+ );
});
builder.Services.AddOpenApi(
diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs
new file mode 100644
index 0000000..beff74a
--- /dev/null
+++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.Misskey.cs
@@ -0,0 +1,170 @@
+// Copyright (C) 2023-present sam/u1f320 (vulpine.solutions)
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published
+// by the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+using System.Net;
+using System.Text.Json.Serialization;
+using System.Web;
+using Foxnouns.Backend.Database;
+using Foxnouns.Backend.Database.Models;
+using Foxnouns.Backend.Extensions;
+
+namespace Foxnouns.Backend.Services.Auth;
+
+public partial class FediverseAuthService
+{
+ private static string MisskeyAppUri(string instance) => $"https://{instance}/api/app/create";
+
+ private static string MisskeyTokenUri(string instance) =>
+ $"https://{instance}/api/auth/session/userkey";
+
+ private static string MisskeyGenerateSessionUri(string instance) =>
+ $"https://{instance}/api/auth/session/generate";
+
+ private async Task CreateMisskeyApplicationAsync(
+ string instance,
+ Snowflake? existingAppId = null
+ )
+ {
+ HttpResponseMessage resp = await _client.PostAsJsonAsync(
+ MisskeyAppUri(instance),
+ new CreateMisskeyApplicationRequest(
+ $"pronouns.cc (+{_config.BaseUrl})",
+ $"pronouns.cc on {_config.BaseUrl}",
+ ["read:account"],
+ MastodonRedirectUri(instance)
+ )
+ );
+ resp.EnsureSuccessStatusCode();
+
+ PartialMisskeyApplication? misskeyApp =
+ await resp.Content.ReadFromJsonAsync();
+ if (misskeyApp == null)
+ {
+ throw new FoxnounsError(
+ $"Application created on Misskey-compatible instance {instance} was null"
+ );
+ }
+
+ FediverseApplication app;
+
+ if (existingAppId == null)
+ {
+ app = new FediverseApplication
+ {
+ Id = existingAppId ?? _snowflakeGenerator.GenerateSnowflake(),
+ ClientId = misskeyApp.Id,
+ ClientSecret = misskeyApp.Secret,
+ Domain = instance,
+ InstanceType = FediverseInstanceType.MisskeyApi,
+ };
+
+ _db.Add(app);
+ }
+ else
+ {
+ app =
+ await _db.FediverseApplications.FindAsync(existingAppId)
+ ?? throw new FoxnounsError($"Existing app with ID {existingAppId} was null");
+
+ app.ClientId = misskeyApp.Id;
+ app.ClientSecret = misskeyApp.Secret;
+ app.InstanceType = FediverseInstanceType.MisskeyApi;
+ }
+
+ await _db.SaveChangesAsync();
+
+ return app;
+ }
+
+ private record GetMisskeySessionUserKeyRequest(
+ [property: JsonPropertyName("appSecret")] string Secret,
+ [property: JsonPropertyName("token")] string Token
+ );
+
+ private record GetMisskeySessionUserKeyResponse(
+ [property: JsonPropertyName("user")] FediverseUser User
+ );
+
+ private async Task GetMisskeyUserAsync(FediverseApplication app, string code)
+ {
+ HttpResponseMessage resp = await _client.PostAsJsonAsync(
+ MisskeyTokenUri(app.Domain),
+ new GetMisskeySessionUserKeyRequest(app.ClientSecret, code)
+ );
+ if (resp.StatusCode == HttpStatusCode.Unauthorized)
+ {
+ throw new FoxnounsError($"Application for instance {app.Domain} was invalid");
+ }
+
+ resp.EnsureSuccessStatusCode();
+ GetMisskeySessionUserKeyResponse? userResp =
+ await resp.Content.ReadFromJsonAsync();
+ if (userResp == null)
+ {
+ throw new FoxnounsError($"User response from instance {app.Domain} was invalid");
+ }
+
+ return userResp.User;
+ }
+
+ private async Task GenerateMisskeyAuthUrlAsync(
+ FediverseApplication app,
+ bool forceRefresh,
+ string? state = null
+ )
+ {
+ if (forceRefresh)
+ {
+ _logger.Information(
+ "An app credentials refresh was requested for {ApplicationId}, creating a new application",
+ app.Id
+ );
+ app = await CreateMisskeyApplicationAsync(app.Domain, app.Id);
+ }
+
+ HttpResponseMessage resp = await _client.PostAsJsonAsync(
+ MisskeyGenerateSessionUri(app.Domain),
+ new CreateMisskeySessionUriRequest(app.ClientSecret)
+ );
+ resp.EnsureSuccessStatusCode();
+
+ CreateMisskeySessionUriResponse? misskeyResp =
+ await resp.Content.ReadFromJsonAsync();
+ if (misskeyResp == null)
+ throw new FoxnounsError($"Session create response for app {app.Id} was null");
+
+ return misskeyResp.Url;
+ }
+
+ private record CreateMisskeySessionUriRequest(
+ [property: JsonPropertyName("appSecret")] string Secret
+ );
+
+ private record CreateMisskeySessionUriResponse(
+ [property: JsonPropertyName("token")] string Token,
+ [property: JsonPropertyName("url")] string Url
+ );
+
+ private record CreateMisskeyApplicationRequest(
+ [property: JsonPropertyName("name")] string Name,
+ [property: JsonPropertyName("description")] string Description,
+ [property: JsonPropertyName("permission")] string[] Permissions,
+ [property: JsonPropertyName("callbackUrl")] string CallbackUrl
+ );
+
+ private record PartialMisskeyApplication(
+ [property: JsonPropertyName("id")] string Id,
+ [property: JsonPropertyName("secret")] string Secret
+ );
+}
diff --git a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs
index 7e67fa7..250455a 100644
--- a/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs
+++ b/Foxnouns.Backend/Services/Auth/FediverseAuthService.cs
@@ -81,11 +81,11 @@ public partial class FediverseAuthService
string softwareName = await GetSoftwareNameAsync(instance);
if (IsMastodonCompatible(softwareName))
- {
return await CreateMastodonApplicationAsync(instance);
- }
+ if (IsMisskeyCompatible(softwareName))
+ return await CreateMisskeyApplicationAsync(instance);
- throw new NotImplementedException();
+ throw new ApiError.BadRequest($"{softwareName} is not a supported instance type, sorry.");
}
private async Task GetSoftwareNameAsync(string instance)
@@ -129,7 +129,11 @@ public partial class FediverseAuthService
forceRefresh,
state
),
- FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
+ FediverseInstanceType.MisskeyApi => await GenerateMisskeyAuthUrlAsync(
+ app,
+ forceRefresh,
+ state
+ ),
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
};
@@ -141,7 +145,7 @@ public partial class FediverseAuthService
app.InstanceType switch
{
FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code, state),
- FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
+ FediverseInstanceType.MisskeyApi => await GetMisskeyUserAsync(app, code),
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
};
diff --git a/Foxnouns.Backend/Services/DataCleanupService.cs b/Foxnouns.Backend/Services/DataCleanupService.cs
index 2b42f86..3d60462 100644
--- a/Foxnouns.Backend/Services/DataCleanupService.cs
+++ b/Foxnouns.Backend/Services/DataCleanupService.cs
@@ -33,10 +33,10 @@ public class DataCleanupService(
public async Task InvokeAsync(CancellationToken ct = default)
{
- _logger.Information("Cleaning up expired users");
+ _logger.Debug("Cleaning up expired users");
await CleanUsersAsync(ct);
- _logger.Information("Cleaning up expired data exports");
+ _logger.Debug("Cleaning up expired data exports");
await CleanExportsAsync(ct);
}
diff --git a/Foxnouns.Backend/Services/EmailRateLimiter.cs b/Foxnouns.Backend/Services/EmailRateLimiter.cs
new file mode 100644
index 0000000..9e73792
--- /dev/null
+++ b/Foxnouns.Backend/Services/EmailRateLimiter.cs
@@ -0,0 +1,36 @@
+using System.Collections.Concurrent;
+using System.Threading.RateLimiting;
+using NodaTime;
+using NodaTime.Extensions;
+
+namespace Foxnouns.Backend.Services;
+
+public class EmailRateLimiter
+{
+ private readonly ConcurrentDictionary _limiters = new();
+
+ private readonly FixedWindowRateLimiterOptions _limiterOptions =
+ new() { Window = TimeSpan.FromHours(2), PermitLimit = 3 };
+
+ private RateLimiter GetLimiter(string bucket) =>
+ _limiters.GetOrAdd(bucket, _ => new FixedWindowRateLimiter(_limiterOptions));
+
+ public bool IsLimited(string bucket, out Duration retryAfter)
+ {
+ RateLimiter limiter = GetLimiter(bucket);
+ RateLimitLease lease = limiter.AttemptAcquire();
+
+ if (!lease.IsAcquired)
+ {
+ retryAfter = lease.TryGetMetadata(MetadataName.RetryAfter, out TimeSpan timeSpan)
+ ? timeSpan.ToDuration()
+ : default;
+ }
+ else
+ {
+ retryAfter = Duration.Zero;
+ }
+
+ return !lease.IsAcquired;
+ }
+}
diff --git a/Foxnouns.Backend/Services/MailService.cs b/Foxnouns.Backend/Services/MailService.cs
index 9ae61bd..a1444d9 100644
--- a/Foxnouns.Backend/Services/MailService.cs
+++ b/Foxnouns.Backend/Services/MailService.cs
@@ -12,6 +12,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see .
+using Coravel.Mailer.Mail;
using Coravel.Mailer.Mail.Interfaces;
using Coravel.Queuing.Interfaces;
using Foxnouns.Backend.Mailables;
@@ -26,25 +27,18 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
{
queue.QueueAsyncTask(async () =>
{
- _logger.Debug("Sending account creation email to {ToEmail}", to);
- try
- {
- await mailer.SendAsync(
- new AccountCreationMailable(
- config,
- new AccountCreationMailableView
- {
- BaseUrl = config.BaseUrl,
- To = to,
- Code = code,
- }
- )
- );
- }
- catch (Exception exc)
- {
- _logger.Error(exc, "Sending account creation email");
- }
+ await SendEmailAsync(
+ to,
+ new AccountCreationMailable(
+ config,
+ new AccountCreationMailableView
+ {
+ BaseUrl = config.BaseUrl,
+ To = to,
+ Code = code,
+ }
+ )
+ );
});
}
@@ -53,25 +47,31 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
_logger.Debug("Sending add email address email to {ToEmail}", to);
queue.QueueAsyncTask(async () =>
{
- try
- {
- await mailer.SendAsync(
- new AddEmailMailable(
- config,
- new AddEmailMailableView
- {
- BaseUrl = config.BaseUrl,
- To = to,
- Code = code,
- Username = username,
- }
- )
- );
- }
- catch (Exception exc)
- {
- _logger.Error(exc, "Sending add email address email");
- }
+ await SendEmailAsync(
+ to,
+ new AddEmailMailable(
+ config,
+ new AddEmailMailableView
+ {
+ BaseUrl = config.BaseUrl,
+ To = to,
+ Code = code,
+ Username = username,
+ }
+ )
+ );
});
}
+
+ private async Task SendEmailAsync(string to, Mailable mailable)
+ {
+ try
+ {
+ await mailer.SendAsync(mailable);
+ }
+ catch (Exception exc)
+ {
+ _logger.Error(exc, "Sending email");
+ }
+ }
}
diff --git a/Foxnouns.Backend/Utils/AuthUtils.cs b/Foxnouns.Backend/Utils/AuthUtils.cs
index bcc2700..491694a 100644
--- a/Foxnouns.Backend/Utils/AuthUtils.cs
+++ b/Foxnouns.Backend/Utils/AuthUtils.cs
@@ -35,6 +35,9 @@ public static class AuthUtils
"user.read_hidden",
"user.read_privileged",
"user.update",
+ "user.read_flags",
+ "user.create_flags",
+ "user.update_flags",
];
public static readonly string[] MemberScopes =
diff --git a/Foxnouns.Frontend/src/app.html b/Foxnouns.Frontend/src/app.html
index 77a5ff5..1391f88 100644
--- a/Foxnouns.Frontend/src/app.html
+++ b/Foxnouns.Frontend/src/app.html
@@ -2,7 +2,7 @@
-
+
%sveltekit.head%
diff --git a/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts b/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts
index fff5322..b092b1e 100644
--- a/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts
+++ b/Foxnouns.Frontend/src/routes/auth/callback/mastodon/[instance]/+page.server.ts
@@ -5,9 +5,10 @@ import createRegisterAction from "$lib/actions/register";
export const load = createCallbackLoader("fediverse", async ({ params, url }) => {
const code = url.searchParams.get("code") as string | null;
const state = url.searchParams.get("state") as string | null;
- if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
+ const token = url.searchParams.get("token") as string | null;
+ if ((!code || !state) && !token) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
- return { code, state, instance: params.instance! };
+ return { code: code || token, state, instance: params.instance! };
});
export const actions = {
diff --git a/Foxnouns.Frontend/static/favicon.png b/Foxnouns.Frontend/static/favicon.png
deleted file mode 100644
index 825b9e6..0000000
Binary files a/Foxnouns.Frontend/static/favicon.png and /dev/null differ