- Sign-up, login, the invitation flows and password recovery allow ten requests a minute per client address; the inboxes give each sending origin (the keyId's) a bucket of 300 that refills at 300 a minute, and answer 429 beyond it, which peers retry. - deploy.yml dumps the PrivaPub database to /var/backups before it stops the service (the last seven are kept), and after the swap checks that Swagger answers 404 and that the shared inbox answers junk with 400 and an unsigned activity with 401. - ActivityPubClient and PostBoost, never used, are gone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012CzABvBkbcFqoHdmi8b9WB
447 lines
15 KiB
C#
447 lines
15 KiB
C#
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.Extensions.Localization;
|
|
using Microsoft.Extensions.Options;
|
|
|
|
using PrivaPub.ClientModels;
|
|
using PrivaPub.ClientModels.Resources;
|
|
using PrivaPub.ClientModels.Group;
|
|
using PrivaPub.ClientModels.User;
|
|
using PrivaPub.ClientModels.User.Avatar;
|
|
using PrivaPub.Extensions;
|
|
using PrivaPub.Federation.Actors;
|
|
using PrivaPub.Infrastructure;
|
|
using PrivaPub.Models;
|
|
using PrivaPub.Models.User;
|
|
using PrivaPub.Resources;
|
|
using PrivaPub.Services;
|
|
using PrivaPub.Services.ClientToServer.Private;
|
|
using PrivaPub.StaticServices;
|
|
|
|
using System.ComponentModel.DataAnnotations;
|
|
|
|
namespace PrivaPub.Controllers.ClientToServer
|
|
{
|
|
[ApiController,
|
|
Route("clientapi/user")]
|
|
public class RootUserController : ControllerBase
|
|
{
|
|
readonly IRootUsersService UsersService;
|
|
readonly IGroupUsersService GroupUsersService;
|
|
readonly IPrivateAvatarUsersService AvatarUsersService;
|
|
readonly ILocalActorService LocalActors;
|
|
readonly AuthTokenManager AuthTokenManager;
|
|
readonly IOptionsMonitor<AppConfiguration> AppConfiguration;
|
|
readonly ILogger<RootUserController> Logger;
|
|
readonly IStringLocalizer Localizer;
|
|
|
|
public RootUserController(IRootUsersService usersService,
|
|
IGroupUsersService groupUsersService,
|
|
IPrivateAvatarUsersService avatarUsersService,
|
|
ILocalActorService localActors,
|
|
AuthTokenManager authTokenManager,
|
|
IOptionsMonitor<AppConfiguration> appConfiguration,
|
|
IStringLocalizer<GenericRes> localizer,
|
|
ILogger<RootUserController> logger)
|
|
{
|
|
UsersService = usersService;
|
|
GroupUsersService = groupUsersService;
|
|
AvatarUsersService = avatarUsersService;
|
|
LocalActors = localActors;
|
|
AuthTokenManager = authTokenManager;
|
|
AppConfiguration = appConfiguration;
|
|
Localizer = localizer;
|
|
Logger = logger;
|
|
}
|
|
|
|
#region User endpoints
|
|
|
|
[HttpPost, Route("/clientapi/user/signup"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous]
|
|
public async Task<IActionResult> SignUp(LoginForm signUpForm)
|
|
{
|
|
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/");
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
result = await UsersService.SignUpAsync(signUpForm);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
(var user, var userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
|
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
|
|
return Ok(jwtUser);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(SignUp)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/login"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser), AllowAnonymous]
|
|
public async Task<IActionResult> Login(LoginForm loginForm)
|
|
{
|
|
if (User.Identity?.IsAuthenticated ?? false) return Redirect("/discussions");
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
result = await UsersService.LoginAsync(loginForm);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
var (user, userSettings) =
|
|
((RootUser, ViewUserSettings))result.Data;
|
|
var jwtUser = AuthTokenManager.GenerateToken(user, userSettings);
|
|
|
|
return Ok(jwtUser);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(Login)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/invitation/signup"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
|
public async Task<IActionResult> InvitationSignUp(InvitationLoginForm signUpForm, CancellationToken token)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
var invitation = await GroupUsersService.GetInvitation(signUpForm.InvitationCode, signUpForm.InvitationPassword, token);
|
|
if (!invitation.IsValid)
|
|
return StatusCode(invitation.StatusCode, invitation);
|
|
|
|
var avatarUserName = signUpForm.AvatarUserName?.Trim().ToLowerInvariant();
|
|
var personaProblem = await PersonaProblem(avatarUserName, signUpForm.UserName, token);
|
|
if (personaProblem != default)
|
|
return BadRequest(result.Invalidate(personaProblem));
|
|
|
|
result = await UsersService.SignUpAsync(signUpForm, signUpForm.InvitationCode);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
|
|
|
var avatar = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
|
{
|
|
RootId = user.ID,
|
|
Name = string.IsNullOrWhiteSpace(signUpForm.AvatarName) ? avatarUserName : signUpForm.AvatarName.Trim(),
|
|
UserName = avatarUserName,
|
|
Biography = string.Empty
|
|
});
|
|
if (!avatar.IsValid)
|
|
return StatusCode(avatar.StatusCode, avatar);
|
|
|
|
var joined = await GroupUsersService.JoinGroup(user.ID, new JoinGroupForm
|
|
{
|
|
AvatarId = ((ViewAvatar)avatar.Data).Id,
|
|
InvitationCode = signUpForm.InvitationCode,
|
|
InvitationPassword = signUpForm.InvitationPassword
|
|
}, token);
|
|
if (!joined.IsValid)
|
|
return StatusCode(joined.StatusCode, joined);
|
|
|
|
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationSignUp)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/invitation/login"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
|
public async Task<IActionResult> InvitationLogin(InvitationLoginForm loginForm, CancellationToken token)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
var invitation = await GroupUsersService.GetInvitation(loginForm.InvitationCode, loginForm.InvitationPassword, token);
|
|
if (!invitation.IsValid)
|
|
return StatusCode(invitation.StatusCode, invitation);
|
|
|
|
result = await UsersService.LoginAsync(loginForm, loginForm.InvitationCode);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data;
|
|
|
|
var avatars = await AvatarUsersService.GetRootAvatars(user.ID, token);
|
|
var avatarUserName = loginForm.AvatarUserName?.Trim().ToLowerInvariant();
|
|
var avatar = string.IsNullOrEmpty(avatarUserName)
|
|
? (avatars.Data as List<ViewAvatar>)?.FirstOrDefault()
|
|
: (avatars.Data as List<ViewAvatar>)?.FirstOrDefault(a => a.UserName == avatarUserName);
|
|
if (avatar == default)
|
|
{
|
|
var personaProblem = await PersonaProblem(avatarUserName, user.UserName, token);
|
|
if (personaProblem != default)
|
|
return BadRequest(new WebResult().Invalidate(personaProblem));
|
|
var inserted = await AvatarUsersService.InsertAvatar(new InsertAvatarForm
|
|
{
|
|
RootId = user.ID,
|
|
Name = string.IsNullOrWhiteSpace(loginForm.AvatarName) ? avatarUserName : loginForm.AvatarName.Trim(),
|
|
UserName = avatarUserName,
|
|
Biography = string.Empty
|
|
});
|
|
if (!inserted.IsValid)
|
|
return StatusCode(inserted.StatusCode, inserted);
|
|
avatar = (ViewAvatar)inserted.Data;
|
|
}
|
|
|
|
var joined = await GroupUsersService.JoinGroup(user.ID, new JoinGroupForm
|
|
{
|
|
AvatarId = avatar.Id,
|
|
InvitationCode = loginForm.InvitationCode,
|
|
InvitationPassword = loginForm.InvitationPassword
|
|
}, token);
|
|
if (!joined.IsValid)
|
|
return StatusCode(joined.StatusCode, joined);
|
|
|
|
return Ok(AuthTokenManager.GenerateToken(user, userSettings));
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(InvitationLogin)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
async Task<string> PersonaProblem(string avatarUserName, string rootUserName, CancellationToken token)
|
|
{
|
|
if (string.IsNullOrEmpty(avatarUserName))
|
|
return Localizer["Choose a username for your persona."];
|
|
if (string.Equals(avatarUserName, rootUserName?.Trim(), StringComparison.OrdinalIgnoreCase))
|
|
return Localizer["Your persona's username must differ from your login."];
|
|
if (await LocalActors.IsUserNameTaken(avatarUserName, token))
|
|
return Localizer["The username '{0}' is already take.", avatarUserName];
|
|
return default;
|
|
}
|
|
|
|
[HttpGet, Route("/clientapi/user/logout"), Authorize(Policy = Policies.IsUser)]
|
|
public IActionResult Logout()
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
return Ok();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(Logout)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/update"), Authorize(Policy = Policies.IsUser)]
|
|
public async Task<IActionResult> UpdateUser(UserForm userEmailForm)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
result = await UsersService.UpdateUserAsync(userEmailForm, User.GetUserId());
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
return Ok();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUser)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/update/settings"), Authorize(Policy = Policies.IsUser)]
|
|
public async Task<IActionResult> UpdateUserSettings(ViewUserSettings userSettings)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
result = await UsersService.UpdateUserSettingsAsync(userSettings, User.GetUserId());
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
return Ok();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdateUserSettings)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/update/password"), Authorize(Policy = Policies.IsUser)]
|
|
public async Task<IActionResult> UpdatePassword(UserPasswordForm userPasswordForm)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
result = await UsersService.UpdateUserPasswordAsync(userPasswordForm, User.GetUserId());
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
return Ok();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdatePassword)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
//[HttpGet, Authorize(Policy = Policies.IsUser)]
|
|
//public async Task<IActionResult> GetUser()
|
|
//{
|
|
// var result = new WebResult();
|
|
// try
|
|
// {
|
|
// result = await UsersService.GetUserAsync(User.GetUserId());
|
|
// if (!result.IsValid)
|
|
// return StatusCode(result.StatusCode, result);
|
|
|
|
// return Ok(result.Data);
|
|
// }
|
|
// catch (Exception ex)
|
|
// {
|
|
// Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUser)}()");
|
|
// return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
// }
|
|
//}
|
|
|
|
[HttpGet, Route("/clientapi/user/settings"), Authorize(Policy = Policies.IsUser)]
|
|
public async Task<IActionResult> GetUserSettings()
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
result = await UsersService.GetUserSettingsAsync(User.GetUserId());
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
return Ok(result.Data);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(GetUserSettings)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/recover/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
|
public async Task<IActionResult> RecoverPassword(PasswordRecoveryForm passwordRecoveryForm)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
var host = string.IsNullOrEmpty(AppConfiguration.CurrentValue.FrontendBaseAddress)
|
|
? $"{Request.Scheme}://{Request.Host.Host}"
|
|
: AppConfiguration.CurrentValue.FrontendBaseAddress.TrimEnd('/');
|
|
result = await UsersService.SetupAndSendRecoveryEmail(passwordRecoveryForm, host);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
return Ok(result);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(RecoverPassword)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/recover/valid"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
|
public async Task<IActionResult> IsValidRecoveryCode(
|
|
[FromBody,
|
|
Required(ErrorMessageResourceName = "Required", ErrorMessageResourceType = typeof(ErrorsResource)),
|
|
Display(Name = "RecoveryCode", ResourceType = typeof(FieldsNameResource))]
|
|
string recoveryCode)
|
|
{
|
|
var result = new WebResult();
|
|
try
|
|
{
|
|
result = await UsersService.IsValidRecoveryCode(recoveryCode);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
return Ok(result.Data);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(IsValidRecoveryCode)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
[HttpPost, Route("/clientapi/user/recover/update/password"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous]
|
|
public async Task<IActionResult> ChangePassword(NewPasswordForm newPasswordForm)
|
|
{
|
|
var result = new WebResult();
|
|
if (!ModelState.IsValid)
|
|
return BadRequest(result.Invalidate(Localizer["Invalid model."]));
|
|
try
|
|
{
|
|
result = await UsersService.ChangePassword(newPasswordForm);
|
|
if (!result.IsValid)
|
|
return StatusCode(result.StatusCode, result);
|
|
|
|
return Ok(result.Data);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, $"{nameof(User)}.{nameof(ChangePassword)}()");
|
|
return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
}
|
|
}
|
|
|
|
//[HttpDelete, Route("delete"), Authorize(Policy = Policies.IsUser)]
|
|
//public async Task<IActionResult> RemoveSelf()
|
|
//{
|
|
// var result = new WebResult();
|
|
// try
|
|
// {
|
|
// //result = await UsersService.RemoveUserAsync(User.GetUserId());
|
|
// if (!result.IsValid)
|
|
// return StatusCode(result.StatusCode, result);
|
|
|
|
// await HttpContext.SignOutAsync();
|
|
|
|
// return Ok();
|
|
// }
|
|
// catch (Exception ex)
|
|
// {
|
|
// Logger.LogError(ex, $"{nameof(User)}.{nameof(RemoveSelf)}()");
|
|
// return BadRequest(result.Invalidate(Localizer["Something went wrong."]));
|
|
// }
|
|
//}
|
|
|
|
#endregion User endpoints
|
|
|
|
#region Auth refresh
|
|
|
|
[HttpGet, Route("/clientapi/user/sniff/again"), Authorize(Policy = Policies.IsUser)]
|
|
public async Task<IActionResult> SniffAgain()
|
|
{
|
|
|
|
return Ok();
|
|
}
|
|
|
|
#endregion
|
|
}
|
|
}
|