Add 'backend/' from commit '040cfd7a75423d4e6136e58a67b40579af4ee966'

git-subtree-dir: backend
git-subtree-mainline: ab911955ed
git-subtree-split: 040cfd7a75
This commit is contained in:
2025-01-15 15:24:30 -05:00
179 changed files with 14349 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
using Hutopy.Web.Common.Security;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public record ChangeAddressRequest(
string? Address);
[PublicAPI]
public class ChangeAddressHandler(
IdentityUserManager userManager)
: Endpoint<ChangeAddressRequest>
{
public override void Configure()
{
Post("/api/users/address");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeAddressRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Address = request.Address;
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
await SendOkAsync(ct);
else
await SendUnauthorizedAsync(ct);
}
}

View File

@@ -0,0 +1,41 @@
using Hutopy.Web.Common.Security;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public record ChangeAliasRequest(
string? Alias);
[PublicAPI]
public class ChangeAliasHandler(
IdentityUserManager userManager)
: Endpoint<ChangeAliasRequest>
{
public override void Configure()
{
Post("/api/users/alias");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeAliasRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Alias = request.Alias;
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
await SendOkAsync(ct);
else
await SendUnauthorizedAsync(ct);
}
}

View File

@@ -0,0 +1,41 @@
using Hutopy.Web.Common.Security;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public record ChangeBirthDateRequest(
DateTime BirthDate);
[PublicAPI]
public class ChangeBirthDateHandler(
IdentityUserManager userManager)
: Endpoint<ChangeBirthDateRequest>
{
public override void Configure()
{
Post("/api/users/birthdate");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeBirthDateRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.BirthDate = request.BirthDate;
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
await SendOkAsync(ct);
else
await SendUnauthorizedAsync(ct);
}
}

View File

@@ -0,0 +1,42 @@
using Hutopy.Web.Common.Security;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public record ChangeEmailRequest(
string? Email);
[PublicAPI]
public class ChangeEmailHandler(
IdentityUserManager userManager)
: Endpoint<ChangeEmailRequest>
{
public override void Configure()
{
Post("/api/users/email");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeEmailRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Email = request.Email;
// TODO: check to see if identity resets the `email confirmed` flag - @jonathan
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
await SendOkAsync(ct);
else
await SendUnauthorizedAsync(ct);
}
}

View File

@@ -0,0 +1,43 @@
using Hutopy.Web.Common.Security;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public record ChangeFullnameRequest(
string? Firstname,
string? Lastname);
[PublicAPI]
public class ChangeFullnameHandler(
IdentityUserManager userManager)
: Endpoint<ChangeFullnameRequest>
{
public override void Configure()
{
Post("/api/users/fullname");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangeFullnameRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.Firstname = request.Firstname;
user.Lastname = request.Lastname;
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
await SendOkAsync(ct);
else
await SendUnauthorizedAsync(ct);
}
}

View File

@@ -0,0 +1,42 @@
using Hutopy.Web.Common.Security;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public record ChangePhoneRequest(
string? PhoneNumber);
[PublicAPI]
public class ChangePhoneHandler(
IdentityUserManager userManager)
: Endpoint<ChangePhoneRequest>
{
public override void Configure()
{
Post("/api/users/phone");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
ChangePhoneRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
user.PhoneNumber = request.PhoneNumber;
// TODO: check to see if identity resets the `phone confirmed` flag - @jonathan
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
await SendOkAsync(ct);
else
await SendUnauthorizedAsync(ct);
}
}

View File

@@ -0,0 +1,72 @@
using Hutopy.Web.Common.BlobStorage;
using Hutopy.Web.Common.Security;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public record ChangePortraitRequest(
IFormFile File);
[PublicAPI]
public record ChangePortraitResponse(
string BlobUrl);
[PublicAPI]
public sealed class ChangePortraitRequestValidator : Validator<ChangePortraitRequest>
{
public ChangePortraitRequestValidator()
{
RuleFor(x => x.File)
.NotNull()
.NotEmpty();
}
}
[PublicAPI]
public class ChangePortraitHandler(
IdentityUserManager userManager,
AzureBlobStorage blobStorage)
: Endpoint<ChangePortraitRequest, ChangePortraitResponse>
{
public override void Configure()
{
Post("/api/users/portrait");
Options(o => o.WithTags("Users"));
AllowFileUploads();
}
public override async Task HandleAsync(
ChangePortraitRequest request,
CancellationToken ct)
{
var user = await userManager.FindByIdAsync(HttpContext.User.GetUserId().ToString());
if (user is null)
{
await SendNotFoundAsync(ct);
return;
}
var blobUrl = await blobStorage.UploadFileAsync(
ContainerNames.Users,
$"{user.Id}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
request.File.OpenReadStream(),
request.File.ContentType,
ct);
user.PortraitUrl = blobUrl;
var result = await userManager.UpdateAsync(user);
if (result.Succeeded)
{
await SendOkAsync(
new ChangePortraitResponse(blobUrl),
ct);
}
else
{
await SendUnauthorizedAsync(ct);
}
}
}

View File

@@ -0,0 +1,56 @@
using Hutopy.Web.Features.Users.Handlers.Models;
using Hutopy.Web.Features.Memberships.Data;
using Hutopy.Web.Features.Memberships.Infrastructure;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public class GetCurrentUserQueryHandler(
IdentityService identityService,
MembershipDbContext membershipDbContext)
: EndpointWithoutRequest<UserDto>
{
public override void Configure()
{
Get("/api/users/profile");
Options(o => o.WithTags("Memberships"));
}
public override async Task HandleAsync(
CancellationToken cancellationToken)
{
var userModel = await identityService.GetCurrentUserAsync();
if (userModel is null)
{
await SendNotFoundAsync(cancellationToken);
return;
}
var roles = await identityService.GetCurrentUserRolesAsync();
var stripeId = await membershipDbContext
.Creators
.Where(c => c.Id == userModel.Id)
.Select(c => c.StripeAccountId)
.FirstOrDefaultAsync(cancellationToken);
await SendOkAsync(
new UserDto
{
Id = userModel.Id,
Alias = userModel.Alias,
PortraitUrl = userModel.PortraitUrl,
Firstname = userModel.Firstname,
Lastname = userModel.Lastname,
Username = userModel.Username,
PhoneNumber = userModel.PhoneNumber,
Email = userModel.Email,
BirthDate = userModel.BirthDate,
Address = userModel.Address,
UserRoles = roles,
StripeId = stripeId ?? string.Empty
},
cancellationToken);
}
}

View File

@@ -0,0 +1,30 @@
using Hutopy.Web.Common.BlobStorage;
namespace Hutopy.Web.Features.Users.Handlers;
[PublicAPI]
public class GetCurrentUserPortraitHandler(
IdentityService identityService,
AzureBlobStorage blobStorage
)
: EndpointWithoutRequest<Stream>
{
public override void Configure()
{
Get("/api/users/portrait");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
CancellationToken cancellationToken)
{
var identityUser = await identityService.GetCurrentUserAsync();
var stream = await blobStorage.DownloadFileAsync(
ContainerNames.Users,
$"{identityUser.Id.ToString()}/{SubDirectoryNames.Profile}/{CommonFileNames.ProfilePicture}",
cancellationToken);
await SendOkAsync(stream, cancellationToken);
}
}

View File

@@ -0,0 +1,139 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Hutopy.Web.Common.Security;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
namespace Hutopy.Web.Features.Users.Handlers;
class GoogleToken
{
[JsonPropertyName("access_token")] public required string AccessToken { get; init; }
[JsonPropertyName("token_type")] public required string TokenType { get; init; }
[JsonPropertyName("expires_in")] public required int ExpiresIn { get; init; }
[JsonPropertyName("scope")] public required string Scope { get; init; }
[JsonPropertyName("authuser")] public required string AuthUser { get; init; }
[JsonPropertyName("prompt")] public required string Prompt { get; init; }
}
public class GoogleUserInfo
{
[JsonPropertyName("id")] public required string Id { get; init; }
[JsonPropertyName("email")] public required string Email { get; init; }
[JsonPropertyName("verified_email")] public required bool VerifiedEmail { get; init; }
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("given_name")] public required string GivenName { get; init; }
[JsonPropertyName("family_name")] public string FamilyName { get; init; } = string.Empty;
[JsonPropertyName("picture")] public required string Picture { get; init; }
}
[PublicAPI]
public record LoginWithGoogleRequest(
string Token);
[PublicAPI]
public record LoginWithGoogleResponse(
string AccessToken,
string RefreshToken);
[PublicAPI]
public class LoginWithGoogleHandler(
IHttpClientFactory httpClientFactory,
IdentityUserManager userManager,
SignInManager<IdentityUser> signInManager,
IOptionsSnapshot<JwtOptions> jwtOptions)
: Endpoint<LoginWithGoogleRequest, LoginWithGoogleResponse>
{
public override void Configure()
{
AllowAnonymous();
Post("/api/users/login-with-google");
Options(o => o.WithTags("Users"));
}
public override async Task HandleAsync(
LoginWithGoogleRequest request,
CancellationToken ct)
{
var googleToken = JsonSerializer.Deserialize<GoogleToken>(request.Token)!;
// Verify the token with Google
using var httpClient = httpClientFactory.CreateClient();
using var response = await httpClient.GetAsync(
$"https://www.googleapis.com/oauth2/v1/userinfo?access_token={googleToken.AccessToken}",
ct);
if (!response.IsSuccessStatusCode)
{
await SendStringAsync(
"The token is not valid",
400,
cancellation: ct);
return;
}
// Extract the user info (email, name, etc.).
var content = await response.Content.ReadAsStringAsync(ct);
var userInfo = JsonSerializer.Deserialize<GoogleUserInfo>(content);
if (userInfo is null
|| !userInfo.VerifiedEmail
|| string.IsNullOrEmpty(userInfo.Email))
{
await SendStringAsync(
"The token does not contain an email",
400,
cancellation: ct);
return;
}
// Check if user exists or create a new one
var user = await userManager.FindByEmailAsync(userInfo.Email);
if (user is null)
{
var generatedPassword = PasswordGenerator.GeneratePassword(10, 12);
var generatedUser = new IdentityUser
{
UserName = userInfo.Email,
Email = userInfo.Email,
Firstname = userInfo.GivenName,
Lastname = userInfo.FamilyName,
Alias = userInfo.Name,
PortraitUrl = userInfo.Picture,
GoogleId = userInfo.Id,
};
var result = await userManager.CreateAsync(
generatedUser,
generatedPassword);
if (!result.Succeeded)
{
await SendStringAsync(
result.Errors.First().Description,
400,
cancellation: ct);
return;
}
user = generatedUser;
}
await signInManager.SignInAsync(user, isPersistent: false);
var accessToken = JwtTokenHelper.GenerateJwtToken(
expiresIn: jwtOptions.Value.Lifetime,
issuer: jwtOptions.Value.Issuer,
audience: jwtOptions.Value.Audience,
key: jwtOptions.Value.Key,
userId: user.Id.ToString(),
email: user.Email,
alias: user.Alias,
firstname: user.Firstname,
lastname: user.Lastname,
portraitUrl: user.PortraitUrl);
await SendOkAsync(
new LoginWithGoogleResponse(accessToken, string.Empty),
cancellation: ct);
}
}

View File

@@ -0,0 +1,17 @@
namespace Hutopy.Web.Features.Users.Handlers.Models;
public class UserDto
{
public Guid Id { get; init; }
public IList<string> UserRoles { get; init; } = [];
public string Username { get; init; } = null!;
public string? Alias { get; init; }
public string? PortraitUrl { get; init; }
public string? Firstname { get; init; }
public string? Lastname { get; init; }
public string? Email { get; init; }
public string? PhoneNumber { get; init; }
public DateTime? BirthDate { get; init; }
public string? Address { get; init; }
public string? StripeId { get; init; }
}