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…
	
	Add table
		Add a link
		
	
		Reference in a new issue