Compare commits

..

7 commits

18 changed files with 237 additions and 201 deletions

View file

@ -6,7 +6,6 @@ using Foxnouns.Backend.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime; using NodaTime;
using FediverseAuthService = Foxnouns.Backend.Services.Auth.FediverseAuthService;
namespace Foxnouns.Backend.Controllers.Authentication; namespace Foxnouns.Backend.Controllers.Authentication;
@ -23,9 +22,15 @@ public class FediverseAuthController(
[HttpGet] [HttpGet]
[ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)] [ProducesResponseType<FediverseUrlResponse>(statusCode: StatusCodes.Status200OK)]
public async Task<IActionResult> GetFediverseUrlAsync([FromQuery] string instance) public async Task<IActionResult> GetFediverseUrlAsync(
[FromQuery] string instance,
[FromQuery] bool forceRefresh = false
)
{ {
var url = await fediverseAuthService.GenerateAuthUrlAsync(instance); if (instance.Any(c => c is '@' or ':' or '/') || !instance.Contains('.'))
throw new ApiError.BadRequest("Not a valid domain.", "instance", instance);
var url = await fediverseAuthService.GenerateAuthUrlAsync(instance, forceRefresh);
return Ok(new FediverseUrlResponse(url)); return Ok(new FediverseUrlResponse(url));
} }
@ -34,7 +39,11 @@ public class FediverseAuthController(
public async Task<IActionResult> FediverseCallbackAsync([FromBody] CallbackRequest req) public async Task<IActionResult> FediverseCallbackAsync([FromBody] CallbackRequest req)
{ {
var app = await fediverseAuthService.GetApplicationAsync(req.Instance); var app = await fediverseAuthService.GetApplicationAsync(req.Instance);
var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(app, req.Code); var remoteUser = await fediverseAuthService.GetRemoteFediverseUserAsync(
app,
req.Code,
req.State
);
var user = await authService.AuthenticateUserAsync( var user = await authService.AuthenticateUserAsync(
AuthType.Fediverse, AuthType.Fediverse,
@ -70,12 +79,16 @@ public class FediverseAuthController(
) )
{ {
var ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>( var ticketData = await keyCacheService.GetKeyAsync<FediverseTicketData>(
$"fediverse:{req.Ticket}" $"fediverse:{req.Ticket}",
delete: true
); );
if (ticketData == null) if (ticketData == null)
throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket); throw new ApiError.BadRequest("Invalid ticket", "ticket", req.Ticket);
var app = await db.FediverseApplications.FindAsync(ticketData.ApplicationId); var app = await db.FediverseApplications.FindAsync(ticketData.ApplicationId);
if (app == null)
throw new FoxnounsError("Null application found for ticket");
if ( if (
await db.AuthMethods.AnyAsync(a => await db.AuthMethods.AnyAsync(a =>
a.AuthType == AuthType.Fediverse a.AuthType == AuthType.Fediverse
@ -105,7 +118,7 @@ public class FediverseAuthController(
return Ok(await authService.GenerateUserTokenAsync(user)); return Ok(await authService.GenerateUserTokenAsync(user));
} }
public record CallbackRequest(string Instance, string Code); public record CallbackRequest(string Instance, string Code, string State);
private record FediverseUrlResponse(string Url); private record FediverseUrlResponse(string Url);

View file

@ -94,6 +94,7 @@ public class MembersController(
Names = req.Names ?? [], Names = req.Names ?? [],
Pronouns = req.Pronouns ?? [], Pronouns = req.Pronouns ?? [],
Unlisted = req.Unlisted ?? false, Unlisted = req.Unlisted ?? false,
Sid = null!,
}; };
db.Add(member); db.Add(member);

View file

@ -0,0 +1,52 @@
using System.Diagnostics.CodeAnalysis;
using Foxnouns.Backend.Database;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace Foxnouns.Backend.Controllers;
[Route("/sid")]
[SuppressMessage(
"Performance",
"CA1862:Use the \'StringComparison\' method overloads to perform case-insensitive string comparisons",
Justification = "Not usable with EFCore"
)]
public class SidController(Config config, DatabaseContext db) : ApiControllerBase
{
[HttpGet("{**id}")]
public async Task<IActionResult> ResolveSidAsync(string id, CancellationToken ct = default) =>
id.Length switch
{
5 => await ResolveUserSidAsync(id, ct),
6 => await ResolveMemberSidAsync(id, ct),
_ => Redirect(config.BaseUrl),
};
private async Task<IActionResult> ResolveUserSidAsync(string id, CancellationToken ct = default)
{
var username = await db
.Users.Where(u => u.Sid == id.ToLowerInvariant() && !u.Deleted)
.Select(u => u.Username)
.FirstOrDefaultAsync(ct);
if (username == null)
return Redirect(config.BaseUrl);
return Redirect($"{config.BaseUrl}/@{username}");
}
private async Task<IActionResult> ResolveMemberSidAsync(
string id,
CancellationToken ct = default
)
{
var member = await db
.Members.Include(m => m.User)
.Where(m => m.Sid == id.ToLowerInvariant() && !m.User.Deleted)
.Select(m => new { m.Name, m.User.Username })
.FirstOrDefaultAsync(ct);
if (member == null)
return Redirect(config.BaseUrl);
return Redirect($"{config.BaseUrl}/@{member.Username}/{member.Name}");
}
}

View file

@ -0,0 +1,40 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using NodaTime;
#nullable disable
namespace Foxnouns.Backend.Database.Migrations
{
/// <inheritdoc />
[DbContext(typeof(DatabaseContext))]
[Migration("20241123210306_RemoveFediverseApplicationTokens")]
public partial class RemoveFediverseApplicationTokens : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "access_token", table: "fediverse_applications");
migrationBuilder.DropColumn(name: "token_valid_until", table: "fediverse_applications");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "access_token",
table: "fediverse_applications",
type: "text",
nullable: true
);
migrationBuilder.AddColumn<Instant>(
name: "token_valid_until",
table: "fediverse_applications",
type: "timestamp with time zone",
nullable: true
);
}
}
}

View file

@ -107,10 +107,6 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("bigint") .HasColumnType("bigint")
.HasColumnName("id"); .HasColumnName("id");
b.Property<string>("AccessToken")
.HasColumnType("text")
.HasColumnName("access_token");
b.Property<string>("ClientId") b.Property<string>("ClientId")
.IsRequired() .IsRequired()
.HasColumnType("text") .HasColumnType("text")
@ -130,10 +126,6 @@ namespace Foxnouns.Backend.Database.Migrations
.HasColumnType("integer") .HasColumnType("integer")
.HasColumnName("instance_type"); .HasColumnName("instance_type");
b.Property<Instant?>("TokenValidUntil")
.HasColumnType("timestamp with time zone")
.HasColumnName("token_valid_until");
b.HasKey("Id") b.HasKey("Id")
.HasName("pk_fediverse_applications"); .HasName("pk_fediverse_applications");

View file

@ -8,10 +8,6 @@ public class FediverseApplication : BaseModel
public required string ClientId { get; set; } public required string ClientId { get; set; }
public required string ClientSecret { get; set; } public required string ClientSecret { get; set; }
public required FediverseInstanceType InstanceType { get; set; } public required FediverseInstanceType InstanceType { get; set; }
// These are for ensuring the application is still valid.
public string? AccessToken { get; set; }
public Instant? TokenValidUntil { get; set; }
} }
public enum FediverseInstanceType public enum FediverseInstanceType

View file

@ -10,7 +10,7 @@ using SixLabors.ImageSharp.Processing.Processors.Transforms;
namespace Foxnouns.Backend.Extensions; namespace Foxnouns.Backend.Extensions;
public static class AvatarObjectExtensions public static class ImageObjectExtensions
{ {
private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"]; private static readonly string[] ValidContentTypes = ["image/png", "image/webp", "image/jpeg"];
@ -39,7 +39,7 @@ public static class AvatarObjectExtensions
) => await objectStorageService.RemoveObjectAsync(CreateFlagInvocable.Path(hash), ct); ) => await objectStorageService.RemoveObjectAsync(CreateFlagInvocable.Path(hash), ct);
public static async Task<(string Hash, Stream Image)> ConvertBase64UriToImage( public static async Task<(string Hash, Stream Image)> ConvertBase64UriToImage(
this string uri, string uri,
int size, int size,
bool crop bool crop
) )

View file

@ -20,6 +20,7 @@
<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="Microsoft.Extensions.Caching.Memory" Version="9.0.0"/>
<PackageReference Include="Minio" Version="6.0.3"/> <PackageReference Include="Minio" Version="6.0.3"/>
<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"/>
@ -35,6 +36,7 @@
<PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0"/> <PackageReference Include="Serilog.Sinks.Seq" Version="8.0.0"/>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.5"/> <PackageReference Include="SixLabors.ImageSharp" Version="3.1.5"/>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2"/>
<PackageReference Include="System.Text.Json" Version="9.0.0"/>
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/> <PackageReference Include="System.Text.RegularExpressions" Version="4.3.1"/>
</ItemGroup> </ItemGroup>

View file

@ -26,7 +26,8 @@ public class CreateFlagInvocable(
try try
{ {
var (hash, image) = await Payload.ImageData.ConvertBase64UriToImage( var (hash, image) = await ImageObjectExtensions.ConvertBase64UriToImage(
Payload.ImageData,
size: 256, size: 256,
crop: false crop: false
); );

View file

@ -39,7 +39,11 @@ public class MemberAvatarUpdateInvocable(
try try
{ {
var (hash, image) = await newAvatar.ConvertBase64UriToImage(size: 512, crop: true); var (hash, image) = await ImageObjectExtensions.ConvertBase64UriToImage(
newAvatar,
size: 512,
crop: true
);
var prevHash = member.Avatar; var prevHash = member.Avatar;
await objectStorageService.PutObjectAsync(Path(id, hash), image, "image/webp"); await objectStorageService.PutObjectAsync(Path(id, hash), image, "image/webp");

View file

@ -39,7 +39,11 @@ public class UserAvatarUpdateInvocable(
try try
{ {
var (hash, image) = await newAvatar.ConvertBase64UriToImage(size: 512, crop: true); var (hash, image) = await ImageObjectExtensions.ConvertBase64UriToImage(
newAvatar,
size: 512,
crop: true
);
image.Seek(0, SeekOrigin.Begin); image.Seek(0, SeekOrigin.Begin);
var prevHash = user.Avatar; var prevHash = user.Avatar;

View file

@ -18,7 +18,7 @@ builder.AddSerilog();
builder builder
.WebHost.UseSentry(opts => .WebHost.UseSentry(opts =>
{ {
opts.Dsn = config.Logging.SentryUrl; opts.Dsn = config.Logging.SentryUrl ?? "";
opts.TracesSampleRate = config.Logging.SentryTracesSampleRate; opts.TracesSampleRate = config.Logging.SentryTracesSampleRate;
opts.MaxRequestBodySize = RequestSize.Small; opts.MaxRequestBodySize = RequestSize.Small;
}) })

View file

@ -45,6 +45,7 @@ public class AuthService(
}, },
}, },
LastActive = clock.GetCurrentInstant(), LastActive = clock.GetCurrentInstant(),
Sid = null!,
}; };
db.Add(user); db.Add(user);
@ -88,6 +89,7 @@ public class AuthService(
}, },
}, },
LastActive = clock.GetCurrentInstant(), LastActive = clock.GetCurrentInstant(),
Sid = null!,
}; };
db.Add(user); db.Add(user);

View file

@ -1,8 +1,9 @@
using System.Diagnostics.CodeAnalysis;
using System.Net; using System.Net;
using System.Web; using System.Web;
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Duration = NodaTime.Duration; using Foxnouns.Backend.Extensions;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Foxnouns.Backend.Services.Auth; namespace Foxnouns.Backend.Services.Auth;
@ -17,16 +18,13 @@ public partial class FediverseAuthService
Snowflake? existingAppId = null Snowflake? existingAppId = null
) )
{ {
var resp = await _client.PostAsync( var resp = await _client.PostAsJsonAsync(
$"https://{instance}/api/v1/apps", $"https://{instance}/api/v1/apps",
new FormUrlEncodedContent( new CreateMastodonApplicationRequest(
new Dictionary<string, string> ClientName: $"pronouns.cc (+{_config.BaseUrl})",
{ RedirectUris: MastodonRedirectUri(instance),
{ "client_name", $"pronouns.cc (+{_config.BaseUrl})" }, Scopes: "read read:accounts",
{ "redirect_uris", MastodonRedirectUri(instance) }, Website: _config.BaseUrl
{ "scope", "read:accounts" },
{ "website", _config.BaseUrl },
}
) )
); );
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
@ -37,12 +35,6 @@ public partial class FediverseAuthService
$"Application created on Mastodon-compatible instance {instance} was null" $"Application created on Mastodon-compatible instance {instance} was null"
); );
var token = await GetMastodonAppTokenAsync(
instance,
mastodonApp.ClientId,
mastodonApp.ClientSecret
);
FediverseApplication app; FediverseApplication app;
if (existingAppId == null) if (existingAppId == null)
@ -54,8 +46,6 @@ public partial class FediverseAuthService
ClientSecret = mastodonApp.ClientSecret, ClientSecret = mastodonApp.ClientSecret,
Domain = instance, Domain = instance,
InstanceType = FediverseInstanceType.MastodonApi, InstanceType = FediverseInstanceType.MastodonApi,
AccessToken = token,
TokenValidUntil = _clock.GetCurrentInstant() + Duration.FromDays(60),
}; };
_db.Add(app); _db.Add(app);
@ -69,8 +59,6 @@ public partial class FediverseAuthService
app.ClientId = mastodonApp.ClientId; app.ClientId = mastodonApp.ClientId;
app.ClientSecret = mastodonApp.ClientSecret; app.ClientSecret = mastodonApp.ClientSecret;
app.InstanceType = FediverseInstanceType.MastodonApi; app.InstanceType = FediverseInstanceType.MastodonApi;
app.AccessToken = null;
app.TokenValidUntil = null;
} }
await _db.SaveChangesAsync(); await _db.SaveChangesAsync();
@ -78,8 +66,14 @@ public partial class FediverseAuthService
return app; return app;
} }
private async Task<FediverseUser> GetMastodonUserAsync(FediverseApplication app, string code) private async Task<FediverseUser> GetMastodonUserAsync(
FediverseApplication app,
string code,
string state
)
{ {
await _keyCacheService.ValidateAuthStateAsync(state);
var tokenResp = await _client.PostAsync( var tokenResp = await _client.PostAsync(
MastodonTokenUri(app.Domain), MastodonTokenUri(app.Domain),
new FormUrlEncodedContent( new FormUrlEncodedContent(
@ -124,109 +118,27 @@ public partial class FediverseAuthService
private record MastodonTokenResponse([property: J("access_token")] string AccessToken); private record MastodonTokenResponse([property: J("access_token")] string AccessToken);
// TODO: Mastodon's OAuth documentation doesn't specify a "state" parameter. that feels... wrong private async Task<string> GenerateMastodonAuthUrlAsync(
// https://docs.joinmastodon.org/methods/oauth/ FediverseApplication app,
private async Task<string> GenerateMastodonAuthUrlAsync(FediverseApplication app) bool forceRefresh
)
{ {
try if (forceRefresh)
{ {
await ValidateMastodonAppAsync(app); _logger.Information(
} "An app credentials refresh was requested for {ApplicationId}, creating a new application",
catch (FoxnounsError.RemoteAuthError e) app.Id
{
_logger.Error(
e,
"Error validating app token for {AppId} on {Instance}",
app.Id,
app.Domain
); );
app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id); app = await CreateMastodonApplicationAsync(app.Domain, existingAppId: app.Id);
} }
var state = HttpUtility.UrlEncode(await _keyCacheService.GenerateAuthStateAsync());
return $"https://{app.Domain}/oauth/authorize?response_type=code" return $"https://{app.Domain}/oauth/authorize?response_type=code"
+ $"&client_id={app.ClientId}" + $"&client_id={app.ClientId}"
+ $"&scope={HttpUtility.UrlEncode("read:accounts")}" + $"&scope={HttpUtility.UrlEncode("read:accounts")}"
+ $"&redirect_uri={HttpUtility.UrlEncode(MastodonRedirectUri(app.Domain))}"; + $"&redirect_uri={HttpUtility.UrlEncode(MastodonRedirectUri(app.Domain))}"
} + $"&state={state}";
private async Task ValidateMastodonAppAsync(FediverseApplication app)
{
// If we don't have an access token stored, or it's too old, get one
// When doing this we don't need to fetch the application info
if (app.AccessToken == null || app.TokenValidUntil < _clock.GetCurrentInstant())
{
_logger.Debug(
"Application {AppId} on instance {Instance} has no valid token, fetching it",
app.Id,
app.Domain
);
app.AccessToken = await GetMastodonAppTokenAsync(
app.Domain,
app.ClientId,
app.ClientSecret
);
app.TokenValidUntil = _clock.GetCurrentInstant() + Duration.FromDays(60);
_db.Update(app);
await _db.SaveChangesAsync();
return;
}
_logger.Debug(
"Checking whether application {AppId} on instance {Instance} is still valid",
app.Id,
app.Domain
);
var req = new HttpRequestMessage(HttpMethod.Get, MastodonCurrentAppUri(app.Domain));
req.Headers.Add("Authorization", $"Bearer {app.AccessToken}");
var resp = await _client.SendAsync(req);
if (!resp.IsSuccessStatusCode)
{
var error = await resp.Content.ReadAsStringAsync();
throw new FoxnounsError.RemoteAuthError(
"Verifying app credentials returned an error",
error
);
}
}
private async Task<string> GetMastodonAppTokenAsync(
string instance,
string clientId,
string clientSecret
)
{
var resp = await _client.PostAsync(
MastodonTokenUri(instance),
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{ "grant_type", "client_credentials" },
{ "client_id", clientId },
{ "client_secret", clientSecret },
}
)
);
if (!resp.IsSuccessStatusCode)
{
var error = await resp.Content.ReadAsStringAsync();
throw new FoxnounsError.RemoteAuthError(
"Requesting app token returned an error",
error
);
}
var token = (await resp.Content.ReadFromJsonAsync<MastodonTokenResponse>())?.AccessToken;
if (token == null)
{
throw new FoxnounsError($"Token response from instance {instance} was invalid");
}
return token;
} }
private static string MastodonTokenUri(string instance) => $"https://{instance}/oauth/token"; private static string MastodonTokenUri(string instance) => $"https://{instance}/oauth/token";
@ -234,12 +146,17 @@ public partial class FediverseAuthService
private static string MastodonCurrentUserUri(string instance) => private static string MastodonCurrentUserUri(string instance) =>
$"https://{instance}/api/v1/accounts/verify_credentials"; $"https://{instance}/api/v1/accounts/verify_credentials";
private static string MastodonCurrentAppUri(string instance) => [SuppressMessage("ReSharper", "ClassNeverInstantiated.Local")]
$"https://{instance}/api/v1/apps/verify_credentials";
private record PartialMastodonApplication( private record PartialMastodonApplication(
[property: J("name")] string Name, [property: J("name")] string Name,
[property: J("client_id")] string ClientId, [property: J("client_id")] string ClientId,
[property: J("client_secret")] string ClientSecret [property: J("client_secret")] string ClientSecret
); );
private record CreateMastodonApplicationRequest(
[property: J("client_name")] string ClientName,
[property: J("redirect_uris")] string RedirectUris,
[property: J("scopes")] string Scopes,
[property: J("website")] string Website
);
} }

View file

@ -1,7 +1,6 @@
using Foxnouns.Backend.Database; using Foxnouns.Backend.Database;
using Foxnouns.Backend.Database.Models; using Foxnouns.Backend.Database.Models;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using NodaTime;
using J = System.Text.Json.Serialization.JsonPropertyNameAttribute; using J = System.Text.Json.Serialization.JsonPropertyNameAttribute;
namespace Foxnouns.Backend.Services.Auth; namespace Foxnouns.Backend.Services.Auth;
@ -10,26 +9,27 @@ public partial class FediverseAuthService
{ {
private const string NodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/2.0"; private const string NodeInfoRel = "http://nodeinfo.diaspora.software/ns/schema/2.0";
private readonly ILogger _logger;
private readonly HttpClient _client; private readonly HttpClient _client;
private readonly DatabaseContext _db; private readonly ILogger _logger;
private readonly Config _config; private readonly Config _config;
private readonly DatabaseContext _db;
private readonly KeyCacheService _keyCacheService;
private readonly ISnowflakeGenerator _snowflakeGenerator; private readonly ISnowflakeGenerator _snowflakeGenerator;
private readonly IClock _clock;
public FediverseAuthService( public FediverseAuthService(
ILogger logger, ILogger logger,
Config config, Config config,
DatabaseContext db, DatabaseContext db,
ISnowflakeGenerator snowflakeGenerator, KeyCacheService keyCacheService,
IClock clock ISnowflakeGenerator snowflakeGenerator
) )
{ {
_logger = logger.ForContext<FediverseAuthService>();
_config = config; _config = config;
_db = db; _db = db;
_keyCacheService = keyCacheService;
_snowflakeGenerator = snowflakeGenerator; _snowflakeGenerator = snowflakeGenerator;
_clock = clock;
_logger = logger.ForContext<FediverseAuthService>();
_client = new HttpClient(); _client = new HttpClient();
_client.DefaultRequestHeaders.Remove("User-Agent"); _client.DefaultRequestHeaders.Remove("User-Agent");
_client.DefaultRequestHeaders.Remove("Accept"); _client.DefaultRequestHeaders.Remove("Accept");
@ -37,10 +37,10 @@ public partial class FediverseAuthService
_client.DefaultRequestHeaders.Add("Accept", "application/json"); _client.DefaultRequestHeaders.Add("Accept", "application/json");
} }
public async Task<string> GenerateAuthUrlAsync(string instance) public async Task<string> GenerateAuthUrlAsync(string instance, bool forceRefresh)
{ {
var app = await GetApplicationAsync(instance); var app = await GetApplicationAsync(instance);
return await GenerateAuthUrlAsync(app); return await GenerateAuthUrlAsync(app, forceRefresh);
} }
// thank you, gargron and syuilo, for agreeing on a name for *once* in your lives, // thank you, gargron and syuilo, for agreeing on a name for *once* in your lives,
@ -96,21 +96,25 @@ public partial class FediverseAuthService
); );
} }
private async Task<string> GenerateAuthUrlAsync(FediverseApplication app) => private async Task<string> GenerateAuthUrlAsync(FediverseApplication app, bool forceRefresh) =>
app.InstanceType switch app.InstanceType switch
{ {
FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync(app), FediverseInstanceType.MastodonApi => await GenerateMastodonAuthUrlAsync(
app,
forceRefresh
),
FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
}; };
public async Task<FediverseUser> GetRemoteFediverseUserAsync( public async Task<FediverseUser> GetRemoteFediverseUserAsync(
FediverseApplication app, FediverseApplication app,
string code string code,
string state
) => ) =>
app.InstanceType switch app.InstanceType switch
{ {
FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code), FediverseInstanceType.MastodonApi => await GetMastodonUserAsync(app, code, state),
FediverseInstanceType.MisskeyApi => throw new NotImplementedException(), FediverseInstanceType.MisskeyApi => throw new NotImplementedException(),
_ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null), _ => throw new ArgumentOutOfRangeException(nameof(app), app.InstanceType, null),
}; };

View file

@ -32,7 +32,7 @@ public class MemberRendererService(DatabaseContext db, Config config)
member.Id, member.Id,
member.Sid, member.Sid,
member.Name, member.Name,
member.DisplayName, member.DisplayName ?? member.Name,
member.Bio, member.Bio,
AvatarUrlFor(member), AvatarUrlFor(member),
member.Links, member.Links,
@ -60,7 +60,7 @@ public class MemberRendererService(DatabaseContext db, Config config)
member.Id, member.Id,
member.Sid, member.Sid,
member.Name, member.Name,
member.DisplayName, member.DisplayName ?? member.Name,
member.Bio, member.Bio,
AvatarUrlFor(member), AvatarUrlFor(member),
member.Names, member.Names,
@ -87,7 +87,7 @@ public class MemberRendererService(DatabaseContext db, Config config)
Snowflake Id, Snowflake Id,
string Sid, string Sid,
string Name, string Name,
string? DisplayName, string DisplayName,
string? Bio, string? Bio,
string? AvatarUrl, string? AvatarUrl,
IEnumerable<FieldEntry> Names, IEnumerable<FieldEntry> Names,
@ -99,7 +99,7 @@ public class MemberRendererService(DatabaseContext db, Config config)
Snowflake Id, Snowflake Id,
string Sid, string Sid,
string Name, string Name,
string? DisplayName, string DisplayName,
string? Bio, string? Bio,
string? AvatarUrl, string? AvatarUrl,
string[] Links, string[] Links,

View file

@ -162,6 +162,7 @@ public static partial class ValidationUtils
} }
public const int MaxBioLength = 1024; public const int MaxBioLength = 1024;
public const int MaxAvatarLength = 1_500_000;
public static ValidationError? ValidateBio(string? bio) public static ValidationError? ValidateBio(string? bio)
{ {
@ -183,7 +184,10 @@ public static partial class ValidationUtils
return avatar?.Length switch return avatar?.Length switch
{ {
0 => ValidationError.GenericValidationError("Avatar cannot be empty", null), 0 => ValidationError.GenericValidationError("Avatar cannot be empty", null),
> 1_500_000 => ValidationError.GenericValidationError("Avatar is too large", null), > MaxAvatarLength => ValidationError.GenericValidationError(
"Avatar is too large",
null
),
_ => null, _ => null,
}; };
} }

View file

@ -96,6 +96,19 @@
"Mono.TextTemplating": "2.2.1" "Mono.TextTemplating": "2.2.1"
} }
}, },
"Microsoft.Extensions.Caching.Memory": {
"type": "Direct",
"requested": "[9.0.0, )",
"resolved": "9.0.0",
"contentHash": "zbnPX/JQ0pETRSUG9fNPBvpIq42Aufvs15gGYyNIMhCun9yhmWihz0WgsI7bSDPjxWTKBf8oX/zv6v2uZ3W9OQ==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "9.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
"Microsoft.Extensions.Logging.Abstractions": "9.0.0",
"Microsoft.Extensions.Options": "9.0.0",
"Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Minio": { "Minio": {
"type": "Direct", "type": "Direct",
"requested": "[6.0.3, )", "requested": "[6.0.3, )",
@ -246,6 +259,16 @@
"Swashbuckle.AspNetCore.SwaggerUI": "6.6.2" "Swashbuckle.AspNetCore.SwaggerUI": "6.6.2"
} }
}, },
"System.Text.Json": {
"type": "Direct",
"requested": "[9.0.0, )",
"resolved": "9.0.0",
"contentHash": "js7+qAu/9mQvnhA4EfGMZNEzXtJCDxgkgj8ohuxq/Qxv+R56G+ljefhiJHOxTNiw54q8vmABCWUwkMulNdlZ4A==",
"dependencies": {
"System.IO.Pipelines": "9.0.0",
"System.Text.Encodings.Web": "9.0.0"
}
},
"System.Text.RegularExpressions": { "System.Text.RegularExpressions": {
"type": "Direct", "type": "Direct",
"requested": "[4.3.1, )", "requested": "[4.3.1, )",
@ -412,22 +435,10 @@
}, },
"Microsoft.Extensions.Caching.Abstractions": { "Microsoft.Extensions.Caching.Abstractions": {
"type": "Transitive", "type": "Transitive",
"resolved": "8.0.0", "resolved": "9.0.0",
"contentHash": "3KuSxeHoNYdxVYfg2IRZCThcrlJ1XJqIXkAWikCsbm5C/bCjv7G0WoKDyuR98Q+T607QT2Zl5GsbGRkENcV2yQ==", "contentHash": "FPWZAa9c0H4dvOj351iR1jkUIs4u9ykL4Bm592yhjDyO5lCoWd+TMAHx2EMbarzUvCvgjWjJIoC6//Q9kH6YhA==",
"dependencies": { "dependencies": {
"Microsoft.Extensions.Primitives": "8.0.0" "Microsoft.Extensions.Primitives": "9.0.0"
}
},
"Microsoft.Extensions.Caching.Memory": {
"type": "Transitive",
"resolved": "8.0.0",
"contentHash": "7pqivmrZDzo1ADPkRwjy+8jtRKWRCPag9qPI+p7sgu7Q4QreWhcvbiWXsbhP+yY8XSiDvZpu2/LWdBv7PnmOpQ==",
"dependencies": {
"Microsoft.Extensions.Caching.Abstractions": "8.0.0",
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0",
"Microsoft.Extensions.Logging.Abstractions": "8.0.0",
"Microsoft.Extensions.Options": "8.0.0",
"Microsoft.Extensions.Primitives": "8.0.0"
} }
}, },
"Microsoft.Extensions.Configuration": { "Microsoft.Extensions.Configuration": {
@ -465,8 +476,8 @@
}, },
"Microsoft.Extensions.DependencyInjection.Abstractions": { "Microsoft.Extensions.DependencyInjection.Abstractions": {
"type": "Transitive", "type": "Transitive",
"resolved": "8.0.1", "resolved": "9.0.0",
"contentHash": "fGLiCRLMYd00JYpClraLjJTNKLmMJPnqxMaiRzEBIIvevlzxz33mXy39Lkd48hu1G+N21S7QpaO5ZzKsI6FRuA==" "contentHash": "+6f2qv2a3dLwd5w6JanPIPs47CxRbnk+ZocMJUhv9NxP88VlOcJYZs9jY+MYSjxvady08bUZn6qgiNh7DadGgg=="
}, },
"Microsoft.Extensions.DependencyModel": { "Microsoft.Extensions.DependencyModel": {
"type": "Transitive", "type": "Transitive",
@ -542,10 +553,11 @@
}, },
"Microsoft.Extensions.Logging.Abstractions": { "Microsoft.Extensions.Logging.Abstractions": {
"type": "Transitive", "type": "Transitive",
"resolved": "8.0.0", "resolved": "9.0.0",
"contentHash": "arDBqTgFCyS0EvRV7O3MZturChstm50OJ0y9bDJvAcmEPJm0FFpFyjU/JLYyStNGGey081DvnQYlncNX5SJJGA==", "contentHash": "g0UfujELzlLbHoVG8kPKVBaW470Ewi+jnptGS9KUi6jcb+k2StujtK3m26DFSGGwQ/+bVgZfsWqNzlP6YOejvw==",
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0" "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
"System.Diagnostics.DiagnosticSource": "9.0.0"
} }
}, },
"Microsoft.Extensions.Logging.Configuration": { "Microsoft.Extensions.Logging.Configuration": {
@ -570,11 +582,11 @@
}, },
"Microsoft.Extensions.Options": { "Microsoft.Extensions.Options": {
"type": "Transitive", "type": "Transitive",
"resolved": "8.0.0", "resolved": "9.0.0",
"contentHash": "JOVOfqpnqlVLUzINQ2fox8evY2SKLYJ3BV8QDe/Jyp21u1T7r45x/R/5QdteURMR5r01GxeJSBBUOCOyaNXA3g==", "contentHash": "y2146b3jrPI3Q0lokKXdKLpmXqakYbDIPDV6r3M8SqvSf45WwOTzkyfDpxnZXJsJQEpAsAqjUq5Pu8RCJMjubg==",
"dependencies": { "dependencies": {
"Microsoft.Extensions.DependencyInjection.Abstractions": "8.0.0", "Microsoft.Extensions.DependencyInjection.Abstractions": "9.0.0",
"Microsoft.Extensions.Primitives": "8.0.0" "Microsoft.Extensions.Primitives": "9.0.0"
} }
}, },
"Microsoft.Extensions.Options.ConfigurationExtensions": { "Microsoft.Extensions.Options.ConfigurationExtensions": {
@ -591,8 +603,8 @@
}, },
"Microsoft.Extensions.Primitives": { "Microsoft.Extensions.Primitives": {
"type": "Transitive", "type": "Transitive",
"resolved": "8.0.0", "resolved": "9.0.0",
"contentHash": "bXJEZrW9ny8vjMF1JV253WeLhpEVzFo1lyaZu1vQ4ZxWUlVvknZ/+ftFgVheLubb4eZPSwwxBeqS1JkCOjxd8g==" "contentHash": "N3qEBzmLMYiASUlKxxFIISP4AiwuPTHF5uCh+2CWSwwzAJiIYx0kBJsS30cp1nvhSySFAVi30jecD307jV+8Kg=="
}, },
"Microsoft.NETCore.Platforms": { "Microsoft.NETCore.Platforms": {
"type": "Transitive", "type": "Transitive",
@ -983,8 +995,8 @@
}, },
"System.Diagnostics.DiagnosticSource": { "System.Diagnostics.DiagnosticSource": {
"type": "Transitive", "type": "Transitive",
"resolved": "8.0.0", "resolved": "9.0.0",
"contentHash": "c9xLpVz6PL9lp/djOWtk5KPDZq3cSYpmXoJQY524EOtuFl5z9ZtsotpsyrDW40U1DRnQSYvcPKEUV0X//u6gkQ==" "contentHash": "ddppcFpnbohLWdYKr/ZeLZHmmI+DXFgZ3Snq+/E7SwcdW4UnvxmaugkwGywvGVWkHPGCSZjCP+MLzu23AL5SDw=="
}, },
"System.Diagnostics.Tracing": { "System.Diagnostics.Tracing": {
"type": "Transitive", "type": "Transitive",
@ -1072,8 +1084,8 @@
}, },
"System.IO.Pipelines": { "System.IO.Pipelines": {
"type": "Transitive", "type": "Transitive",
"resolved": "6.0.3", "resolved": "9.0.0",
"contentHash": "ryTgF+iFkpGZY1vRQhfCzX0xTdlV3pyaTTqRu2ETbEv+HlV7O6y7hyQURnghNIXvctl5DuZ//Dpks6HdL/Txgw==" "contentHash": "eA3cinogwaNB4jdjQHOP3Z3EuyiDII7MT35jgtnsA4vkn0LUrrSHsU0nzHTzFzmaFYeKV7MYyMxOocFzsBHpTw=="
}, },
"System.Linq": { "System.Linq": {
"type": "Transitive", "type": "Transitive",
@ -1484,16 +1496,8 @@
}, },
"System.Text.Encodings.Web": { "System.Text.Encodings.Web": {
"type": "Transitive", "type": "Transitive",
"resolved": "8.0.0", "resolved": "9.0.0",
"contentHash": "yev/k9GHAEGx2Rg3/tU6MQh4HGBXJs70y7j1LaM1i/ER9po+6nnQ6RRqTJn1E7Xu0fbIFK80Nh5EoODxrbxwBQ==" "contentHash": "e2hMgAErLbKyUUwt18qSBf9T5Y+SFAL3ZedM8fLupkVj8Rj2PZ9oxQ37XX2LF8fTO1wNIxvKpihD7Of7D/NxZw=="
},
"System.Text.Json": {
"type": "Transitive",
"resolved": "8.0.4",
"contentHash": "bAkhgDJ88XTsqczoxEMliSrpijKZHhbJQldhAmObj/RbrN3sU5dcokuXmWJWsdQAhiMJ9bTayWsL1C9fbbCRhw==",
"dependencies": {
"System.Text.Encodings.Web": "8.0.0"
}
}, },
"System.Threading": { "System.Threading": {
"type": "Transitive", "type": "Transitive",