too many things to list (notably, user avatar update)

This commit is contained in:
sam 2024-07-08 19:03:04 +02:00
parent a7950671e1
commit d6c9345dba
Signed by: sam
GPG key ID: B4EF20DDE721CAA1
20 changed files with 341 additions and 47 deletions

View file

@ -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;

View file

@ -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));

View file

@ -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);
}

View file

@ -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 = [],

View file

@ -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");

View file

@ -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));
}

View file

@ -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.

View file

@ -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;
}

View file

@ -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>()

View file

@ -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>

View 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";
}

View file

@ -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;
}
}

View file

@ -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; }

View file

@ -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);

View file

@ -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);

View file

@ -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
}

View file

@ -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>

View 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}

View file

@ -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 },
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB