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 MongoDB.Entities; 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; readonly IOptionsMonitor Registrations; readonly ILogger Logger; readonly IStringLocalizer Localizer; public RootUserController(IRootUsersService usersService, IGroupUsersService groupUsersService, IPrivateAvatarUsersService avatarUsersService, ILocalActorService localActors, AuthTokenManager authTokenManager, IOptionsMonitor appConfiguration, IOptionsMonitor registrations, IStringLocalizer localizer, ILogger logger) { Registrations = registrations; 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 SignUp(LoginForm signUpForm) { if (User.Identity?.IsAuthenticated ?? false) return Redirect("/"); var result = new WebResult(); if (!Registrations.CurrentValue.IsOpen) return StatusCode(StatusCodes.Status403Forbidden, result.Invalidate(Localizer[RegistrationOptions.ClosedMessage], StatusCodes.Status403Forbidden)); 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 Login(LoginForm loginForm) { 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.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 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); if (!result.IsValid) return StatusCode(result.StatusCode, result); var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data; var avatar = await InsertPersona(user.ID, avatarUserName, signUpForm.AvatarName); 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 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); 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)?.FirstOrDefault() : (avatars.Data as List)?.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 InsertPersona(user.ID, avatarUserName, loginForm.AvatarName); 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."])); } } [HttpGet, Route("/clientapi/user/setup"), AllowAnonymous] public async Task Setup(CancellationToken token) => Ok(new ViewServerSetup { IsPending = await ServerSetup.IsPending(token) }); // The server's first login, made while it has no administrator (owner decision 2026-10-16: first come, first served): // it is made the administrator, with its first persona, and setup closes for good. Sign-up stays closed to everyone // else (Registrations), so NodeInfo and the instance API don't change. [HttpPost, Route("/clientapi/user/setup"), EnableRateLimiting(RateLimiting.Accounts), AllowAnonymous] public async Task SetUp(SetupForm setupForm, CancellationToken token) { if (User.Identity?.IsAuthenticated ?? false) return Redirect("/"); var result = new WebResult(); if (!ModelState.IsValid) return BadRequest(result.Invalidate(Localizer["Invalid model."])); var claimed = false; var adminId = default(string); try { if (!await ServerSetup.IsPending(token)) return SetUpAlready(); var avatarUserName = setupForm.AvatarUserName?.Trim().ToLowerInvariant(); var personaProblem = await PersonaProblem(avatarUserName, setupForm.UserName, token); if (personaProblem != default) return BadRequest(result.Invalidate(personaProblem)); claimed = await ServerSetup.Claim(token); if (!claimed) return SetUpAlready(); result = await UsersService.SignUpAsync(setupForm); if (!result.IsValid) { await ServerSetup.Release(); return StatusCode(result.StatusCode, result); } var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data; user.SetAdmin(true); await DB.Default.SaveAsync(user, CancellationToken.None); adminId = user.ID; await ServerSetup.Claimed(adminId, CancellationToken.None); // set up from here, whatever happens to the persona: it can be made in the client's settings var avatar = await InsertPersona(user.ID, avatarUserName, setupForm.AvatarName); if (!avatar.IsValid) return StatusCode(avatar.StatusCode, avatar); return Ok(AuthTokenManager.GenerateToken(user, userSettings)); } catch (Exception ex) { Logger.LogError(ex, $"{nameof(User)}.{nameof(SetUp)}()"); if (claimed && adminId == default) await ServerSetup.Release(); return BadRequest(result.Invalidate(Localizer["Something went wrong."])); } } IActionResult SetUpAlready() => StatusCode(StatusCodes.Status403Forbidden, new WebResult().Invalidate(Localizer["This server is already set up: joining it takes a group invitation."], StatusCodes.Status403Forbidden)); Task InsertPersona(string rootId, string avatarUserName, string avatarName) => AvatarUsersService.InsertAvatar(new InsertAvatarForm { RootId = rootId, Name = string.IsNullOrWhiteSpace(avatarName) ? avatarUserName : avatarName.Trim(), UserName = avatarUserName, Biography = string.Empty }); async Task 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 taken.", avatarUserName]; return default; } // This device signs out: its session (every token it renewed) is refused from now on. A token from before sessions had // ids can't be told apart from the others, so it signs every device out. [HttpGet, Route("/clientapi/user/logout"), Authorize(Policy = Policies.IsUser)] public async Task Logout([FromServices] IRootSessions sessions, CancellationToken token) { var result = new WebResult(); try { var sessionId = User.FindFirst(AuthTokenManager.SessionId)?.Value; if (string.IsNullOrEmpty(sessionId)) await sessions.Revoke(User.GetUserId(), token); else await sessions.End(User.GetUserId(), sessionId, token); return Ok(); } catch (Exception ex) { Logger.LogError(ex, $"{nameof(User)}.{nameof(Logout)}()"); return BadRequest(result.Invalidate(Localizer["Something went wrong."])); } } [HttpPost, Route("/clientapi/user/logout/everywhere"), Authorize(Policy = Policies.IsUser)] public async Task LogoutEverywhere([FromServices] IRootSessions sessions, CancellationToken token) { var result = new WebResult(); try { await sessions.Revoke(User.GetUserId(), token); return Ok(); } catch (Exception ex) { Logger.LogError(ex, $"{nameof(User)}.{nameof(LogoutEverywhere)}()"); return BadRequest(result.Invalidate(Localizer["Something went wrong."])); } } // The address is changed only with the password and a code sent to it (/clientapi/user/email); this older route, which // took neither and said whether an address was taken, no longer changes anything. [HttpPost, Route("/clientapi/user/update"), Authorize(Policy = Policies.IsUser)] public IActionResult UpdateUser(UserForm userEmailForm) => StatusCode(StatusCodes.Status422UnprocessableEntity, new WebResult().Invalidate(Localizer["Changing the address takes your password and a code sent to it."], StatusCodes.Status422UnprocessableEntity)); [HttpGet, Route("/clientapi/user/account"), Authorize(Policy = Policies.IsUser)] public async Task Account() { var result = await UsersService.AccountAsync(User.GetUserId()); return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); } [HttpPost, Route("/clientapi/user/email"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)] public async Task RequestEmailChange(EmailChangeForm form) { if (!ModelState.IsValid) return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."])); var result = await UsersService.RequestEmailChangeAsync(User.GetUserId(), form); return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); } [HttpPost, Route("/clientapi/user/email/verify"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)] public async Task VerifyEmail(EmailCodeForm form) { if (!ModelState.IsValid) return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."])); var result = await UsersService.VerifyEmailAsync(User.GetUserId(), form.Code); return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); } [HttpPost, Route("/clientapi/user/totp/setup"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)] public async Task TotpSetup(PasswordForm form) { if (!ModelState.IsValid) return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."])); var result = await UsersService.TotpSetupAsync(User.GetUserId(), form.Password); return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); } // on with the app's first code; every other session ends, and this device goes on with a new one [HttpPost, Route("/clientapi/user/totp/enable"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)] public async Task TotpEnable(TotpCodeForm form) { if (!ModelState.IsValid) return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."])); var result = await UsersService.TotpEnableAsync(User.GetUserId(), form.Code); if (!result.IsValid) return StatusCode(result.StatusCode, result); var (user, userSettings, codes) = ((RootUser, ViewUserSettings, List))result.Data; return Ok(new ViewTotpEnabled { RecoveryCodes = codes, Session = AuthTokenManager.GenerateToken(user, userSettings) }); } [HttpPost, Route("/clientapi/user/totp/disable"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)] public async Task TotpDisable(TotpDisableForm form) { if (!ModelState.IsValid) return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."])); var result = await UsersService.TotpDisableAsync(User.GetUserId(), form.Password, form.Code); return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); } [HttpPost, Route("/clientapi/user/email/remove"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)] public async Task RemoveEmail(PasswordForm form) { if (!ModelState.IsValid) return BadRequest(new WebResult().Invalidate(Localizer["Invalid model."])); var result = await UsersService.RemoveEmailAsync(User.GetUserId(), form.Password); return result.IsValid ? Ok(result.Data) : StatusCode(result.StatusCode, result); } [HttpPost, Route("/clientapi/user/update/settings"), Authorize(Policy = Policies.IsUser)] public async Task 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 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); // every session ended with the change; this device goes on with a new one var (user, userSettings) = ((RootUser, ViewUserSettings))result.Data; return Ok(AuthTokenManager.GenerateToken(user, userSettings)); } catch (Exception ex) { Logger.LogError(ex, $"{nameof(User)}.{nameof(UpdatePassword)}()"); return BadRequest(result.Invalidate(Localizer["Something went wrong."])); } } // what the login's media take, every persona's, and its quota when the server sets one [HttpGet, Route("/clientapi/user/storage"), Authorize(Policy = Policies.IsUser)] public async Task GetStorage([FromServices] Microsoft.Extensions.Options.IOptionsMonitor media, CancellationToken token) { var (bytes, files) = await Domain.Privacy.Counted.MediaOfRoot(User.GetUserId(), token); var quota = media.CurrentValue.QuotaBytesPerRoot; return Ok(new PrivaPub.ClientModels.User.ViewStorage { MediaBytes = bytes, MediaFiles = files, QuotaBytes = quota > 0 ? quota : null }); } [HttpGet, Route("/clientapi/user/settings"), Authorize(Policy = Policies.IsUser)] public async Task 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 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.Data);//the same sentence for everyone } 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 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 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."])); } } [HttpPost, Route("/clientapi/user/delete"), EnableRateLimiting(RateLimiting.Accounts), Authorize(Policy = Policies.IsUser)] public async Task RemoveSelf(RemoveSelfForm form) { var result = new WebResult(); if (!ModelState.IsValid) return BadRequest(result.Invalidate(Localizer["Invalid model."])); try { result = await UsersService.RemoveSelfAsync(User.GetUserId(), form.Password); return result.IsValid ? Ok() : StatusCode(result.StatusCode, result); } 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 SniffAgain([FromServices] DbEntities dbEntities, CancellationToken token) { var result = new WebResult(); try { var user = await dbEntities.RootUsers.MatchID(User.GetUserId()).ExecuteFirstAsync(token); if (user is not { IsBanned: false, DeletedAt: null }) return Unauthorized(result.Invalidate(Localizer["Unauthorized."], StatusCodes.Status401Unauthorized)); result = await UsersService.GetUserSettingsAsync(user.ID); if (!result.IsValid) return StatusCode(result.StatusCode, result); return Ok(AuthTokenManager.GenerateToken(user, (ViewUserSettings)result.Data, User.FindFirst(AuthTokenManager.SessionId)?.Value)); } catch (Exception ex) { Logger.LogError(ex, $"{nameof(User)}.{nameof(SniffAgain)}()"); return BadRequest(result.Invalidate(Localizer["Something went wrong."])); } } #endregion } }