chore: add csharpier to husky, format backend with csharpier
This commit is contained in:
parent
5fab66444f
commit
7f971e8549
73 changed files with 2098 additions and 1048 deletions
|
@ -16,8 +16,12 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
|
|||
/// Creates a new user with the given email address and password.
|
||||
/// This method does <i>not</i> save the resulting user, the caller must still call <see cref="M:Microsoft.EntityFrameworkCore.DbContext.SaveChanges" />.
|
||||
/// </summary>
|
||||
public async Task<User> CreateUserWithPasswordAsync(string username, string email, string password,
|
||||
CancellationToken ct = default)
|
||||
public async Task<User> CreateUserWithPasswordAsync(
|
||||
string username,
|
||||
string email,
|
||||
string password,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
|
@ -26,9 +30,13 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
|
|||
AuthMethods =
|
||||
{
|
||||
new AuthMethod
|
||||
{ Id = snowflakeGenerator.GenerateSnowflake(), AuthType = AuthType.Email, RemoteId = email }
|
||||
{
|
||||
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||
AuthType = AuthType.Email,
|
||||
RemoteId = email,
|
||||
},
|
||||
},
|
||||
LastActive = clock.GetCurrentInstant()
|
||||
LastActive = clock.GetCurrentInstant(),
|
||||
};
|
||||
|
||||
db.Add(user);
|
||||
|
@ -42,8 +50,14 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
|
|||
/// To create a user with email authentication, use <see cref="CreateUserWithPasswordAsync" />
|
||||
/// This method does <i>not</i> save the resulting user, the caller must still call <see cref="M:Microsoft.EntityFrameworkCore.DbContext.SaveChanges" />.
|
||||
/// </summary>
|
||||
public async Task<User> CreateUserWithRemoteAuthAsync(string username, AuthType authType, string remoteId,
|
||||
string remoteUsername, FediverseApplication? instance = null, CancellationToken ct = default)
|
||||
public async Task<User> CreateUserWithRemoteAuthAsync(
|
||||
string username,
|
||||
AuthType authType,
|
||||
string remoteId,
|
||||
string remoteUsername,
|
||||
FediverseApplication? instance = null,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
AssertValidAuthType(authType, instance);
|
||||
|
||||
|
@ -58,11 +72,14 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
|
|||
{
|
||||
new AuthMethod
|
||||
{
|
||||
Id = snowflakeGenerator.GenerateSnowflake(), AuthType = authType, RemoteId = remoteId,
|
||||
RemoteUsername = remoteUsername, FediverseApplication = instance
|
||||
}
|
||||
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||
AuthType = authType,
|
||||
RemoteId = remoteId,
|
||||
RemoteUsername = remoteUsername,
|
||||
FediverseApplication = instance,
|
||||
},
|
||||
},
|
||||
LastActive = clock.GetCurrentInstant()
|
||||
LastActive = clock.GetCurrentInstant(),
|
||||
};
|
||||
|
||||
db.Add(user);
|
||||
|
@ -78,19 +95,31 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
|
|||
/// <returns>A tuple of the authenticated user and whether multi-factor authentication is required</returns>
|
||||
/// <exception cref="ApiError.NotFound">Thrown if the email address is not associated with any user
|
||||
/// or if the password is incorrect</exception>
|
||||
public async Task<(User, EmailAuthenticationResult)> AuthenticateUserAsync(string email, string password,
|
||||
CancellationToken ct = default)
|
||||
public async Task<(User, EmailAuthenticationResult)> AuthenticateUserAsync(
|
||||
string email,
|
||||
string password,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var user = await db.Users.FirstOrDefaultAsync(u =>
|
||||
u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email), ct);
|
||||
var user = await db.Users.FirstOrDefaultAsync(
|
||||
u => u.AuthMethods.Any(a => a.AuthType == AuthType.Email && a.RemoteId == email),
|
||||
ct
|
||||
);
|
||||
if (user == null)
|
||||
throw new ApiError.NotFound("No user with that email address found, or password is incorrect",
|
||||
ErrorCode.UserNotFound);
|
||||
throw new ApiError.NotFound(
|
||||
"No user with that email address found, or password is incorrect",
|
||||
ErrorCode.UserNotFound
|
||||
);
|
||||
|
||||
var pwResult = await Task.Run(() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password), ct);
|
||||
var pwResult = await Task.Run(
|
||||
() => _passwordHasher.VerifyHashedPassword(user, user.Password!, password),
|
||||
ct
|
||||
);
|
||||
if (pwResult == PasswordVerificationResult.Failed) // TODO: this seems to fail on some valid passwords?
|
||||
throw new ApiError.NotFound("No user with that email address found, or password is incorrect",
|
||||
ErrorCode.UserNotFound);
|
||||
throw new ApiError.NotFound(
|
||||
"No user with that email address found, or password is incorrect",
|
||||
ErrorCode.UserNotFound
|
||||
);
|
||||
if (pwResult == PasswordVerificationResult.SuccessRehashNeeded)
|
||||
{
|
||||
user.Password = await Task.Run(() => _passwordHasher.HashPassword(user, password), ct);
|
||||
|
@ -117,19 +146,33 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
|
|||
/// <returns>A user object, or null if the remote account isn't linked to any user.</returns>
|
||||
/// <exception cref="FoxnounsError">Thrown if <c>instance</c> is passed when not required,
|
||||
/// or not passed when required</exception>
|
||||
public async Task<User?> AuthenticateUserAsync(AuthType authType, string remoteId,
|
||||
FediverseApplication? instance = null, CancellationToken ct = default)
|
||||
public async Task<User?> AuthenticateUserAsync(
|
||||
AuthType authType,
|
||||
string remoteId,
|
||||
FediverseApplication? instance = null,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
AssertValidAuthType(authType, instance);
|
||||
|
||||
return await db.Users.FirstOrDefaultAsync(u =>
|
||||
u.AuthMethods.Any(a =>
|
||||
a.AuthType == authType && a.RemoteId == remoteId && a.FediverseApplication == instance), ct);
|
||||
return await db.Users.FirstOrDefaultAsync(
|
||||
u =>
|
||||
u.AuthMethods.Any(a =>
|
||||
a.AuthType == authType
|
||||
&& a.RemoteId == remoteId
|
||||
&& a.FediverseApplication == instance
|
||||
),
|
||||
ct
|
||||
);
|
||||
}
|
||||
|
||||
public async Task<AuthMethod> AddAuthMethodAsync(Snowflake userId, AuthType authType, string remoteId,
|
||||
public async Task<AuthMethod> AddAuthMethodAsync(
|
||||
Snowflake userId,
|
||||
AuthType authType,
|
||||
string remoteId,
|
||||
string? remoteUsername = null,
|
||||
CancellationToken ct = default)
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
AssertValidAuthType(authType, null);
|
||||
|
||||
|
@ -139,7 +182,7 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
|
|||
AuthType = authType,
|
||||
RemoteId = remoteId,
|
||||
RemoteUsername = remoteUsername,
|
||||
UserId = userId
|
||||
UserId = userId,
|
||||
};
|
||||
|
||||
db.Add(authMethod);
|
||||
|
@ -147,21 +190,33 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
|
|||
return authMethod;
|
||||
}
|
||||
|
||||
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 (!AuthUtils.ValidateScopes(application, scopes))
|
||||
throw new ApiError.BadRequest("Invalid scopes requested for this token", "scopes", scopes);
|
||||
throw new ApiError.BadRequest(
|
||||
"Invalid scopes requested for this token",
|
||||
"scopes",
|
||||
scopes
|
||||
);
|
||||
|
||||
var (token, hash) = GenerateToken();
|
||||
return (token, new Token
|
||||
{
|
||||
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||
Hash = hash,
|
||||
Application = application,
|
||||
User = user,
|
||||
ExpiresAt = expires,
|
||||
Scopes = scopes
|
||||
});
|
||||
return (
|
||||
token,
|
||||
new Token
|
||||
{
|
||||
Id = snowflakeGenerator.GenerateSnowflake(),
|
||||
Hash = hash,
|
||||
Application = application,
|
||||
User = user,
|
||||
ExpiresAt = expires,
|
||||
Scopes = scopes,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private static (string, byte[]) GenerateToken()
|
||||
|
@ -179,4 +234,4 @@ public class AuthService(IClock clock, DatabaseContext db, ISnowflakeGenerator s
|
|||
if (authType != AuthType.Fediverse && instance != null)
|
||||
throw new FoxnounsError("Non-Fediverse authentication does not require an instance.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,26 +10,43 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
|
|||
{
|
||||
private readonly ILogger _logger = logger.ForContext<KeyCacheService>();
|
||||
|
||||
public Task SetKeyAsync(string key, string value, Duration expireAfter, CancellationToken ct = default) =>
|
||||
SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter, ct);
|
||||
public Task SetKeyAsync(
|
||||
string key,
|
||||
string value,
|
||||
Duration expireAfter,
|
||||
CancellationToken ct = default
|
||||
) => SetKeyAsync(key, value, clock.GetCurrentInstant() + expireAfter, ct);
|
||||
|
||||
public async Task SetKeyAsync(string key, string value, Instant expires, CancellationToken ct = default)
|
||||
public async Task SetKeyAsync(
|
||||
string key,
|
||||
string value,
|
||||
Instant expires,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
db.TemporaryKeys.Add(new TemporaryKey
|
||||
{
|
||||
Expires = expires,
|
||||
Key = key,
|
||||
Value = value,
|
||||
});
|
||||
db.TemporaryKeys.Add(
|
||||
new TemporaryKey
|
||||
{
|
||||
Expires = expires,
|
||||
Key = key,
|
||||
Value = value,
|
||||
}
|
||||
);
|
||||
await db.SaveChangesAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<string?> GetKeyAsync(string key, bool delete = false, CancellationToken ct = default)
|
||||
public async Task<string?> GetKeyAsync(
|
||||
string key,
|
||||
bool delete = false,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var value = await db.TemporaryKeys.FirstOrDefaultAsync(k => k.Key == key, ct);
|
||||
if (value == null) return null;
|
||||
if (value == null)
|
||||
return null;
|
||||
|
||||
if (delete) await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct);
|
||||
if (delete)
|
||||
await db.TemporaryKeys.Where(k => k.Key == key).ExecuteDeleteAsync(ct);
|
||||
|
||||
return value.Value;
|
||||
}
|
||||
|
@ -39,23 +56,41 @@ public class KeyCacheService(DatabaseContext db, IClock clock, ILogger logger)
|
|||
|
||||
public async Task DeleteExpiredKeysAsync(CancellationToken ct)
|
||||
{
|
||||
var count = await db.TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant()).ExecuteDeleteAsync(ct);
|
||||
if (count != 0) _logger.Information("Removed {Count} expired keys from the database", count);
|
||||
var count = await db
|
||||
.TemporaryKeys.Where(k => k.Expires < clock.GetCurrentInstant())
|
||||
.ExecuteDeleteAsync(ct);
|
||||
if (count != 0)
|
||||
_logger.Information("Removed {Count} expired keys from the database", count);
|
||||
}
|
||||
|
||||
public Task SetKeyAsync<T>(string key, T obj, Duration expiresAt, CancellationToken ct = default) where T : class =>
|
||||
SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct);
|
||||
public Task SetKeyAsync<T>(
|
||||
string key,
|
||||
T obj,
|
||||
Duration expiresAt,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
where T : class => SetKeyAsync(key, obj, clock.GetCurrentInstant() + expiresAt, ct);
|
||||
|
||||
public async Task SetKeyAsync<T>(string key, T obj, Instant expires, CancellationToken ct = default) where T : class
|
||||
public async Task SetKeyAsync<T>(
|
||||
string key,
|
||||
T obj,
|
||||
Instant expires,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
where T : class
|
||||
{
|
||||
var value = JsonConvert.SerializeObject(obj);
|
||||
await SetKeyAsync(key, value, expires, ct);
|
||||
}
|
||||
|
||||
public async Task<T?> GetKeyAsync<T>(string key, bool delete = false, CancellationToken ct = default)
|
||||
public async Task<T?> GetKeyAsync<T>(
|
||||
string key,
|
||||
bool delete = false,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
where T : class
|
||||
{
|
||||
var value = await GetKeyAsync(key, delete, ct);
|
||||
return value == null ? default : JsonConvert.DeserializeObject<T>(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,12 +15,17 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
|
|||
_logger.Debug("Sending account creation email to {ToEmail}", to);
|
||||
try
|
||||
{
|
||||
await mailer.SendAsync(new AccountCreationMailable(config, new AccountCreationMailableView
|
||||
{
|
||||
BaseUrl = config.BaseUrl,
|
||||
To = to,
|
||||
Code = code
|
||||
}));
|
||||
await mailer.SendAsync(
|
||||
new AccountCreationMailable(
|
||||
config,
|
||||
new AccountCreationMailableView
|
||||
{
|
||||
BaseUrl = config.BaseUrl,
|
||||
To = to,
|
||||
Code = code,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
catch (Exception exc)
|
||||
{
|
||||
|
@ -28,4 +33,4 @@ public class MailService(ILogger logger, IMailer mailer, IQueue queue, Config co
|
|||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,17 +10,17 @@ public class MemberRendererService(DatabaseContext db, Config config)
|
|||
{
|
||||
public async Task<IEnumerable<PartialMember>> RenderUserMembersAsync(User user, Token? token)
|
||||
{
|
||||
var canReadHiddenMembers = token != null && token.UserId == user.Id && token.HasScope("member.read");
|
||||
var renderUnlisted = token != null && token.UserId == user.Id && token.HasScope("user.read_hidden");
|
||||
var canReadHiddenMembers =
|
||||
token != null && token.UserId == user.Id && token.HasScope("member.read");
|
||||
var renderUnlisted =
|
||||
token != null && token.UserId == user.Id && token.HasScope("user.read_hidden");
|
||||
var canReadMemberList = !user.ListHidden || canReadHiddenMembers;
|
||||
|
||||
IEnumerable<Member> members = canReadMemberList
|
||||
? await db.Members
|
||||
.Where(m => m.UserId == user.Id)
|
||||
.OrderBy(m => m.Name)
|
||||
.ToListAsync()
|
||||
? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync()
|
||||
: [];
|
||||
if (!canReadHiddenMembers) members = members.Where(m => !m.Unlisted);
|
||||
if (!canReadHiddenMembers)
|
||||
members = members.Where(m => !m.Unlisted);
|
||||
return members.Select(m => RenderPartialMember(m, renderUnlisted));
|
||||
}
|
||||
|
||||
|
@ -29,25 +29,54 @@ public class MemberRendererService(DatabaseContext db, Config config)
|
|||
var renderUnlisted = token?.UserId == member.UserId && token.HasScope("user.read_hidden");
|
||||
|
||||
return new MemberResponse(
|
||||
member.Id, member.Sid, member.Name, member.DisplayName, member.Bio,
|
||||
AvatarUrlFor(member), member.Links, member.Names, member.Pronouns, member.Fields,
|
||||
member.Id,
|
||||
member.Sid,
|
||||
member.Name,
|
||||
member.DisplayName,
|
||||
member.Bio,
|
||||
AvatarUrlFor(member),
|
||||
member.Links,
|
||||
member.Names,
|
||||
member.Pronouns,
|
||||
member.Fields,
|
||||
member.ProfileFlags.Select(f => RenderPrideFlag(f.PrideFlag)),
|
||||
RenderPartialUser(member.User), renderUnlisted ? member.Unlisted : null);
|
||||
RenderPartialUser(member.User),
|
||||
renderUnlisted ? member.Unlisted : null
|
||||
);
|
||||
}
|
||||
|
||||
private UserRendererService.PartialUser RenderPartialUser(User user) =>
|
||||
new(user.Id, user.Sid, user.Username, user.DisplayName, AvatarUrlFor(user), user.CustomPreferences);
|
||||
new(
|
||||
user.Id,
|
||||
user.Sid,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
AvatarUrlFor(user),
|
||||
user.CustomPreferences
|
||||
);
|
||||
|
||||
public PartialMember RenderPartialMember(Member member, bool renderUnlisted = false) => new(member.Id, member.Sid,
|
||||
member.Name,
|
||||
member.DisplayName, member.Bio, AvatarUrlFor(member), member.Names, member.Pronouns,
|
||||
renderUnlisted ? member.Unlisted : null);
|
||||
public PartialMember RenderPartialMember(Member member, bool renderUnlisted = false) =>
|
||||
new(
|
||||
member.Id,
|
||||
member.Sid,
|
||||
member.Name,
|
||||
member.DisplayName,
|
||||
member.Bio,
|
||||
AvatarUrlFor(member),
|
||||
member.Names,
|
||||
member.Pronouns,
|
||||
renderUnlisted ? member.Unlisted : null
|
||||
);
|
||||
|
||||
private string? AvatarUrlFor(Member member) =>
|
||||
member.Avatar != null ? $"{config.MediaBaseUrl}/members/{member.Id}/avatars/{member.Avatar}.webp" : null;
|
||||
member.Avatar != null
|
||||
? $"{config.MediaBaseUrl}/members/{member.Id}/avatars/{member.Avatar}.webp"
|
||||
: null;
|
||||
|
||||
private string? AvatarUrlFor(User user) =>
|
||||
user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null;
|
||||
user.Avatar != null
|
||||
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
||||
: null;
|
||||
|
||||
private string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp";
|
||||
|
||||
|
@ -63,8 +92,8 @@ public class MemberRendererService(DatabaseContext db, Config config)
|
|||
string? AvatarUrl,
|
||||
IEnumerable<FieldEntry> Names,
|
||||
IEnumerable<Pronoun> Pronouns,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
bool? Unlisted);
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted
|
||||
);
|
||||
|
||||
public record MemberResponse(
|
||||
Snowflake Id,
|
||||
|
@ -79,6 +108,6 @@ public class MemberRendererService(DatabaseContext db, Config config)
|
|||
IEnumerable<Field> Fields,
|
||||
IEnumerable<UserRendererService.PrideFlagResponse> Flags,
|
||||
UserRendererService.PartialUser User,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
bool? Unlisted);
|
||||
}
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] bool? Unlisted
|
||||
);
|
||||
}
|
||||
|
|
|
@ -6,10 +6,7 @@ using Prometheus;
|
|||
|
||||
namespace Foxnouns.Backend.Services;
|
||||
|
||||
public class MetricsCollectionService(
|
||||
ILogger logger,
|
||||
IServiceProvider services,
|
||||
IClock clock)
|
||||
public class MetricsCollectionService(ILogger logger, IServiceProvider services, IClock clock)
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<MetricsCollectionService>();
|
||||
|
||||
|
@ -31,8 +28,10 @@ public class MetricsCollectionService(
|
|||
FoxnounsMetrics.UsersActiveWeekCount.Set(users.Count(i => i > now - Week));
|
||||
FoxnounsMetrics.UsersActiveDayCount.Set(users.Count(i => i > now - Day));
|
||||
|
||||
var memberCount = await db.Members.Include(m => m.User)
|
||||
.Where(m => !m.Unlisted && !m.User.ListHidden && !m.User.Deleted).CountAsync(ct);
|
||||
var memberCount = await db
|
||||
.Members.Include(m => m.User)
|
||||
.Where(m => !m.Unlisted && !m.User.ListHidden && !m.User.Deleted)
|
||||
.CountAsync(ct);
|
||||
FoxnounsMetrics.MemberCount.Set(memberCount);
|
||||
|
||||
var process = Process.GetCurrentProcess();
|
||||
|
@ -42,13 +41,17 @@ public class MetricsCollectionService(
|
|||
FoxnounsMetrics.ProcessThreads.Set(process.Threads.Count);
|
||||
FoxnounsMetrics.ProcessHandles.Set(process.HandleCount);
|
||||
|
||||
_logger.Information("Collected metrics in {DurationMilliseconds} ms",
|
||||
timer.ObserveDuration().TotalMilliseconds);
|
||||
_logger.Information(
|
||||
"Collected metrics in {DurationMilliseconds} ms",
|
||||
timer.ObserveDuration().TotalMilliseconds
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
public class BackgroundMetricsCollectionService(ILogger logger, MetricsCollectionService metricsCollectionService)
|
||||
: BackgroundService
|
||||
public class BackgroundMetricsCollectionService(
|
||||
ILogger logger,
|
||||
MetricsCollectionService metricsCollectionService
|
||||
) : BackgroundService
|
||||
{
|
||||
private readonly ILogger _logger = logger.ForContext<BackgroundMetricsCollectionService>();
|
||||
|
||||
|
@ -63,4 +66,4 @@ public class BackgroundMetricsCollectionService(ILogger logger, MetricsCollectio
|
|||
await metricsCollectionService.CollectMetricsAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,7 +15,8 @@ public class ObjectStorageService(ILogger logger, Config config, IMinioClient mi
|
|||
{
|
||||
await minioClient.RemoveObjectAsync(
|
||||
new RemoveObjectArgs().WithBucket(config.Storage.Bucket).WithObject(path),
|
||||
ct);
|
||||
ct
|
||||
);
|
||||
}
|
||||
catch (InvalidObjectNameException)
|
||||
{
|
||||
|
@ -23,17 +24,28 @@ public class ObjectStorageService(ILogger logger, Config config, IMinioClient mi
|
|||
}
|
||||
}
|
||||
|
||||
public async Task PutObjectAsync(string path, Stream data, string contentType, CancellationToken ct = default)
|
||||
public async Task PutObjectAsync(
|
||||
string path,
|
||||
Stream data,
|
||||
string contentType,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
_logger.Debug("Putting object at path {Path} with length {Length} and content type {ContentType}", path,
|
||||
data.Length, contentType);
|
||||
_logger.Debug(
|
||||
"Putting object at path {Path} with length {Length} and content type {ContentType}",
|
||||
path,
|
||||
data.Length,
|
||||
contentType
|
||||
);
|
||||
|
||||
await minioClient.PutObjectAsync(new PutObjectArgs()
|
||||
await minioClient.PutObjectAsync(
|
||||
new PutObjectArgs()
|
||||
.WithBucket(config.Storage.Bucket)
|
||||
.WithObject(path)
|
||||
.WithObjectSize(data.Length)
|
||||
.WithStreamData(data)
|
||||
.WithContentType(contentType), ct
|
||||
.WithContentType(contentType),
|
||||
ct
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,4 +23,4 @@ public class PeriodicTasksService(ILogger logger, IServiceProvider services) : B
|
|||
var keyCacheSvc = scope.ServiceProvider.GetRequiredService<KeyCacheService>();
|
||||
await keyCacheSvc.DeleteExpiredKeysAsync(ct);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,30 +11,42 @@ public class RemoteAuthService(Config config, ILogger logger)
|
|||
private readonly Uri _discordTokenUri = new("https://discord.com/api/oauth2/token");
|
||||
private readonly Uri _discordUserUri = new("https://discord.com/api/v10/users/@me");
|
||||
|
||||
public async Task<RemoteUser> RequestDiscordTokenAsync(string code, string state, CancellationToken ct = default)
|
||||
public async Task<RemoteUser> RequestDiscordTokenAsync(
|
||||
string code,
|
||||
string state,
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var redirectUri = $"{config.BaseUrl}/auth/callback/discord";
|
||||
var resp = await _httpClient.PostAsync(_discordTokenUri, new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "client_id", config.DiscordAuth.ClientId! },
|
||||
{ "client_secret", config.DiscordAuth.ClientSecret! },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "code", code },
|
||||
{ "redirect_uri", redirectUri }
|
||||
}
|
||||
), ct);
|
||||
var resp = await _httpClient.PostAsync(
|
||||
_discordTokenUri,
|
||||
new FormUrlEncodedContent(
|
||||
new Dictionary<string, string>
|
||||
{
|
||||
{ "client_id", config.DiscordAuth.ClientId! },
|
||||
{ "client_secret", config.DiscordAuth.ClientSecret! },
|
||||
{ "grant_type", "authorization_code" },
|
||||
{ "code", code },
|
||||
{ "redirect_uri", redirectUri },
|
||||
}
|
||||
),
|
||||
ct
|
||||
);
|
||||
if (!resp.IsSuccessStatusCode)
|
||||
{
|
||||
var respBody = await resp.Content.ReadAsStringAsync(ct);
|
||||
_logger.Error("Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}",
|
||||
(int)resp.StatusCode, respBody);
|
||||
_logger.Error(
|
||||
"Received error status {StatusCode} when exchanging OAuth token: {ErrorBody}",
|
||||
(int)resp.StatusCode,
|
||||
respBody
|
||||
);
|
||||
throw new FoxnounsError("Invalid Discord OAuth response");
|
||||
}
|
||||
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var token = await resp.Content.ReadFromJsonAsync<DiscordTokenResponse>(ct);
|
||||
if (token == null) throw new FoxnounsError("Discord token response was null");
|
||||
if (token == null)
|
||||
throw new FoxnounsError("Discord token response was null");
|
||||
|
||||
var req = new HttpRequestMessage(HttpMethod.Get, _discordUserUri);
|
||||
req.Headers.Add("Authorization", $"{token.token_type} {token.access_token}");
|
||||
|
@ -42,20 +54,27 @@ public class RemoteAuthService(Config config, ILogger logger)
|
|||
var resp2 = await _httpClient.SendAsync(req, ct);
|
||||
resp2.EnsureSuccessStatusCode();
|
||||
var user = await resp2.Content.ReadFromJsonAsync<DiscordUserResponse>(ct);
|
||||
if (user == null) throw new FoxnounsError("Discord user response was null");
|
||||
if (user == null)
|
||||
throw new FoxnounsError("Discord user response was null");
|
||||
|
||||
return new RemoteUser(user.id, user.username);
|
||||
}
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming",
|
||||
Justification = "Easier to use snake_case here, rather than passing in JSON converter options")]
|
||||
[SuppressMessage(
|
||||
"ReSharper",
|
||||
"InconsistentNaming",
|
||||
Justification = "Easier to use snake_case here, rather than passing in JSON converter options"
|
||||
)]
|
||||
[UsedImplicitly]
|
||||
private record DiscordTokenResponse(string access_token, string token_type);
|
||||
|
||||
[SuppressMessage("ReSharper", "InconsistentNaming",
|
||||
Justification = "Easier to use snake_case here, rather than passing in JSON converter options")]
|
||||
[SuppressMessage(
|
||||
"ReSharper",
|
||||
"InconsistentNaming",
|
||||
Justification = "Easier to use snake_case here, rather than passing in JSON converter options"
|
||||
)]
|
||||
[UsedImplicitly]
|
||||
private record DiscordUserResponse(string id, string username);
|
||||
|
||||
public record RemoteUser(string Id, string Username);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,48 +7,73 @@ using NodaTime;
|
|||
|
||||
namespace Foxnouns.Backend.Services;
|
||||
|
||||
public class UserRendererService(DatabaseContext db, MemberRendererService memberRenderer, Config config)
|
||||
public class UserRendererService(
|
||||
DatabaseContext db,
|
||||
MemberRendererService memberRenderer,
|
||||
Config config
|
||||
)
|
||||
{
|
||||
public async Task<UserResponse> RenderUserAsync(User user, User? selfUser = null,
|
||||
public async Task<UserResponse> RenderUserAsync(
|
||||
User user,
|
||||
User? selfUser = null,
|
||||
Token? token = null,
|
||||
bool renderMembers = true,
|
||||
bool renderAuthMethods = false,
|
||||
CancellationToken ct = default)
|
||||
CancellationToken ct = default
|
||||
)
|
||||
{
|
||||
var isSelfUser = selfUser?.Id == user.Id;
|
||||
var tokenCanReadHiddenMembers = token.HasScope("member.read") && isSelfUser;
|
||||
var tokenHidden = token.HasScope("user.read_hidden") && isSelfUser;
|
||||
var tokenPrivileged = token.HasScope("user.read_privileged") && isSelfUser;
|
||||
|
||||
renderMembers = renderMembers &&
|
||||
(!user.ListHidden || tokenCanReadHiddenMembers);
|
||||
renderMembers = renderMembers && (!user.ListHidden || tokenCanReadHiddenMembers);
|
||||
renderAuthMethods = renderAuthMethods && tokenPrivileged;
|
||||
|
||||
IEnumerable<Member> members =
|
||||
renderMembers ? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync(ct) : [];
|
||||
IEnumerable<Member> members = renderMembers
|
||||
? await db.Members.Where(m => m.UserId == user.Id).OrderBy(m => m.Name).ToListAsync(ct)
|
||||
: [];
|
||||
// Unless the user is requesting their own members AND the token can read hidden members, we filter out unlisted members.
|
||||
if (!(isSelfUser && tokenCanReadHiddenMembers)) members = members.Where(m => !m.Unlisted);
|
||||
if (!(isSelfUser && tokenCanReadHiddenMembers))
|
||||
members = members.Where(m => !m.Unlisted);
|
||||
|
||||
var flags = await db.UserFlags.Where(f => f.UserId == user.Id).OrderBy(f => f.Id).ToListAsync(ct);
|
||||
var flags = await db
|
||||
.UserFlags.Where(f => f.UserId == user.Id)
|
||||
.OrderBy(f => f.Id)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var authMethods = renderAuthMethods
|
||||
? await db.AuthMethods
|
||||
.Where(a => a.UserId == user.Id)
|
||||
? await db
|
||||
.AuthMethods.Where(a => a.UserId == user.Id)
|
||||
.Include(a => a.FediverseApplication)
|
||||
.ToListAsync(ct)
|
||||
: [];
|
||||
|
||||
return new UserResponse(
|
||||
user.Id, user.Sid, user.Username, user.DisplayName, user.Bio, user.MemberTitle, AvatarUrlFor(user),
|
||||
user.Id,
|
||||
user.Sid,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
user.Bio,
|
||||
user.MemberTitle,
|
||||
AvatarUrlFor(user),
|
||||
user.Links,
|
||||
user.Names, user.Pronouns, user.Fields, user.CustomPreferences,
|
||||
user.Names,
|
||||
user.Pronouns,
|
||||
user.Fields,
|
||||
user.CustomPreferences,
|
||||
flags.Select(f => RenderPrideFlag(f.PrideFlag)),
|
||||
user.Role,
|
||||
renderMembers ? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden)) : null,
|
||||
renderMembers
|
||||
? members.Select(m => memberRenderer.RenderPartialMember(m, tokenHidden))
|
||||
: null,
|
||||
renderAuthMethods
|
||||
? authMethods.Select(a => new AuthenticationMethodResponse(
|
||||
a.Id, a.AuthType, a.RemoteId,
|
||||
a.RemoteUsername, a.FediverseApplication?.Domain
|
||||
a.Id,
|
||||
a.AuthType,
|
||||
a.RemoteId,
|
||||
a.RemoteUsername,
|
||||
a.FediverseApplication?.Domain
|
||||
))
|
||||
: null,
|
||||
tokenHidden ? user.ListHidden : null,
|
||||
|
@ -58,10 +83,19 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
|
|||
}
|
||||
|
||||
public PartialUser RenderPartialUser(User user) =>
|
||||
new(user.Id, user.Sid, user.Username, user.DisplayName, AvatarUrlFor(user), user.CustomPreferences);
|
||||
new(
|
||||
user.Id,
|
||||
user.Sid,
|
||||
user.Username,
|
||||
user.DisplayName,
|
||||
AvatarUrlFor(user),
|
||||
user.CustomPreferences
|
||||
);
|
||||
|
||||
private string? AvatarUrlFor(User user) =>
|
||||
user.Avatar != null ? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp" : null;
|
||||
user.Avatar != null
|
||||
? $"{config.MediaBaseUrl}/users/{user.Id}/avatars/{user.Avatar}.webp"
|
||||
: null;
|
||||
|
||||
public string ImageUrlFor(PrideFlag flag) => $"{config.MediaBaseUrl}/flags/{flag.Hash}.webp";
|
||||
|
||||
|
@ -79,29 +113,26 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
|
|||
IEnumerable<Field> Fields,
|
||||
Dictionary<Snowflake, User.CustomPreference> CustomPreferences,
|
||||
IEnumerable<PrideFlagResponse> Flags,
|
||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||
UserRole Role,
|
||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] UserRole Role,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
IEnumerable<MemberRendererService.PartialMember>? Members,
|
||||
IEnumerable<MemberRendererService.PartialMember>? Members,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
IEnumerable<AuthenticationMethodResponse>? AuthMethods,
|
||||
IEnumerable<AuthenticationMethodResponse>? AuthMethods,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
bool? MemberListHidden,
|
||||
bool? MemberListHidden,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)] Instant? LastActive,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
Instant? LastActive,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
Instant? LastSidReroll
|
||||
Instant? LastSidReroll
|
||||
);
|
||||
|
||||
public record AuthenticationMethodResponse(
|
||||
Snowflake Id,
|
||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))]
|
||||
AuthType Type,
|
||||
[property: JsonConverter(typeof(ScreamingSnakeCaseEnumConverter))] AuthType Type,
|
||||
string RemoteId,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
string? RemoteUsername,
|
||||
string? RemoteUsername,
|
||||
[property: JsonProperty(NullValueHandling = NullValueHandling.Ignore)]
|
||||
string? FediverseInstance
|
||||
string? FediverseInstance
|
||||
);
|
||||
|
||||
public record PartialUser(
|
||||
|
@ -120,5 +151,6 @@ public class UserRendererService(DatabaseContext db, MemberRendererService membe
|
|||
Snowflake Id,
|
||||
string ImageUrl,
|
||||
string Name,
|
||||
string? Description);
|
||||
}
|
||||
string? Description
|
||||
);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue