too many things to list (notably, user avatar update)
This commit is contained in:
parent
a7950671e1
commit
d6c9345dba
20 changed files with 341 additions and 47 deletions
|
@ -10,14 +10,23 @@ public class Config
|
|||
|
||||
public string Address => $"http://{Host}:{Port}";
|
||||
|
||||
public string? SeqLogUrl { get; init; }
|
||||
public LogEventLevel LogEventLevel { get; init; } = LogEventLevel.Debug;
|
||||
|
||||
public LoggingConfig Logging { get; init; } = new();
|
||||
public DatabaseConfig Database { get; init; } = new();
|
||||
public JobsConfig Jobs { get; init; } = new();
|
||||
public StorageConfig Storage { get; init; } = new();
|
||||
public DiscordAuthConfig DiscordAuth { get; init; } = new();
|
||||
public GoogleAuthConfig GoogleAuth { get; init; } = new();
|
||||
public TumblrAuthConfig TumblrAuth { get; init; } = new();
|
||||
|
||||
public class LoggingConfig
|
||||
{
|
||||
public LogEventLevel LogEventLevel { get; init; } = LogEventLevel.Debug;
|
||||
public string? SeqLogUrl { get; init; }
|
||||
public string? SentryUrl { get; init; }
|
||||
public bool SentryTracing { get; init; } = false;
|
||||
public double SentryTracesSampleRate { get; init; } = 0.0;
|
||||
}
|
||||
|
||||
public class DatabaseConfig
|
||||
{
|
||||
public string Url { get; init; } = string.Empty;
|
||||
|
@ -25,6 +34,20 @@ public class Config
|
|||
public int? MaxPoolSize { get; init; }
|
||||
}
|
||||
|
||||
public class JobsConfig
|
||||
{
|
||||
public string Redis { get; init; } = string.Empty;
|
||||
public int Workers { get; init; } = 5;
|
||||
}
|
||||
|
||||
public class StorageConfig
|
||||
{
|
||||
public string Endpoint { get; init; } = string.Empty;
|
||||
public string AccessKey { get; init; } = string.Empty;
|
||||
public string SecretKey { get; init; } = string.Empty;
|
||||
public string Bucket { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public class DiscordAuthConfig
|
||||
{
|
||||
public bool Enabled => ClientId != null && ClientSecret != null;
|
||||
|
|
|
@ -37,7 +37,7 @@ public class DiscordAuthController(
|
|||
logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username,
|
||||
remoteUser.Id);
|
||||
|
||||
var ticket = OauthUtils.RandomToken();
|
||||
var ticket = AuthUtils.RandomToken();
|
||||
await keyCacheSvc.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20));
|
||||
|
||||
return Ok(new AuthController.CallbackResponse(false, ticket, remoteUser.Username));
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Jobs;
|
||||
using Foxnouns.Backend.Middleware;
|
||||
using Foxnouns.Backend.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
@ -9,7 +10,7 @@ namespace Foxnouns.Backend.Controllers;
|
|||
public class UsersController(DatabaseContext db, UserRendererService userRendererService) : ApiControllerBase
|
||||
{
|
||||
[HttpGet("{userRef}")]
|
||||
public async Task<IActionResult> GetUser(string userRef)
|
||||
public async Task<IActionResult> GetUserAsync(string userRef)
|
||||
{
|
||||
var user = await db.ResolveUserAsync(userRef);
|
||||
return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser));
|
||||
|
@ -17,17 +18,20 @@ public class UsersController(DatabaseContext db, UserRendererService userRendere
|
|||
|
||||
[HttpGet("@me")]
|
||||
[Authorize("identify")]
|
||||
public async Task<IActionResult> GetMe()
|
||||
public async Task<IActionResult> GetMeAsync()
|
||||
{
|
||||
var user = await db.ResolveUserAsync(CurrentUser!.Id);
|
||||
return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser));
|
||||
}
|
||||
|
||||
[HttpPatch("@me")]
|
||||
public Task<IActionResult> UpdateUser([FromBody] UpdateUserRequest req)
|
||||
public async Task<IActionResult> UpdateUserAsync([FromBody] UpdateUserRequest req)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
if (req.Avatar != null)
|
||||
AvatarUpdateJob.QueueUpdateUserAvatar(CurrentUser!.Id, req.Avatar);
|
||||
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
public record UpdateUserRequest(string? Username, string? DisplayName);
|
||||
public record UpdateUserRequest(string? Username, string? DisplayName, string? Avatar);
|
||||
}
|
|
@ -76,7 +76,7 @@ public static class DatabaseQueryExtensions
|
|||
{
|
||||
Id = new Snowflake(0),
|
||||
ClientId = RandomNumberGenerator.GetHexString(32, true),
|
||||
ClientSecret = OauthUtils.RandomToken(48),
|
||||
ClientSecret = AuthUtils.RandomToken(48),
|
||||
Name = "pronouns.cc",
|
||||
Scopes = ["*"],
|
||||
RedirectUris = [],
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
// <auto-generated />
|
||||
using System;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
|
@ -334,7 +335,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
.IsRequired()
|
||||
.HasConstraintName("fk_members_users_user_id");
|
||||
|
||||
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
|
||||
b.OwnsOne("Foxnouns.Backend.Database.Models.Member.Fields#System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Field>", "Fields", b1 =>
|
||||
{
|
||||
b1.Property<long>("MemberId")
|
||||
.HasColumnType("bigint");
|
||||
|
@ -344,7 +345,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
|
||||
b1.HasKey("MemberId");
|
||||
|
||||
b1.ToTable("members");
|
||||
b1.ToTable("members", (string)null);
|
||||
|
||||
b1.ToJson("fields");
|
||||
|
||||
|
@ -353,7 +354,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
.HasConstraintName("fk_members_members_id");
|
||||
});
|
||||
|
||||
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
|
||||
b.OwnsOne("Foxnouns.Backend.Database.Models.Member.Names#System.Collections.Generic.List<Foxnouns.Backend.Database.Models.FieldEntry>", "Names", b1 =>
|
||||
{
|
||||
b1.Property<long>("MemberId")
|
||||
.HasColumnType("bigint");
|
||||
|
@ -363,7 +364,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
|
||||
b1.HasKey("MemberId");
|
||||
|
||||
b1.ToTable("members");
|
||||
b1.ToTable("members", (string)null);
|
||||
|
||||
b1.ToJson("names");
|
||||
|
||||
|
@ -372,7 +373,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
.HasConstraintName("fk_members_members_id");
|
||||
});
|
||||
|
||||
b.OwnsOne("System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
|
||||
b.OwnsOne("Foxnouns.Backend.Database.Models.Member.Pronouns#System.Collections.Generic.List<Foxnouns.Backend.Database.Models.Pronoun>", "Pronouns", b1 =>
|
||||
{
|
||||
b1.Property<long>("MemberId")
|
||||
.HasColumnType("bigint");
|
||||
|
@ -382,7 +383,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
|
||||
b1.HasKey("MemberId");
|
||||
|
||||
b1.ToTable("members");
|
||||
b1.ToTable("members", (string)null);
|
||||
|
||||
b1.ToJson("pronouns");
|
||||
|
||||
|
@ -426,7 +427,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
|
||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
|
||||
{
|
||||
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 =>
|
||||
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Fields#Foxnouns.Backend.Database.Models.User.Fields#List", "Fields", b1 =>
|
||||
{
|
||||
b1.Property<long>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
@ -437,7 +438,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
b1.HasKey("UserId")
|
||||
.HasName("pk_users");
|
||||
|
||||
b1.ToTable("users");
|
||||
b1.ToTable("users", (string)null);
|
||||
|
||||
b1.ToJson("fields");
|
||||
|
||||
|
@ -446,7 +447,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
.HasConstraintName("fk_users_users_user_id");
|
||||
});
|
||||
|
||||
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 =>
|
||||
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Names#Foxnouns.Backend.Database.Models.User.Names#List", "Names", b1 =>
|
||||
{
|
||||
b1.Property<long>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
@ -457,7 +458,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
b1.HasKey("UserId")
|
||||
.HasName("pk_users");
|
||||
|
||||
b1.ToTable("users");
|
||||
b1.ToTable("users", (string)null);
|
||||
|
||||
b1.ToJson("names");
|
||||
|
||||
|
@ -466,7 +467,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
.HasConstraintName("fk_users_users_user_id");
|
||||
});
|
||||
|
||||
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 =>
|
||||
b.OwnsOne("Foxnouns.Backend.Database.Models.User.Pronouns#Foxnouns.Backend.Database.Models.User.Pronouns#List", "Pronouns", b1 =>
|
||||
{
|
||||
b1.Property<long>("UserId")
|
||||
.HasColumnType("bigint");
|
||||
|
@ -477,7 +478,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
|||
b1.HasKey("UserId")
|
||||
.HasName("pk_users");
|
||||
|
||||
b1.ToTable("users");
|
||||
b1.ToTable("users", (string)null);
|
||||
|
||||
b1.ToJson("pronouns");
|
||||
|
||||
|
|
|
@ -15,14 +15,14 @@ public class Application : BaseModel
|
|||
string[] redirectUrls)
|
||||
{
|
||||
var clientId = RandomNumberGenerator.GetHexString(32, true);
|
||||
var clientSecret = OauthUtils.RandomToken();
|
||||
var clientSecret = AuthUtils.RandomToken();
|
||||
|
||||
if (scopes.Except(OauthUtils.ApplicationScopes).Any())
|
||||
if (scopes.Except(AuthUtils.ApplicationScopes).Any())
|
||||
{
|
||||
throw new ArgumentException("Invalid scopes passed to Application.Create", nameof(scopes));
|
||||
}
|
||||
|
||||
if (redirectUrls.Any(s => !OauthUtils.ValidateRedirectUri(s)))
|
||||
if (redirectUrls.Any(s => !AuthUtils.ValidateRedirectUri(s)))
|
||||
{
|
||||
throw new ArgumentException("Invalid redirect URLs passed to Application.Create", nameof(redirectUrls));
|
||||
}
|
||||
|
|
|
@ -56,6 +56,7 @@ public readonly struct Snowflake(ulong value)
|
|||
|
||||
public override bool Equals(object? obj) => obj is Snowflake other && Value == other.Value;
|
||||
public override int GetHashCode() => Value.GetHashCode();
|
||||
public override string ToString() => Value.ToString();
|
||||
|
||||
/// <summary>
|
||||
/// An Entity Framework ValueConverter for Snowflakes to longs.
|
||||
|
|
|
@ -8,7 +8,7 @@ public static class KeyCacheExtensions
|
|||
{
|
||||
public static async Task<string> GenerateAuthStateAsync(this KeyCacheService keyCacheSvc)
|
||||
{
|
||||
var state = OauthUtils.RandomToken();
|
||||
var state = AuthUtils.RandomToken();
|
||||
await keyCacheSvc.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10));
|
||||
return state;
|
||||
}
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Jobs;
|
||||
using Foxnouns.Backend.Middleware;
|
||||
using Foxnouns.Backend.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
@ -19,7 +20,7 @@ public static class WebApplicationExtensions
|
|||
|
||||
var logCfg = new LoggerConfiguration()
|
||||
.Enrich.FromLogContext()
|
||||
.MinimumLevel.Is(config.LogEventLevel)
|
||||
.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.
|
||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
|
||||
|
@ -28,9 +29,9 @@ public static class WebApplicationExtensions
|
|||
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
|
||||
.WriteTo.Console();
|
||||
|
||||
if (config.SeqLogUrl != null)
|
||||
if (config.Logging.SeqLogUrl != null)
|
||||
{
|
||||
logCfg.WriteTo.Seq(config.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose);
|
||||
logCfg.WriteTo.Seq(config.Logging.SeqLogUrl, restrictedToMinimumLevel: LogEventLevel.Verbose);
|
||||
}
|
||||
|
||||
Log.Logger = logCfg.CreateLogger();
|
||||
|
@ -68,7 +69,9 @@ public static class WebApplicationExtensions
|
|||
.AddScoped<MemberRendererService>()
|
||||
.AddScoped<AuthService>()
|
||||
.AddScoped<KeyCacheService>()
|
||||
.AddScoped<RemoteAuthService>();
|
||||
.AddScoped<RemoteAuthService>()
|
||||
// Background job classes
|
||||
.AddTransient<AvatarUpdateJob>();
|
||||
|
||||
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services
|
||||
.AddScoped<ErrorHandlerMiddleware>()
|
||||
|
|
|
@ -7,6 +7,9 @@
|
|||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
|
||||
<PackageReference Include="Hangfire.AspNetCore" Version="1.8.14" />
|
||||
<PackageReference Include="Hangfire.Core" Version="1.8.14" />
|
||||
<PackageReference Include="Hangfire.Redis.StackExchange" Version="1.9.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.5"/>
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4"/>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.5"/>
|
||||
|
@ -14,14 +17,18 @@
|
|||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Minio" Version="6.0.2" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
|
||||
<PackageReference Include="NodaTime" Version="3.1.11"/>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/>
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" Version="8.0.4"/>
|
||||
<PackageReference Include="Sentry.AspNetCore" Version="4.8.1" />
|
||||
<PackageReference Include="Sentry.Hangfire" Version="4.8.1" />
|
||||
<PackageReference Include="Serilog" Version="3.1.1"/>
|
||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/>
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1"/>
|
||||
<PackageReference Include="Serilog.Sinks.Seq" Version="7.0.1"/>
|
||||
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.4" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
|
||||
</ItemGroup>
|
||||
|
||||
|
|
137
Foxnouns.Backend/Jobs/AvatarUpdateJob.cs
Normal file
137
Foxnouns.Backend/Jobs/AvatarUpdateJob.cs
Normal file
|
@ -0,0 +1,137 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Security.Cryptography;
|
||||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Utils;
|
||||
using Hangfire;
|
||||
using Minio;
|
||||
using Minio.DataModel.Args;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.Formats.Webp;
|
||||
using SixLabors.ImageSharp.Processing;
|
||||
using SixLabors.ImageSharp.Processing.Processors.Transforms;
|
||||
|
||||
namespace Foxnouns.Backend.Jobs;
|
||||
|
||||
[SuppressMessage("ReSharper", "MemberCanBePrivate.Global")]
|
||||
public class AvatarUpdateJob(DatabaseContext db, IMinioClient minio, Config config, ILogger logger)
|
||||
{
|
||||
private readonly string[] _validContentTypes = ["image/png", "image/webp", "image/jpeg"];
|
||||
|
||||
public static void QueueUpdateUserAvatar(Snowflake id, string? newAvatar)
|
||||
{
|
||||
if (newAvatar != null)
|
||||
BackgroundJob.Enqueue<AvatarUpdateJob>(job => job.UpdateUserAvatar(id, newAvatar));
|
||||
else
|
||||
BackgroundJob.Enqueue<AvatarUpdateJob>(job => job.ClearUserAvatar(id));
|
||||
}
|
||||
|
||||
public static void QueueUpdateMemberAvatar(Snowflake id, string? newAvatar) =>
|
||||
BackgroundJob.Enqueue<AvatarUpdateJob>(job =>
|
||||
newAvatar != null ? job.UpdateMemberAvatar(id, newAvatar) : job.ClearMemberAvatar(id));
|
||||
|
||||
public async Task UpdateUserAvatar(Snowflake id, string newAvatar)
|
||||
{
|
||||
var user = await db.Users.FindAsync(id);
|
||||
if (user == null)
|
||||
{
|
||||
logger.Warning("Update avatar job queued for {UserId} but no user with that ID exists", id);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var image = await ConvertAvatar(newAvatar);
|
||||
var hash = Convert.ToHexString(await SHA256.HashDataAsync(image)).ToLower();
|
||||
image.Seek(0, SeekOrigin.Begin);
|
||||
var prevHash = user.Avatar;
|
||||
|
||||
await minio.PutObjectAsync(new PutObjectArgs()
|
||||
.WithBucket(config.Storage.Bucket)
|
||||
.WithObject(UserAvatarPath(id, hash))
|
||||
.WithObjectSize(image.Length)
|
||||
.WithStreamData(image)
|
||||
.WithContentType("image/webp")
|
||||
);
|
||||
|
||||
user.Avatar = hash;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (prevHash != null && prevHash != hash)
|
||||
await minio.RemoveObjectAsync(new RemoveObjectArgs()
|
||||
.WithBucket(config.Storage.Bucket)
|
||||
.WithObject(UserAvatarPath(id, prevHash))
|
||||
);
|
||||
|
||||
logger.Information("Updated avatar for user {UserId}", id);
|
||||
}
|
||||
catch (ArgumentException ae)
|
||||
{
|
||||
logger.Warning("Invalid data URI for new avatar for user {UserId}: {Reason}", id, ae.Message);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task ClearUserAvatar(Snowflake id)
|
||||
{
|
||||
var user = await db.Users.FindAsync(id);
|
||||
if (user == null)
|
||||
{
|
||||
logger.Warning("Clear avatar job queued for {UserId} but no user with that ID exists", id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.Avatar == null)
|
||||
{
|
||||
logger.Warning("Clear avatar job queued for {UserId} with null avatar", id);
|
||||
return;
|
||||
}
|
||||
|
||||
await minio.RemoveObjectAsync(new RemoveObjectArgs()
|
||||
.WithBucket(config.Storage.Bucket)
|
||||
.WithObject(UserAvatarPath(user.Id, user.Avatar))
|
||||
);
|
||||
|
||||
user.Avatar = null;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
public Task UpdateMemberAvatar(Snowflake id, string newAvatar)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
public Task ClearMemberAvatar(Snowflake id)
|
||||
{
|
||||
throw new NotImplementedException();
|
||||
}
|
||||
|
||||
private async Task<Stream> ConvertAvatar(string uri)
|
||||
{
|
||||
if (!uri.StartsWith("data:image/"))
|
||||
throw new ArgumentException("Not a data URI", nameof(uri));
|
||||
|
||||
var split = uri.Remove(0, "data:".Length).Split(";base64,");
|
||||
var contentType = split[0];
|
||||
var encoded = split[1];
|
||||
if (!_validContentTypes.Contains(contentType))
|
||||
throw new ArgumentException("Invalid content type for image", nameof(uri));
|
||||
|
||||
if (!AuthUtils.TryFromBase64String(encoded, out var rawImage))
|
||||
throw new ArgumentException("Invalid base64 string", nameof(uri));
|
||||
|
||||
var image = Image.Load(rawImage);
|
||||
|
||||
var processor = new ResizeProcessor(
|
||||
new ResizeOptions { Size = new Size(512), Mode = ResizeMode.Crop, Position = AnchorPositionMode.Center },
|
||||
image.Size
|
||||
);
|
||||
|
||||
image.Mutate(x => x.ApplyProcessor(processor));
|
||||
|
||||
var stream = new MemoryStream(64 * 1024);
|
||||
await image.SaveAsync(stream, new WebpEncoder { Quality = 95, NearLossless = false });
|
||||
return stream;
|
||||
}
|
||||
|
||||
private static string UserAvatarPath(Snowflake id, string hash) => $"users/{id}/avatars/{hash}.webp";
|
||||
private static string MemberAvatarPath(Snowflake id, string hash) => $"members/{id}/avatars/{hash}.webp";
|
||||
}
|
|
@ -2,6 +2,7 @@ using System.Security.Cryptography;
|
|||
using Foxnouns.Backend.Database;
|
||||
using Foxnouns.Backend.Database.Models;
|
||||
using Foxnouns.Backend.Utils;
|
||||
using Hangfire.Dashboard;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NodaTime;
|
||||
|
||||
|
@ -21,7 +22,7 @@ public class AuthenticationMiddleware(DatabaseContext db, IClock clock) : IMiddl
|
|||
}
|
||||
|
||||
var header = ctx.Request.Headers.Authorization.ToString();
|
||||
if (!OauthUtils.TryFromBase64String(header, out var rawToken))
|
||||
if (!AuthUtils.TryFromBase64String(header, out var rawToken))
|
||||
{
|
||||
await next(ctx);
|
||||
return;
|
||||
|
@ -63,4 +64,33 @@ public static class HttpContextExtensions
|
|||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
||||
public class AuthenticateAttribute : Attribute;
|
||||
public class AuthenticateAttribute : Attribute;
|
||||
|
||||
/// <summary>
|
||||
/// Authentication filter for the Hangfire dashboard. Uses the cookie created by the frontend
|
||||
/// (and otherwise only read <i>by</i> the frontend) to only allow admins to use it.
|
||||
/// </summary>
|
||||
public class HangfireDashboardAuthorizationFilter(IServiceProvider services) : IDashboardAsyncAuthorizationFilter
|
||||
{
|
||||
public async Task<bool> AuthorizeAsync(DashboardContext context)
|
||||
{
|
||||
await using var scope = services.CreateAsyncScope();
|
||||
|
||||
await using var db = scope.ServiceProvider.GetRequiredService<DatabaseContext>();
|
||||
var clock = scope.ServiceProvider.GetRequiredService<IClock>();
|
||||
|
||||
var httpContext = context.GetHttpContext();
|
||||
|
||||
if (!httpContext.Request.Cookies.TryGetValue("pronounscc-token", out var cookie)) return false;
|
||||
|
||||
if (!AuthUtils.TryFromBase64String(cookie!, out var rawToken)) return false;
|
||||
|
||||
var hash = SHA512.HashData(rawToken);
|
||||
var oauthToken = await db.Tokens
|
||||
.Include(t => t.Application)
|
||||
.Include(t => t.User)
|
||||
.FirstOrDefaultAsync(t => t.Hash == hash && t.ExpiresAt > clock.GetCurrentInstant() && !t.ManuallyExpired);
|
||||
|
||||
return oauthToken?.User.Role == UserRole.Admin;
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ using Newtonsoft.Json.Converters;
|
|||
|
||||
namespace Foxnouns.Backend.Middleware;
|
||||
|
||||
public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware
|
||||
public class ErrorHandlerMiddleware(ILogger baseLogger, IHub sentry) : IMiddleware
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext ctx, RequestDelegate next)
|
||||
{
|
||||
|
@ -22,6 +22,18 @@ public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware
|
|||
{
|
||||
logger.Error(e, "Error in {ClassName} ({Path}) after response started being sent", typeName,
|
||||
ctx.Request.Path);
|
||||
|
||||
sentry.CaptureException(e, scope =>
|
||||
{
|
||||
var user = ctx.GetUser();
|
||||
if (user != null)
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = user.Id.ToString(),
|
||||
Username = user.Username
|
||||
};
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -59,6 +71,17 @@ public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware
|
|||
{
|
||||
logger.Error(e, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path);
|
||||
}
|
||||
|
||||
var errorId = sentry.CaptureException(e, scope =>
|
||||
{
|
||||
var user = ctx.GetUser();
|
||||
if (user != null)
|
||||
scope.User = new SentryUser
|
||||
{
|
||||
Id = user.Id.ToString(),
|
||||
Username = user.Username
|
||||
};
|
||||
});
|
||||
|
||||
ctx.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||
ctx.Response.Headers.RequestId = ctx.TraceIdentifier;
|
||||
|
@ -67,6 +90,7 @@ public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware
|
|||
{
|
||||
Status = (int)HttpStatusCode.InternalServerError,
|
||||
Code = ErrorCode.InternalServerError,
|
||||
ErrorId = errorId.ToString(),
|
||||
Message = "Internal server error",
|
||||
}));
|
||||
}
|
||||
|
@ -79,6 +103,7 @@ public record HttpApiError
|
|||
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public required ErrorCode Code { get; init; }
|
||||
public string? ErrorId { get; init; }
|
||||
|
||||
public required string Message { get; init; }
|
||||
|
||||
|
|
|
@ -2,10 +2,16 @@ using Foxnouns.Backend;
|
|||
using Foxnouns.Backend.Database;
|
||||
using Serilog;
|
||||
using Foxnouns.Backend.Extensions;
|
||||
using Foxnouns.Backend.Middleware;
|
||||
using Foxnouns.Backend.Services;
|
||||
using Hangfire;
|
||||
using Hangfire.Redis.StackExchange;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Minio;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Sentry.Extensibility;
|
||||
using Sentry.Hangfire;
|
||||
|
||||
// Read version information from .version in the repository root
|
||||
await BuildInfo.ReadBuildInfo();
|
||||
|
@ -16,6 +22,13 @@ var config = builder.AddConfiguration();
|
|||
|
||||
builder.AddSerilog();
|
||||
|
||||
builder.WebHost.UseSentry(opts =>
|
||||
{
|
||||
opts.Dsn = config.Logging.SentryUrl;
|
||||
opts.TracesSampleRate = config.Logging.SentryTracesSampleRate;
|
||||
opts.MaxRequestBodySize = RequestSize.Small;
|
||||
});
|
||||
|
||||
builder.Services
|
||||
.AddControllers()
|
||||
.AddNewtonsoftJson(options =>
|
||||
|
@ -44,7 +57,17 @@ builder.Services
|
|||
.AddCustomServices()
|
||||
.AddCustomMiddleware()
|
||||
.AddEndpointsApiExplorer()
|
||||
.AddSwaggerGen();
|
||||
.AddSwaggerGen()
|
||||
.AddMinio(c =>
|
||||
c.WithEndpoint(config.Storage.Endpoint)
|
||||
.WithCredentials(config.Storage.AccessKey, config.Storage.SecretKey)
|
||||
.Build());
|
||||
|
||||
builder.Services.AddHangfire(c => c.UseSentry().UseRedisStorage(config.Jobs.Redis, new RedisStorageOptions
|
||||
{
|
||||
Prefix = "foxnouns_"
|
||||
}))
|
||||
.AddHangfireServer(options => { options.WorkerCount = config.Jobs.Workers; });
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
@ -52,12 +75,21 @@ await app.Initialize(args);
|
|||
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseRouting();
|
||||
// Not all environments will want tracing (from experience, it's expensive to use in production, even with a low sample rate),
|
||||
// so it's locked behind a config option.
|
||||
if (config.Logging.SentryTracing) app.UseSentryTracing();
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
app.UseCors();
|
||||
app.UseCustomMiddleware();
|
||||
app.MapControllers();
|
||||
|
||||
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||
{
|
||||
AppPath = null,
|
||||
AsyncAuthorization = [new HangfireDashboardAuthorizationFilter(app.Services)]
|
||||
});
|
||||
|
||||
app.Urls.Clear();
|
||||
app.Urls.Add(config.Address);
|
||||
|
||||
|
|
|
@ -34,7 +34,7 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator
|
|||
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new user with the given username and remote authentication method.
|
||||
/// To create a user with email authentication, use <see cref="CreateUserWithPasswordAsync" />
|
||||
|
@ -44,7 +44,7 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator
|
|||
string remoteUsername, FediverseApplication? instance = null)
|
||||
{
|
||||
AssertValidAuthType(authType, instance);
|
||||
|
||||
|
||||
if (await db.Users.AnyAsync(u => u.Username == username))
|
||||
throw new ApiError.BadRequest("Username is already taken");
|
||||
|
||||
|
@ -121,7 +121,7 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator
|
|||
|
||||
public (string, Token) GenerateToken(User user, Application application, string[] scopes, Instant expires)
|
||||
{
|
||||
if (!OauthUtils.ValidateScopes(application, scopes))
|
||||
if (!AuthUtils.ValidateScopes(application, scopes))
|
||||
throw new ApiError.BadRequest("Invalid scopes requested for this token");
|
||||
|
||||
var (token, hash) = GenerateToken();
|
||||
|
@ -138,7 +138,7 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator
|
|||
|
||||
private static (string, byte[]) GenerateToken()
|
||||
{
|
||||
var token = OauthUtils.RandomToken(48);
|
||||
var token = AuthUtils.RandomToken(48);
|
||||
var hash = SHA512.HashData(Convert.FromBase64String(token));
|
||||
|
||||
return (token, hash);
|
||||
|
|
|
@ -3,7 +3,7 @@ using Foxnouns.Backend.Database.Models;
|
|||
|
||||
namespace Foxnouns.Backend.Utils;
|
||||
|
||||
public static class OauthUtils
|
||||
public static class AuthUtils
|
||||
{
|
||||
public const string ClientCredentials = "client_credentials";
|
||||
public const string AuthorizationCode = "authorization_code";
|
||||
|
@ -63,7 +63,6 @@ public static class OauthUtils
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
public static bool TryFromBase64String(string b64, out byte[] bytes)
|
||||
{
|
||||
try
|
||||
|
@ -71,8 +70,9 @@ public static class OauthUtils
|
|||
bytes = Convert.FromBase64String(b64);
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
catch (Exception e)
|
||||
{
|
||||
Console.WriteLine($"Error converting string: {e}");
|
||||
bytes = [];
|
||||
return false;
|
||||
}
|
||||
|
@ -80,4 +80,6 @@ public static class OauthUtils
|
|||
|
||||
public static string RandomToken(int bytes = 48) =>
|
||||
Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('=');
|
||||
|
||||
public const int MaxAuthMethodsPerType = 3; // Maximum of 3 Discord accounts, 3 emails, etc
|
||||
}
|
|
@ -5,10 +5,17 @@ Port = 5000
|
|||
; The base *external* URL
|
||||
BaseUrl = https://pronouns.localhost
|
||||
|
||||
[Logging]
|
||||
; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal
|
||||
LogEventLevel = Verbose
|
||||
|
||||
LogEventLevel = Debug
|
||||
; The URL to the Seq instance (optional)
|
||||
SeqLogUrl = http://localhost:5341
|
||||
; The Sentry DSN to log to (optional)
|
||||
SentryUrl = https://examplePublicKey@o0.ingest.sentry.io/0
|
||||
; Whether to trace performance with Sentry (optional)
|
||||
SentryTracing = true
|
||||
; Percentage of performance traces to send to Sentry (optional). Defaults to 0.0 (no traces at all)
|
||||
SentryTracesSampleRate = 1.0
|
||||
|
||||
[Database]
|
||||
; The database URL in ADO.NET format.
|
||||
|
@ -19,6 +26,18 @@ Timeout = 5
|
|||
; The maximum number of open connections. Defaults to 50.
|
||||
MaxPoolSize = 50
|
||||
|
||||
[Jobs]
|
||||
; The connection string for the Redis server.
|
||||
Redis = localhost:6379
|
||||
; The number of workers to use for background jobs. Defaults to 5.
|
||||
Workers = 5
|
||||
|
||||
[Storage]
|
||||
Endpoint = <s3EndpointHere>
|
||||
AccessKey = <s3AccessKey>
|
||||
SecretKey = <s3SecretKey>
|
||||
Bucket = pronounscc
|
||||
|
||||
[DiscordAuth]
|
||||
ClientId = <clientIdHere>
|
||||
ClientSecret = <clientSecretHere>
|
||||
|
|
14
Foxnouns.Frontend/src/routes/+error.svelte
Normal file
14
Foxnouns.Frontend/src/routes/+error.svelte
Normal file
|
@ -0,0 +1,14 @@
|
|||
<script>
|
||||
import { page } from "$app/stores";
|
||||
import neofox from "./neofox_confused_2048.png";
|
||||
</script>
|
||||
|
||||
{#if $page.status === 404}
|
||||
<div class="has-text-centered">
|
||||
<img src={neofox} alt="A very confused-looking fox" width="25%" />
|
||||
<h1 class="title">Not found</h1>
|
||||
<p>Our foxes can't find the page you're looking for, sorry!</p>
|
||||
</div>
|
||||
{:else}
|
||||
div.has-text-centered
|
||||
{/if}
|
|
@ -19,8 +19,6 @@ export const load = async ({ fetch, url, cookies, parent }) => {
|
|||
},
|
||||
);
|
||||
|
||||
console.log(JSON.stringify(resp));
|
||||
|
||||
if ("token" in resp) {
|
||||
const authResp = resp as AuthResponse;
|
||||
cookies.set("pronounscc-token", authResp.token, { path: "/" });
|
||||
|
@ -42,8 +40,6 @@ export const actions = {
|
|||
const username = data.get("username");
|
||||
const ticket = data.get("ticket");
|
||||
|
||||
console.log(JSON.stringify({ username, ticket }));
|
||||
|
||||
const resp = await request<AuthResponse>(fetch, "POST", "/auth/discord/register", {
|
||||
body: { username, ticket },
|
||||
});
|
||||
|
|
BIN
Foxnouns.Frontend/src/routes/neofox_confused_2048.png
Executable file
BIN
Foxnouns.Frontend/src/routes/neofox_confused_2048.png
Executable file
Binary file not shown.
After Width: | Height: | Size: 143 KiB |
Loading…
Reference in a new issue