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 Address => $"http://{Host}:{Port}";
|
||||||
|
|
||||||
public string? SeqLogUrl { get; init; }
|
public LoggingConfig Logging { get; init; } = new();
|
||||||
public LogEventLevel LogEventLevel { get; init; } = LogEventLevel.Debug;
|
|
||||||
|
|
||||||
public DatabaseConfig Database { 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 DiscordAuthConfig DiscordAuth { get; init; } = new();
|
||||||
public GoogleAuthConfig GoogleAuth { get; init; } = new();
|
public GoogleAuthConfig GoogleAuth { get; init; } = new();
|
||||||
public TumblrAuthConfig TumblrAuth { 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 class DatabaseConfig
|
||||||
{
|
{
|
||||||
public string Url { get; init; } = string.Empty;
|
public string Url { get; init; } = string.Empty;
|
||||||
|
@ -25,6 +34,20 @@ public class Config
|
||||||
public int? MaxPoolSize { get; init; }
|
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 class DiscordAuthConfig
|
||||||
{
|
{
|
||||||
public bool Enabled => ClientId != null && ClientSecret != null;
|
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,
|
logger.Debug("Discord user {Username} ({Id}) authenticated with no local account", remoteUser.Username,
|
||||||
remoteUser.Id);
|
remoteUser.Id);
|
||||||
|
|
||||||
var ticket = OauthUtils.RandomToken();
|
var ticket = AuthUtils.RandomToken();
|
||||||
await keyCacheSvc.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20));
|
await keyCacheSvc.SetKeyAsync($"discord:{ticket}", remoteUser, Duration.FromMinutes(20));
|
||||||
|
|
||||||
return Ok(new AuthController.CallbackResponse(false, ticket, remoteUser.Username));
|
return Ok(new AuthController.CallbackResponse(false, ticket, remoteUser.Username));
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
@ -9,7 +10,7 @@ namespace Foxnouns.Backend.Controllers;
|
||||||
public class UsersController(DatabaseContext db, UserRendererService userRendererService) : ApiControllerBase
|
public class UsersController(DatabaseContext db, UserRendererService userRendererService) : ApiControllerBase
|
||||||
{
|
{
|
||||||
[HttpGet("{userRef}")]
|
[HttpGet("{userRef}")]
|
||||||
public async Task<IActionResult> GetUser(string userRef)
|
public async Task<IActionResult> GetUserAsync(string userRef)
|
||||||
{
|
{
|
||||||
var user = await db.ResolveUserAsync(userRef);
|
var user = await db.ResolveUserAsync(userRef);
|
||||||
return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser));
|
return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser));
|
||||||
|
@ -17,17 +18,20 @@ public class UsersController(DatabaseContext db, UserRendererService userRendere
|
||||||
|
|
||||||
[HttpGet("@me")]
|
[HttpGet("@me")]
|
||||||
[Authorize("identify")]
|
[Authorize("identify")]
|
||||||
public async Task<IActionResult> GetMe()
|
public async Task<IActionResult> GetMeAsync()
|
||||||
{
|
{
|
||||||
var user = await db.ResolveUserAsync(CurrentUser!.Id);
|
var user = await db.ResolveUserAsync(CurrentUser!.Id);
|
||||||
return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser));
|
return Ok(await userRendererService.RenderUserAsync(user, selfUser: CurrentUser));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPatch("@me")]
|
[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),
|
Id = new Snowflake(0),
|
||||||
ClientId = RandomNumberGenerator.GetHexString(32, true),
|
ClientId = RandomNumberGenerator.GetHexString(32, true),
|
||||||
ClientSecret = OauthUtils.RandomToken(48),
|
ClientSecret = AuthUtils.RandomToken(48),
|
||||||
Name = "pronouns.cc",
|
Name = "pronouns.cc",
|
||||||
Scopes = ["*"],
|
Scopes = ["*"],
|
||||||
RedirectUris = [],
|
RedirectUris = [],
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
@ -334,7 +335,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasConstraintName("fk_members_users_user_id");
|
.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")
|
b1.Property<long>("MemberId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
@ -344,7 +345,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
|
||||||
b1.HasKey("MemberId");
|
b1.HasKey("MemberId");
|
||||||
|
|
||||||
b1.ToTable("members");
|
b1.ToTable("members", (string)null);
|
||||||
|
|
||||||
b1.ToJson("fields");
|
b1.ToJson("fields");
|
||||||
|
|
||||||
|
@ -353,7 +354,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasConstraintName("fk_members_members_id");
|
.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")
|
b1.Property<long>("MemberId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
@ -363,7 +364,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
|
||||||
b1.HasKey("MemberId");
|
b1.HasKey("MemberId");
|
||||||
|
|
||||||
b1.ToTable("members");
|
b1.ToTable("members", (string)null);
|
||||||
|
|
||||||
b1.ToJson("names");
|
b1.ToJson("names");
|
||||||
|
|
||||||
|
@ -372,7 +373,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasConstraintName("fk_members_members_id");
|
.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")
|
b1.Property<long>("MemberId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
@ -382,7 +383,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
|
||||||
b1.HasKey("MemberId");
|
b1.HasKey("MemberId");
|
||||||
|
|
||||||
b1.ToTable("members");
|
b1.ToTable("members", (string)null);
|
||||||
|
|
||||||
b1.ToJson("pronouns");
|
b1.ToJson("pronouns");
|
||||||
|
|
||||||
|
@ -426,7 +427,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
|
|
||||||
modelBuilder.Entity("Foxnouns.Backend.Database.Models.User", b =>
|
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")
|
b1.Property<long>("UserId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
@ -437,7 +438,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b1.HasKey("UserId")
|
b1.HasKey("UserId")
|
||||||
.HasName("pk_users");
|
.HasName("pk_users");
|
||||||
|
|
||||||
b1.ToTable("users");
|
b1.ToTable("users", (string)null);
|
||||||
|
|
||||||
b1.ToJson("fields");
|
b1.ToJson("fields");
|
||||||
|
|
||||||
|
@ -446,7 +447,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasConstraintName("fk_users_users_user_id");
|
.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")
|
b1.Property<long>("UserId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
@ -457,7 +458,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b1.HasKey("UserId")
|
b1.HasKey("UserId")
|
||||||
.HasName("pk_users");
|
.HasName("pk_users");
|
||||||
|
|
||||||
b1.ToTable("users");
|
b1.ToTable("users", (string)null);
|
||||||
|
|
||||||
b1.ToJson("names");
|
b1.ToJson("names");
|
||||||
|
|
||||||
|
@ -466,7 +467,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
.HasConstraintName("fk_users_users_user_id");
|
.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")
|
b1.Property<long>("UserId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
@ -477,7 +478,7 @@ namespace Foxnouns.Backend.Database.Migrations
|
||||||
b1.HasKey("UserId")
|
b1.HasKey("UserId")
|
||||||
.HasName("pk_users");
|
.HasName("pk_users");
|
||||||
|
|
||||||
b1.ToTable("users");
|
b1.ToTable("users", (string)null);
|
||||||
|
|
||||||
b1.ToJson("pronouns");
|
b1.ToJson("pronouns");
|
||||||
|
|
||||||
|
|
|
@ -15,14 +15,14 @@ public class Application : BaseModel
|
||||||
string[] redirectUrls)
|
string[] redirectUrls)
|
||||||
{
|
{
|
||||||
var clientId = RandomNumberGenerator.GetHexString(32, true);
|
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));
|
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));
|
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 bool Equals(object? obj) => obj is Snowflake other && Value == other.Value;
|
||||||
public override int GetHashCode() => Value.GetHashCode();
|
public override int GetHashCode() => Value.GetHashCode();
|
||||||
|
public override string ToString() => Value.ToString();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// An Entity Framework ValueConverter for Snowflakes to longs.
|
/// 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)
|
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));
|
await keyCacheSvc.SetKeyAsync($"oauth_state:{state}", "", Duration.FromMinutes(10));
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
|
using Foxnouns.Backend.Jobs;
|
||||||
using Foxnouns.Backend.Middleware;
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
@ -19,7 +20,7 @@ public static class WebApplicationExtensions
|
||||||
|
|
||||||
var logCfg = new LoggerConfiguration()
|
var logCfg = new LoggerConfiguration()
|
||||||
.Enrich.FromLogContext()
|
.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.
|
// 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.
|
// Serilog doesn't disable the built-in logs, so we do it here.
|
||||||
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
|
.MinimumLevel.Override("Microsoft", LogEventLevel.Information)
|
||||||
|
@ -28,9 +29,9 @@ public static class WebApplicationExtensions
|
||||||
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
|
.MinimumLevel.Override("Microsoft.AspNetCore.Routing", LogEventLevel.Warning)
|
||||||
.WriteTo.Console();
|
.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();
|
Log.Logger = logCfg.CreateLogger();
|
||||||
|
@ -68,7 +69,9 @@ public static class WebApplicationExtensions
|
||||||
.AddScoped<MemberRendererService>()
|
.AddScoped<MemberRendererService>()
|
||||||
.AddScoped<AuthService>()
|
.AddScoped<AuthService>()
|
||||||
.AddScoped<KeyCacheService>()
|
.AddScoped<KeyCacheService>()
|
||||||
.AddScoped<RemoteAuthService>();
|
.AddScoped<RemoteAuthService>()
|
||||||
|
// Background job classes
|
||||||
|
.AddTransient<AvatarUpdateJob>();
|
||||||
|
|
||||||
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services
|
public static IServiceCollection AddCustomMiddleware(this IServiceCollection services) => services
|
||||||
.AddScoped<ErrorHandlerMiddleware>()
|
.AddScoped<ErrorHandlerMiddleware>()
|
||||||
|
|
|
@ -7,6 +7,9 @@
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="EFCore.NamingConventions" Version="8.0.3"/>
|
<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.Mvc.NewtonsoftJson" Version="8.0.5"/>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4"/>
|
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="8.0.4"/>
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.5"/>
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="8.0.5"/>
|
||||||
|
@ -14,14 +17,18 @@
|
||||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Minio" Version="6.0.2" />
|
||||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
|
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
|
||||||
<PackageReference Include="NodaTime" Version="3.1.11"/>
|
<PackageReference Include="NodaTime" Version="3.1.11"/>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/>
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.4"/>
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL.NodaTime" 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" Version="3.1.1"/>
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/>
|
<PackageReference Include="Serilog.AspNetCore" Version="8.0.1"/>
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1"/>
|
<PackageReference Include="Serilog.Sinks.Console" Version="5.0.1"/>
|
||||||
<PackageReference Include="Serilog.Sinks.Seq" Version="7.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"/>
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.4.0"/>
|
||||||
</ItemGroup>
|
</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;
|
||||||
using Foxnouns.Backend.Database.Models;
|
using Foxnouns.Backend.Database.Models;
|
||||||
using Foxnouns.Backend.Utils;
|
using Foxnouns.Backend.Utils;
|
||||||
|
using Hangfire.Dashboard;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using NodaTime;
|
using NodaTime;
|
||||||
|
|
||||||
|
@ -21,7 +22,7 @@ public class AuthenticationMiddleware(DatabaseContext db, IClock clock) : IMiddl
|
||||||
}
|
}
|
||||||
|
|
||||||
var header = ctx.Request.Headers.Authorization.ToString();
|
var header = ctx.Request.Headers.Authorization.ToString();
|
||||||
if (!OauthUtils.TryFromBase64String(header, out var rawToken))
|
if (!AuthUtils.TryFromBase64String(header, out var rawToken))
|
||||||
{
|
{
|
||||||
await next(ctx);
|
await next(ctx);
|
||||||
return;
|
return;
|
||||||
|
@ -63,4 +64,33 @@ public static class HttpContextExtensions
|
||||||
}
|
}
|
||||||
|
|
||||||
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method)]
|
[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;
|
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)
|
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,
|
logger.Error(e, "Error in {ClassName} ({Path}) after response started being sent", typeName,
|
||||||
ctx.Request.Path);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,6 +71,17 @@ public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware
|
||||||
{
|
{
|
||||||
logger.Error(e, "Exception in {ClassName} ({Path})", typeName, ctx.Request.Path);
|
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.StatusCode = (int)HttpStatusCode.InternalServerError;
|
||||||
ctx.Response.Headers.RequestId = ctx.TraceIdentifier;
|
ctx.Response.Headers.RequestId = ctx.TraceIdentifier;
|
||||||
|
@ -67,6 +90,7 @@ public class ErrorHandlerMiddleware(ILogger baseLogger) : IMiddleware
|
||||||
{
|
{
|
||||||
Status = (int)HttpStatusCode.InternalServerError,
|
Status = (int)HttpStatusCode.InternalServerError,
|
||||||
Code = ErrorCode.InternalServerError,
|
Code = ErrorCode.InternalServerError,
|
||||||
|
ErrorId = errorId.ToString(),
|
||||||
Message = "Internal server error",
|
Message = "Internal server error",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -79,6 +103,7 @@ public record HttpApiError
|
||||||
|
|
||||||
[JsonConverter(typeof(StringEnumConverter))]
|
[JsonConverter(typeof(StringEnumConverter))]
|
||||||
public required ErrorCode Code { get; init; }
|
public required ErrorCode Code { get; init; }
|
||||||
|
public string? ErrorId { get; init; }
|
||||||
|
|
||||||
public required string Message { get; init; }
|
public required string Message { get; init; }
|
||||||
|
|
||||||
|
|
|
@ -2,10 +2,16 @@ using Foxnouns.Backend;
|
||||||
using Foxnouns.Backend.Database;
|
using Foxnouns.Backend.Database;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
using Foxnouns.Backend.Extensions;
|
using Foxnouns.Backend.Extensions;
|
||||||
|
using Foxnouns.Backend.Middleware;
|
||||||
using Foxnouns.Backend.Services;
|
using Foxnouns.Backend.Services;
|
||||||
|
using Hangfire;
|
||||||
|
using Hangfire.Redis.StackExchange;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Minio;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Serialization;
|
using Newtonsoft.Json.Serialization;
|
||||||
|
using Sentry.Extensibility;
|
||||||
|
using Sentry.Hangfire;
|
||||||
|
|
||||||
// Read version information from .version in the repository root
|
// Read version information from .version in the repository root
|
||||||
await BuildInfo.ReadBuildInfo();
|
await BuildInfo.ReadBuildInfo();
|
||||||
|
@ -16,6 +22,13 @@ var config = builder.AddConfiguration();
|
||||||
|
|
||||||
builder.AddSerilog();
|
builder.AddSerilog();
|
||||||
|
|
||||||
|
builder.WebHost.UseSentry(opts =>
|
||||||
|
{
|
||||||
|
opts.Dsn = config.Logging.SentryUrl;
|
||||||
|
opts.TracesSampleRate = config.Logging.SentryTracesSampleRate;
|
||||||
|
opts.MaxRequestBodySize = RequestSize.Small;
|
||||||
|
});
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
.AddControllers()
|
.AddControllers()
|
||||||
.AddNewtonsoftJson(options =>
|
.AddNewtonsoftJson(options =>
|
||||||
|
@ -44,7 +57,17 @@ builder.Services
|
||||||
.AddCustomServices()
|
.AddCustomServices()
|
||||||
.AddCustomMiddleware()
|
.AddCustomMiddleware()
|
||||||
.AddEndpointsApiExplorer()
|
.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();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
@ -52,12 +75,21 @@ await app.Initialize(args);
|
||||||
|
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
app.UseRouting();
|
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.UseSwagger();
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
app.UseCors();
|
app.UseCors();
|
||||||
app.UseCustomMiddleware();
|
app.UseCustomMiddleware();
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
|
app.UseHangfireDashboard("/hangfire", new DashboardOptions
|
||||||
|
{
|
||||||
|
AppPath = null,
|
||||||
|
AsyncAuthorization = [new HangfireDashboardAuthorizationFilter(app.Services)]
|
||||||
|
});
|
||||||
|
|
||||||
app.Urls.Clear();
|
app.Urls.Clear();
|
||||||
app.Urls.Add(config.Address);
|
app.Urls.Add(config.Address);
|
||||||
|
|
||||||
|
|
|
@ -34,7 +34,7 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator
|
||||||
|
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Creates a new user with the given username and remote authentication method.
|
/// Creates a new user with the given username and remote authentication method.
|
||||||
/// To create a user with email authentication, use <see cref="CreateUserWithPasswordAsync" />
|
/// 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)
|
string remoteUsername, FediverseApplication? instance = null)
|
||||||
{
|
{
|
||||||
AssertValidAuthType(authType, instance);
|
AssertValidAuthType(authType, instance);
|
||||||
|
|
||||||
if (await db.Users.AnyAsync(u => u.Username == username))
|
if (await db.Users.AnyAsync(u => u.Username == username))
|
||||||
throw new ApiError.BadRequest("Username is already taken");
|
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)
|
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");
|
throw new ApiError.BadRequest("Invalid scopes requested for this token");
|
||||||
|
|
||||||
var (token, hash) = GenerateToken();
|
var (token, hash) = GenerateToken();
|
||||||
|
@ -138,7 +138,7 @@ public class AuthService(ILogger logger, DatabaseContext db, ISnowflakeGenerator
|
||||||
|
|
||||||
private static (string, byte[]) GenerateToken()
|
private static (string, byte[]) GenerateToken()
|
||||||
{
|
{
|
||||||
var token = OauthUtils.RandomToken(48);
|
var token = AuthUtils.RandomToken(48);
|
||||||
var hash = SHA512.HashData(Convert.FromBase64String(token));
|
var hash = SHA512.HashData(Convert.FromBase64String(token));
|
||||||
|
|
||||||
return (token, hash);
|
return (token, hash);
|
||||||
|
|
|
@ -3,7 +3,7 @@ using Foxnouns.Backend.Database.Models;
|
||||||
|
|
||||||
namespace Foxnouns.Backend.Utils;
|
namespace Foxnouns.Backend.Utils;
|
||||||
|
|
||||||
public static class OauthUtils
|
public static class AuthUtils
|
||||||
{
|
{
|
||||||
public const string ClientCredentials = "client_credentials";
|
public const string ClientCredentials = "client_credentials";
|
||||||
public const string AuthorizationCode = "authorization_code";
|
public const string AuthorizationCode = "authorization_code";
|
||||||
|
@ -63,7 +63,6 @@ public static class OauthUtils
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public static bool TryFromBase64String(string b64, out byte[] bytes)
|
public static bool TryFromBase64String(string b64, out byte[] bytes)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
@ -71,8 +70,9 @@ public static class OauthUtils
|
||||||
bytes = Convert.FromBase64String(b64);
|
bytes = Convert.FromBase64String(b64);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
catch
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
|
Console.WriteLine($"Error converting string: {e}");
|
||||||
bytes = [];
|
bytes = [];
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
@ -80,4 +80,6 @@ public static class OauthUtils
|
||||||
|
|
||||||
public static string RandomToken(int bytes = 48) =>
|
public static string RandomToken(int bytes = 48) =>
|
||||||
Convert.ToBase64String(RandomNumberGenerator.GetBytes(bytes)).Trim('=');
|
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
|
; The base *external* URL
|
||||||
BaseUrl = https://pronouns.localhost
|
BaseUrl = https://pronouns.localhost
|
||||||
|
|
||||||
|
[Logging]
|
||||||
; The level to log things at. Valid settings: Verbose, Debug, Information, Warning, Error, Fatal
|
; 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
|
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]
|
[Database]
|
||||||
; The database URL in ADO.NET format.
|
; The database URL in ADO.NET format.
|
||||||
|
@ -19,6 +26,18 @@ Timeout = 5
|
||||||
; The maximum number of open connections. Defaults to 50.
|
; The maximum number of open connections. Defaults to 50.
|
||||||
MaxPoolSize = 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]
|
[DiscordAuth]
|
||||||
ClientId = <clientIdHere>
|
ClientId = <clientIdHere>
|
||||||
ClientSecret = <clientSecretHere>
|
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) {
|
if ("token" in resp) {
|
||||||
const authResp = resp as AuthResponse;
|
const authResp = resp as AuthResponse;
|
||||||
cookies.set("pronounscc-token", authResp.token, { path: "/" });
|
cookies.set("pronounscc-token", authResp.token, { path: "/" });
|
||||||
|
@ -42,8 +40,6 @@ export const actions = {
|
||||||
const username = data.get("username");
|
const username = data.get("username");
|
||||||
const ticket = data.get("ticket");
|
const ticket = data.get("ticket");
|
||||||
|
|
||||||
console.log(JSON.stringify({ username, ticket }));
|
|
||||||
|
|
||||||
const resp = await request<AuthResponse>(fetch, "POST", "/auth/discord/register", {
|
const resp = await request<AuthResponse>(fetch, "POST", "/auth/discord/register", {
|
||||||
body: { username, ticket },
|
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