Compare commits
No commits in common. "77c3047b1ef02b9112ba2f90c11b4bb1ff9229bc" and "7f8e72e857f67d6dbc3b607cffc4dcdc432eb28a" have entirely different histories.
77c3047b1e
...
7f8e72e857
23 changed files with 89 additions and 447 deletions
|
@ -36,7 +36,6 @@ public class EmailAuthController(
|
||||||
DatabaseContext db,
|
DatabaseContext db,
|
||||||
AuthService authService,
|
AuthService authService,
|
||||||
MailService mailService,
|
MailService mailService,
|
||||||
EmailRateLimiter rateLimiter,
|
|
||||||
KeyCacheService keyCacheService,
|
KeyCacheService keyCacheService,
|
||||||
UserRendererService userRenderer,
|
UserRendererService userRenderer,
|
||||||
IClock clock,
|
IClock clock,
|
||||||
|
@ -69,9 +68,6 @@ public class EmailAuthController(
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsRateLimited())
|
|
||||||
return NoContent();
|
|
||||||
|
|
||||||
mailService.QueueAccountCreationEmail(req.Email, state);
|
mailService.QueueAccountCreationEmail(req.Email, state);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
@ -225,9 +221,6 @@ public class EmailAuthController(
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsRateLimited())
|
|
||||||
return NoContent();
|
|
||||||
|
|
||||||
mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username);
|
mailService.QueueAddEmailAddressEmail(req.Email, state, CurrentUser.Username);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
@ -281,34 +274,4 @@ public class EmailAuthController(
|
||||||
if (!config.EmailAuth.Enabled)
|
if (!config.EmailAuth.Enabled)
|
||||||
throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
|
throw new ApiError.BadRequest("Email authentication is not enabled on this instance.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Checks whether the context's IP address is rate limited from dispatching emails.
|
|
||||||
/// </summary>
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -161,13 +161,20 @@ public class FediverseAuthController(
|
||||||
[FromBody] FediverseCallbackRequest req
|
[FromBody] FediverseCallbackRequest req
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
await remoteAuthService.ValidateAddAccountStateAsync(
|
||||||
|
req.State,
|
||||||
|
CurrentUser!.Id,
|
||||||
|
AuthType.Fediverse,
|
||||||
|
req.Instance
|
||||||
|
);
|
||||||
|
|
||||||
FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
|
FediverseApplication app = await fediverseAuthService.GetApplicationAsync(req.Instance);
|
||||||
FediverseAuthService.FediverseUser remoteUser =
|
FediverseAuthService.FediverseUser remoteUser =
|
||||||
await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code);
|
await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
AuthMethod authMethod = await authService.AddAuthMethodAsync(
|
AuthMethod authMethod = await authService.AddAuthMethodAsync(
|
||||||
CurrentUser!.Id,
|
CurrentUser.Id,
|
||||||
AuthType.Fediverse,
|
AuthType.Fediverse,
|
||||||
remoteUser.Id,
|
remoteUser.Id,
|
||||||
remoteUser.Username,
|
remoteUser.Username,
|
||||||
|
|
|
@ -34,8 +34,7 @@ public class FlagsController(
|
||||||
) : ApiControllerBase
|
) : ApiControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
[Authorize("identify")]
|
||||||
[Authorize("user.read_flags")]
|
|
||||||
[ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<IEnumerable<PrideFlagResponse>>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
|
public async Task<IActionResult> GetFlagsAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
@ -51,7 +50,7 @@ public class FlagsController(
|
||||||
public const int MaxFlagCount = 500;
|
public const int MaxFlagCount = 500;
|
||||||
|
|
||||||
[HttpPost]
|
[HttpPost]
|
||||||
[Authorize("user.update_flags")]
|
[Authorize("user.update")]
|
||||||
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)]
|
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status202Accepted)]
|
||||||
public async Task<IActionResult> CreateFlagAsync([FromBody] CreateFlagRequest req)
|
public async Task<IActionResult> CreateFlagAsync([FromBody] CreateFlagRequest req)
|
||||||
{
|
{
|
||||||
|
@ -80,7 +79,7 @@ public class FlagsController(
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("{id}")]
|
[HttpPatch("{id}")]
|
||||||
[Authorize("user.create_flags")]
|
[Authorize("user.update")]
|
||||||
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<PrideFlagResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
public async Task<IActionResult> UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req)
|
public async Task<IActionResult> UpdateFlagAsync(Snowflake id, [FromBody] UpdateFlagRequest req)
|
||||||
{
|
{
|
||||||
|
@ -105,7 +104,7 @@ public class FlagsController(
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpDelete("{id}")]
|
[HttpDelete("{id}")]
|
||||||
[Authorize("user.update_flags")]
|
[Authorize("user.update")]
|
||||||
public async Task<IActionResult> DeleteFlagAsync(Snowflake id)
|
public async Task<IActionResult> DeleteFlagAsync(Snowflake id)
|
||||||
{
|
{
|
||||||
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
PrideFlag? flag = await db.PrideFlags.FirstOrDefaultAsync(f =>
|
||||||
|
|
|
@ -44,7 +44,6 @@ public class MembersController(
|
||||||
|
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
|
[ProducesResponseType<IEnumerable<PartialMember>>(StatusCodes.Status200OK)]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
|
||||||
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
public async Task<IActionResult> GetMembersAsync(string userRef, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||||
|
@ -53,7 +52,6 @@ public class MembersController(
|
||||||
|
|
||||||
[HttpGet("{memberRef}")]
|
[HttpGet("{memberRef}")]
|
||||||
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
[ProducesResponseType<MemberResponse>(StatusCodes.Status200OK)]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
|
||||||
public async Task<IActionResult> GetMemberAsync(
|
public async Task<IActionResult> GetMemberAsync(
|
||||||
string userRef,
|
string userRef,
|
||||||
string memberRef,
|
string memberRef,
|
||||||
|
|
|
@ -42,7 +42,6 @@ public class UsersController(
|
||||||
|
|
||||||
[HttpGet("{userRef}")]
|
[HttpGet("{userRef}")]
|
||||||
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
|
[ProducesResponseType<UserResponse>(statusCode: StatusCodes.Status200OK)]
|
||||||
[Limit(UsableBySuspendedUsers = true)]
|
|
||||||
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
public async Task<IActionResult> GetUserAsync(string userRef, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
User user = await db.ResolveUserAsync(userRef, CurrentToken, ct);
|
||||||
|
|
|
@ -84,9 +84,6 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
||||||
modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique();
|
modelBuilder.Entity<Member>().HasIndex(m => new { m.UserId, m.Name }).IsUnique();
|
||||||
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique();
|
modelBuilder.Entity<Member>().HasIndex(m => m.Sid).IsUnique();
|
||||||
modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique();
|
modelBuilder.Entity<TemporaryKey>().HasIndex(k => k.Key).IsUnique();
|
||||||
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
|
|
||||||
|
|
||||||
// Two indexes on auth_methods, one for fediverse auth and one for all other types.
|
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.Entity<AuthMethod>()
|
.Entity<AuthMethod>()
|
||||||
.HasIndex(m => new
|
.HasIndex(m => new
|
||||||
|
@ -97,6 +94,7 @@ public class DatabaseContext(DbContextOptions options) : DbContext(options)
|
||||||
})
|
})
|
||||||
.HasFilter("fediverse_application_id IS NOT NULL")
|
.HasFilter("fediverse_application_id IS NOT NULL")
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
modelBuilder.Entity<DataExport>().HasIndex(d => d.Filename).IsUnique();
|
||||||
|
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.Entity<AuthMethod>()
|
.Entity<AuthMethod>()
|
||||||
|
|
|
@ -31,7 +31,6 @@ public static class DatabaseQueryExtensions
|
||||||
{
|
{
|
||||||
if (userRef == "@me")
|
if (userRef == "@me")
|
||||||
{
|
{
|
||||||
// Not filtering deleted users, as a suspended user should still be able to look at their own profile.
|
|
||||||
return token != null
|
return token != null
|
||||||
? await context.Users.FirstAsync(u => u.Id == token.UserId, ct)
|
? await context.Users.FirstAsync(u => u.Id == token.UserId, ct)
|
||||||
: throw new ApiError.Unauthorized(
|
: throw new ApiError.Unauthorized(
|
||||||
|
@ -44,14 +43,14 @@ public static class DatabaseQueryExtensions
|
||||||
if (Snowflake.TryParse(userRef, out Snowflake? snowflake))
|
if (Snowflake.TryParse(userRef, out Snowflake? snowflake))
|
||||||
{
|
{
|
||||||
user = await context
|
user = await context
|
||||||
.Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id))
|
.Users.Where(u => !u.Deleted)
|
||||||
.FirstOrDefaultAsync(u => u.Id == snowflake, ct);
|
.FirstOrDefaultAsync(u => u.Id == snowflake, ct);
|
||||||
if (user != null)
|
if (user != null)
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
user = await context
|
user = await context
|
||||||
.Users.Where(u => !u.Deleted || (token != null && token.UserId == u.Id))
|
.Users.Where(u => !u.Deleted)
|
||||||
.FirstOrDefaultAsync(u => u.Username == userRef, ct);
|
.FirstOrDefaultAsync(u => u.Username == userRef, ct);
|
||||||
if (user != null)
|
if (user != null)
|
||||||
return user;
|
return user;
|
||||||
|
@ -99,14 +98,13 @@ public static class DatabaseQueryExtensions
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
User user = await context.ResolveUserAsync(userRef, token, ct);
|
User user = await context.ResolveUserAsync(userRef, token, ct);
|
||||||
return await context.ResolveMemberAsync(user.Id, memberRef, token, ct);
|
return await context.ResolveMemberAsync(user.Id, memberRef, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<Member> ResolveMemberAsync(
|
public static async Task<Member> ResolveMemberAsync(
|
||||||
this DatabaseContext context,
|
this DatabaseContext context,
|
||||||
Snowflake userId,
|
Snowflake userId,
|
||||||
string memberRef,
|
string memberRef,
|
||||||
Token? token = null,
|
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
@ -116,8 +114,7 @@ public static class DatabaseQueryExtensions
|
||||||
member = await context
|
member = await context
|
||||||
.Members.Include(m => m.User)
|
.Members.Include(m => m.User)
|
||||||
.Include(m => m.ProfileFlags)
|
.Include(m => m.ProfileFlags)
|
||||||
// Return members if their user isn't deleted or the user querying it is the member's owner
|
.Where(m => !m.User.Deleted)
|
||||||
.Where(m => !m.User.Deleted || (token != null && token.UserId == m.UserId))
|
|
||||||
.FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct);
|
.FirstOrDefaultAsync(m => m.Id == snowflake && m.UserId == userId, ct);
|
||||||
if (member != null)
|
if (member != null)
|
||||||
return member;
|
return member;
|
||||||
|
@ -126,8 +123,7 @@ public static class DatabaseQueryExtensions
|
||||||
member = await context
|
member = await context
|
||||||
.Members.Include(m => m.User)
|
.Members.Include(m => m.User)
|
||||||
.Include(m => m.ProfileFlags)
|
.Include(m => m.ProfileFlags)
|
||||||
// Return members if their user isn't deleted or the user querying it is the member's owner
|
.Where(m => !m.User.Deleted)
|
||||||
.Where(m => !m.User.Deleted || (token != null && token.UserId == m.UserId))
|
|
||||||
.FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct);
|
.FirstOrDefaultAsync(m => m.Name == memberRef && m.UserId == userId, ct);
|
||||||
if (member != null)
|
if (member != null)
|
||||||
return member;
|
return member;
|
||||||
|
|
|
@ -1,53 +0,0 @@
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
|
||||||
using Microsoft.EntityFrameworkCore.Migrations;
|
|
||||||
using NodaTime;
|
|
||||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
|
||||||
|
|
||||||
#nullable disable
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Database.Migrations
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
[DbContext(typeof(DatabaseContext))]
|
|
||||||
[Migration("20241211193653_AddSentEmailCache")]
|
|
||||||
public partial class AddSentEmailCache : Migration
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Up(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.CreateTable(
|
|
||||||
name: "sent_emails",
|
|
||||||
columns: table => new
|
|
||||||
{
|
|
||||||
id = table
|
|
||||||
.Column<int>(type: "integer", nullable: false)
|
|
||||||
.Annotation(
|
|
||||||
"Npgsql:ValueGenerationStrategy",
|
|
||||||
NpgsqlValueGenerationStrategy.IdentityByDefaultColumn
|
|
||||||
),
|
|
||||||
email = table.Column<string>(type: "text", nullable: false),
|
|
||||||
sent_at = table.Column<Instant>(
|
|
||||||
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" }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
protected override void Down(MigrationBuilder migrationBuilder)
|
|
||||||
{
|
|
||||||
migrationBuilder.DropTable(name: "sent_emails");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,4 +1,5 @@
|
||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
|
@ -19,7 +20,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
{
|
{
|
||||||
#pragma warning disable 612, 618
|
#pragma warning disable 612, 618
|
||||||
modelBuilder
|
modelBuilder
|
||||||
.HasAnnotation("ProductVersion", "9.0.0")
|
.HasAnnotation("ProductVersion", "8.0.7")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
@ -45,12 +46,12 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasColumnType("text")
|
.HasColumnType("text")
|
||||||
.HasColumnName("name");
|
.HasColumnName("name");
|
||||||
|
|
||||||
b.PrimitiveCollection<string[]>("RedirectUris")
|
b.Property<string[]>("RedirectUris")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text[]")
|
.HasColumnType("text[]")
|
||||||
.HasColumnName("redirect_uris");
|
.HasColumnName("redirect_uris");
|
||||||
|
|
||||||
b.PrimitiveCollection<string[]>("Scopes")
|
b.Property<string[]>("Scopes")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text[]")
|
.HasColumnType("text[]")
|
||||||
.HasColumnName("scopes");
|
.HasColumnName("scopes");
|
||||||
|
@ -192,7 +193,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasColumnType("jsonb")
|
.HasColumnType("jsonb")
|
||||||
.HasColumnName("fields");
|
.HasColumnName("fields");
|
||||||
|
|
||||||
b.PrimitiveCollection<string[]>("Links")
|
b.Property<string[]>("Links")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text[]")
|
.HasColumnType("text[]")
|
||||||
.HasColumnName("links");
|
.HasColumnName("links");
|
||||||
|
@ -358,7 +359,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasColumnType("boolean")
|
.HasColumnType("boolean")
|
||||||
.HasColumnName("manually_expired");
|
.HasColumnName("manually_expired");
|
||||||
|
|
||||||
b.PrimitiveCollection<string[]>("Scopes")
|
b.Property<string[]>("Scopes")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text[]")
|
.HasColumnType("text[]")
|
||||||
.HasColumnName("scopes");
|
.HasColumnName("scopes");
|
||||||
|
@ -427,7 +428,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasColumnType("timestamp with time zone")
|
.HasColumnType("timestamp with time zone")
|
||||||
.HasColumnName("last_sid_reroll");
|
.HasColumnName("last_sid_reroll");
|
||||||
|
|
||||||
b.PrimitiveCollection<string[]>("Links")
|
b.Property<string[]>("Links")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text[]")
|
.HasColumnType("text[]")
|
||||||
.HasColumnName("links");
|
.HasColumnName("links");
|
||||||
|
|
|
@ -59,4 +59,4 @@ public record EmailCallbackRequest(string State);
|
||||||
|
|
||||||
public record EmailChangePasswordRequest(string Current, string New);
|
public record EmailChangePasswordRequest(string Current, string New);
|
||||||
|
|
||||||
public record FediverseCallbackRequest(string Instance, string Code, string? State = null);
|
public record FediverseCallbackRequest(string Instance, string Code, string State);
|
||||||
|
|
|
@ -91,34 +91,6 @@ public static class KeyCacheExtensions
|
||||||
string state,
|
string state,
|
||||||
CancellationToken ct = default
|
CancellationToken ct = default
|
||||||
) => await keyCacheService.GetKeyAsync<AddExtraAccountState>($"add_account:{state}", true, ct);
|
) => await keyCacheService.GetKeyAsync<AddExtraAccountState>($"add_account:{state}", true, ct);
|
||||||
|
|
||||||
public static async Task<string> 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<ForgotPasswordState?> GetForgotPasswordStateAsync(
|
|
||||||
this KeyCacheService keyCacheService,
|
|
||||||
string state,
|
|
||||||
CancellationToken ct = default
|
|
||||||
) =>
|
|
||||||
await keyCacheService.GetKeyAsync<ForgotPasswordState>(
|
|
||||||
$"forgot_password:{state}",
|
|
||||||
true,
|
|
||||||
ct
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record RegisterEmailState(
|
public record RegisterEmailState(
|
||||||
|
@ -126,6 +98,4 @@ public record RegisterEmailState(
|
||||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId
|
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Snowflake? ExistingUserId
|
||||||
);
|
);
|
||||||
|
|
||||||
public record ForgotPasswordState(string Email, Snowflake UserId);
|
|
||||||
|
|
||||||
public record AddExtraAccountState(AuthType AuthType, Snowflake UserId, string? Instance = null);
|
public record AddExtraAccountState(AuthType AuthType, Snowflake UserId, string? Instance = null);
|
||||||
|
|
|
@ -110,7 +110,6 @@ public static class WebApplicationExtensions
|
||||||
.AddSingleton<IClock>(SystemClock.Instance)
|
.AddSingleton<IClock>(SystemClock.Instance)
|
||||||
.AddSnowflakeGenerator()
|
.AddSnowflakeGenerator()
|
||||||
.AddSingleton<MailService>()
|
.AddSingleton<MailService>()
|
||||||
.AddSingleton<EmailRateLimiter>()
|
|
||||||
.AddScoped<UserRendererService>()
|
.AddScoped<UserRendererService>()
|
||||||
.AddScoped<MemberRendererService>()
|
.AddScoped<MemberRendererService>()
|
||||||
.AddScoped<AuthService>()
|
.AddScoped<AuthService>()
|
||||||
|
|
|
@ -14,7 +14,6 @@
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
using Microsoft.AspNetCore.Mvc.ViewFeatures;
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Middleware;
|
namespace Foxnouns.Backend.Middleware;
|
||||||
|
|
||||||
|
@ -23,11 +22,9 @@ public class AuthorizationMiddleware : IMiddleware
|
||||||
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||||
{
|
{
|
||||||
Endpoint? endpoint = ctx.GetEndpoint();
|
Endpoint? endpoint = ctx.GetEndpoint();
|
||||||
AuthorizeAttribute? authorizeAttribute =
|
AuthorizeAttribute? attribute = endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
|
||||||
endpoint?.Metadata.GetMetadata<AuthorizeAttribute>();
|
|
||||||
LimitAttribute? limitAttribute = endpoint?.Metadata.GetMetadata<LimitAttribute>();
|
|
||||||
|
|
||||||
if (authorizeAttribute == null || authorizeAttribute.Scopes.Length == 0)
|
if (attribute == null)
|
||||||
{
|
{
|
||||||
await next(ctx);
|
await next(ctx);
|
||||||
return;
|
return;
|
||||||
|
@ -42,35 +39,24 @@ public class AuthorizationMiddleware : IMiddleware
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Users who got suspended by a moderator can still access *some* endpoints.
|
|
||||||
if (
|
if (
|
||||||
token.User.Deleted
|
attribute.Scopes.Length > 0
|
||||||
&& (limitAttribute?.UsableBySuspendedUsers != true || token.User.DeletedBy == null)
|
&& attribute.Scopes.Except(token.Scopes.ExpandScopes()).Any()
|
||||||
)
|
|
||||||
{
|
|
||||||
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(
|
throw new ApiError.Forbidden(
|
||||||
"This endpoint requires ungranted scopes.",
|
"This endpoint requires ungranted scopes.",
|
||||||
authorizeAttribute.Scopes.Except(token.Scopes.ExpandScopes()),
|
attribute.Scopes.Except(token.Scopes.ExpandScopes()),
|
||||||
ErrorCode.MissingScopes
|
ErrorCode.MissingScopes
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (limitAttribute?.RequireAdmin == true && token.User.Role != UserRole.Admin)
|
if (attribute.RequireAdmin && token.User.Role != UserRole.Admin)
|
||||||
{
|
|
||||||
throw new ApiError.Forbidden("This endpoint can only be used by admins.");
|
throw new ApiError.Forbidden("This endpoint can only be used by admins.");
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
limitAttribute?.RequireModerator == true
|
attribute.RequireModerator
|
||||||
&& token.User.Role is not (UserRole.Admin or UserRole.Moderator)
|
&& token.User.Role != UserRole.Admin
|
||||||
|
&& token.User.Role != UserRole.Moderator
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
throw new ApiError.Forbidden("This endpoint can only be used by moderators.");
|
throw new ApiError.Forbidden("This endpoint can only be used by moderators.");
|
||||||
|
@ -83,13 +69,8 @@ public class AuthorizationMiddleware : IMiddleware
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||||
public class AuthorizeAttribute(params string[] scopes) : Attribute
|
public class AuthorizeAttribute(params string[] scopes) : Attribute
|
||||||
{
|
{
|
||||||
public readonly string[] Scopes = scopes.Except([":admin", ":moderator", ":deleted"]).ToArray();
|
public readonly bool RequireAdmin = scopes.Contains(":admin");
|
||||||
}
|
public readonly bool RequireModerator = scopes.Contains(":moderator");
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
public readonly string[] Scopes = scopes.Except([":admin", ":moderator"]).ToArray();
|
||||||
public class LimitAttribute : Attribute
|
|
||||||
{
|
|
||||||
public bool UsableBySuspendedUsers { get; init; }
|
|
||||||
public bool RequireAdmin { get; init; }
|
|
||||||
public bool RequireModerator { get; init; }
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -66,9 +66,7 @@ builder
|
||||||
})
|
})
|
||||||
.ConfigureApiBehaviorOptions(options =>
|
.ConfigureApiBehaviorOptions(options =>
|
||||||
{
|
{
|
||||||
// the type isn't needed but without it, rider keeps complaining for no reason (it compiles just fine)
|
options.InvalidModelStateResponseFactory = actionContext => new BadRequestObjectResult(
|
||||||
options.InvalidModelStateResponseFactory = (ActionContext actionContext) =>
|
|
||||||
new BadRequestObjectResult(
|
|
||||||
new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson()
|
new ApiError.AspBadRequest("Bad request", actionContext.ModelState).ToJson()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,170 +0,0 @@
|
||||||
// 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 <https://www.gnu.org/licenses/>.
|
|
||||||
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<FediverseApplication> 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<PartialMisskeyApplication>();
|
|
||||||
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<FediverseUser> 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<GetMisskeySessionUserKeyResponse>();
|
|
||||||
if (userResp == null)
|
|
||||||
{
|
|
||||||
throw new FoxnounsError($"User response from instance {app.Domain} was invalid");
|
|
||||||
}
|
|
||||||
|
|
||||||
return userResp.User;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<string> 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<CreateMisskeySessionUriResponse>();
|
|
||||||
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
|
|
||||||
);
|
|
||||||
}
|
|
|
@ -81,11 +81,11 @@ public partial class FediverseAuthService
|
||||||
string softwareName = await GetSoftwareNameAsync(instance);
|
string softwareName = await GetSoftwareNameAsync(instance);
|
||||||
|
|
||||||
if (IsMastodonCompatible(softwareName))
|
if (IsMastodonCompatible(softwareName))
|
||||||
|
{
|
||||||
return await CreateMastodonApplicationAsync(instance);
|
return await CreateMastodonApplicationAsync(instance);
|
||||||
if (IsMisskeyCompatible(softwareName))
|
}
|
||||||
return await CreateMisskeyApplicationAsync(instance);
|
|
||||||
|
|
||||||
throw new ApiError.BadRequest($"{softwareName} is not a supported instance type, sorry.");
|
throw new NotImplementedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string> GetSoftwareNameAsync(string instance)
|
private async Task<string> GetSoftwareNameAsync(string instance)
|
||||||
|
@ -129,11 +129,7 @@ public partial class FediverseAuthService
|
||||||
forceRefresh,
|
forceRefresh,
|
||||||
state
|
state
|
||||||
),
|
),
|
||||||
FediverseInstanceType.MisskeyApi => await GenerateMisskeyAuthUrlAsync(
|
FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
|
||||||
app,
|
|
||||||
forceRefresh,
|
|
||||||
state
|
|
||||||
),
|
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
|
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -145,7 +141,7 @@ public partial class FediverseAuthService
|
||||||
app.InstanceType switch
|
app.InstanceType switch
|
||||||
{
|
{
|
||||||
FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code, state),
|
FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code, state),
|
||||||
FediverseInstanceType.MisskeyApi => await GetMisskeyUserAsync(app, code),
|
FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
|
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -33,10 +33,10 @@ public class DataCleanupService(
|
||||||
|
|
||||||
public async Task InvokeAsync(CancellationToken ct = default)
|
public async Task InvokeAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
_logger.Debug("Cleaning up expired users");
|
_logger.Information("Cleaning up expired users");
|
||||||
await CleanUsersAsync(ct);
|
await CleanUsersAsync(ct);
|
||||||
|
|
||||||
_logger.Debug("Cleaning up expired data exports");
|
_logger.Information("Cleaning up expired data exports");
|
||||||
await CleanExportsAsync(ct);
|
await CleanExportsAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,36 +0,0 @@
|
||||||
using System.Collections.Concurrent;
|
|
||||||
using System.Threading.RateLimiting;
|
|
||||||
using NodaTime;
|
|
||||||
using NodaTime.Extensions;
|
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Services;
|
|
||||||
|
|
||||||
public class EmailRateLimiter
|
|
||||||
{
|
|
||||||
private readonly ConcurrentDictionary<string, RateLimiter> _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;
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -12,7 +12,6 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// You should have received a copy of the GNU Affero General Public License
|
||||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
using Coravel.Mailer.Mail;
|
|
||||||
using Coravel.Mailer.Mail.Interfaces;
|
using Coravel.Mailer.Mail.Interfaces;
|
||||||
using Coravel.Queuing.Interfaces;
|
using Coravel.Queuing.Interfaces;
|
||||||
using Foxnouns.Backend.Mailables;
|
using Foxnouns.Backend.Mailables;
|
||||||
|
@ -27,8 +26,10 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
|
||||||
{
|
{
|
||||||
queue.QueueAsyncTask(async () =>
|
queue.QueueAsyncTask(async () =>
|
||||||
{
|
{
|
||||||
await SendEmailAsync(
|
_logger.Debug("Sending account creation email to {ToEmail}", to);
|
||||||
to,
|
try
|
||||||
|
{
|
||||||
|
await mailer.SendAsync(
|
||||||
new AccountCreationMailable(
|
new AccountCreationMailable(
|
||||||
config,
|
config,
|
||||||
new AccountCreationMailableView
|
new AccountCreationMailableView
|
||||||
|
@ -39,6 +40,11 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
catch (Exception exc)
|
||||||
|
{
|
||||||
|
_logger.Error(exc, "Sending account creation email");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -47,8 +53,9 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
|
||||||
_logger.Debug("Sending add email address email to {ToEmail}", to);
|
_logger.Debug("Sending add email address email to {ToEmail}", to);
|
||||||
queue.QueueAsyncTask(async () =>
|
queue.QueueAsyncTask(async () =>
|
||||||
{
|
{
|
||||||
await SendEmailAsync(
|
try
|
||||||
to,
|
{
|
||||||
|
await mailer.SendAsync(
|
||||||
new AddEmailMailable(
|
new AddEmailMailable(
|
||||||
config,
|
config,
|
||||||
new AddEmailMailableView
|
new AddEmailMailableView
|
||||||
|
@ -60,18 +67,11 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task SendEmailAsync<T>(string to, Mailable<T> mailable)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await mailer.SendAsync(mailable);
|
|
||||||
}
|
}
|
||||||
catch (Exception exc)
|
catch (Exception exc)
|
||||||
{
|
{
|
||||||
_logger.Error(exc, "Sending email");
|
_logger.Error(exc, "Sending add email address email");
|
||||||
}
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,9 +35,6 @@ public static class AuthUtils
|
||||||
"user.read_hidden",
|
"user.read_hidden",
|
||||||
"user.read_privileged",
|
"user.read_privileged",
|
||||||
"user.update",
|
"user.update",
|
||||||
"user.read_flags",
|
|
||||||
"user.create_flags",
|
|
||||||
"user.update_flags",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
public static readonly string[] MemberScopes =
|
public static readonly string[] MemberScopes =
|
||||||
|
|
|
@ -2,7 +2,7 @@
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
%sveltekit.head%
|
%sveltekit.head%
|
||||||
</head>
|
</head>
|
||||||
|
|
|
@ -5,10 +5,9 @@ import createRegisterAction from "$lib/actions/register";
|
||||||
export const load = createCallbackLoader("fediverse", async ({ params, url }) => {
|
export const load = createCallbackLoader("fediverse", async ({ params, url }) => {
|
||||||
const code = url.searchParams.get("code") as string | null;
|
const code = url.searchParams.get("code") as string | null;
|
||||||
const state = url.searchParams.get("state") as string | null;
|
const state = url.searchParams.get("state") as string | null;
|
||||||
const token = url.searchParams.get("token") as string | null;
|
if (!code || !state) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
|
||||||
if ((!code || !state) && !token) throw new ApiError(undefined, ErrorCode.BadRequest).obj;
|
|
||||||
|
|
||||||
return { code: code || token, state, instance: params.instance! };
|
return { code, state, instance: params.instance! };
|
||||||
});
|
});
|
||||||
|
|
||||||
export const actions = {
|
export const actions = {
|
||||||
|
|
BIN
Foxnouns.Frontend/static/favicon.png
Normal file
BIN
Foxnouns.Frontend/static/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 1.5 KiB |
Loading…
Reference in a new issue