Files
SocialPub/PrivaPub/Services/RootUsersService.cs
T
thepraandClaude Opus 5.5 ff5e635dda
Build / Build (push) Successful in 1m7s
R1 cleanup: collAnon and dead code gone, docs current, every peer's scenario in one sweep
Starts the completion program (docs/ROADMAP.md, "Owner decisions on finishing everything", 2026-10-17).

- Dead code: the commented-out collAnon blocks (discussions, seeding, admin user lists, HTTP/3, the old signature
  middleware), the empty PrivaPubWorkersConfiguration, PublicAvatarUsersService and UserPolicyType, unused helpers,
  the second IPasswordHasher registration, the PasswordGenerator package, SignUpAsync/LoginAsync's unused invitation
  parameters. "Starting PrivaPub"; a signed-in login is sent to "/", not to "/discussions".
- Stored fields nothing read: RootUser.Contacts and ResetPasswordToken*, the local Avatar.AvatarServer,
  EmailRecovery.RecoveryCode/RequestIP/CreationDate, MongoSettings.LogsDatabase, and AppConfiguration's
  upload/discussion keys, RequiresFirstTimeSetup and Version (old documents still load: MongoDB.Entities ignores extra
  elements). DmPost's indexes are no longer made. 49 collAnon keys leave FieldsNameResource in every language.
- /clientapi/data/current-version answers the build's ref. UsersIds is a list that can't throw, and the admin routes
  leave the administrator out without mutating the request.
- deploy.yml checks SecureMode on a name no persona can have, and the browser redirect on the directory's first local
  persona: @thepra is the administrator's own now.
- Town: decePub's e2e remnants (expect, client.jsonl, the decePub column) and town/tests go; the dead gaps/load
  commands too. Mobilizon no longer keeps an event for a deleted test.
- tools/pasture/sweep.sh runs every peer's scenario, batch by batch, as CLAUDE.md asks before a change to what
  PrivaPub sends.
- Docs: CLAUDE.md's repo map, route table (every frozen route) and stale lines; ROADMAP's status (setup, v1.25.0,
  v1.26.0, the program C), the 2026-10-17 decisions, and dated notes where earlier text no longer holds; FEDERATION.md's
  FEP list (7888, f228, 7628, ae0c and 8967 supported) and the deploy's persona.

Verified: dotnet build PrivaPub.sln -c Release (0 errors, no new warnings); bash -n on the touched scripts; py_compile
on the town; the pasture's gts, mastodon and lemmy scenarios on this build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WpVCoDeM1qUTRp4fXQdfof
2026-10-17 02:56:57 +02:00

420 lines
15 KiB
C#

using Microsoft.Extensions.Localization;
using MongoDB.Driver;
using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
using PrivaPub.Models;
using PrivaPub.Models.User;
using PrivaPub.Resources;
using PrivaPub.Infrastructure.Jobs;
using PrivaPub.Models.Jobs;
using PrivaPub.StaticServices;
using System.Globalization;
using System.Text.Json;
#pragma warning disable 8603
#pragma warning disable 8625
namespace PrivaPub.Services
{
public class RootUsersService : IRootUsersService
{
readonly DbEntities DbEntities;
readonly IPasswordHasher PasswordHasher;
readonly IStringLocalizer Localizer;
readonly ILogger<RootUsersService> Logger;
readonly AppConfigurationService AppConfigurationService;
readonly IJobQueue Jobs;
readonly IRootSessions Sessions;
readonly IRootRemoval Removal;
public RootUsersService(
IJobQueue jobs,
IRootSessions sessions,
IRootRemoval removal,
IStringLocalizer<GenericRes> localizer,
ILogger<RootUsersService> logger,
IPasswordHasher passwordHasher,
DbEntities dbEntities,
AppConfigurationService appConfigurationService)
{
Jobs = jobs;
Sessions = sessions;
Removal = removal;
DbEntities = dbEntities;
PasswordHasher = passwordHasher;
Localizer = localizer;
Logger = logger;
AppConfigurationService = appConfigurationService;
}
public const string NoMatch = "That username and password do not match.";
static string _decoy;
// a hash nobody's password matches, so an unknown login costs the same hashing as a wrong password
string Decoy => _decoy ??= PasswordHasher.Hash(Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)));
public async Task<WebResult> SignUpAsync(LoginForm signUpForm)
{
var result = new WebResult();
try
{
signUpForm.UserName = signUpForm.UserName.ToLower();
if (await DbEntities.RootUsers.Match(u => u.UserName == signUpForm.UserName).ExecuteAnyAsync())
return result.Invalidate(Localizer["Username '{0}' already taken.", signUpForm.UserName]);
var signUpPasswordHashed = PasswordHasher.Hash(signUpForm.Password);
var newUser = new RootUser
{
UserName = signUpForm.UserName,
HashedPassword = signUpPasswordHashed
};
var cultureLanguage = CultureInfo.CurrentCulture.TwoLetterISOLanguageName;
var language = await DbEntities.Languages.Match(l => l.International2Code == cultureLanguage).ExecuteFirstAsync();
newUser.Settings = new RootUserSettings
{
LanguageCode = language?.International2Code ?? "en",
LightThemeIndexColour = signUpForm.LightThemeIndexColour,
DarkThemeIndexColour = signUpForm.DarkThemeIndexColour,
IconsThemeIndexColour = signUpForm.IconsThemeIndexColour,
ThemeIsDarkGray = signUpForm.ThemeIsDarkGray,
ThemeIsLightGray = signUpForm.ThemeIsLightGray,
PreferSystemTheming = signUpForm.PreferSystemTheming,
ThemeIsDarkMode = signUpForm.ThemeIsDarkMode
};
await DB.Default.SaveAsync(newUser);
result.Data = (newUser, ToViewSettings(newUser.Settings));
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SignUpAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> LoginAsync(LoginForm loginForm)
{
var result = new WebResult();
try
{
// One answer, after the same work, whether the login is unknown, deleted or the password wrong: sign-in must not
// tell anyone which logins exist. Only someone who knows the password learns the login is banned.
loginForm.UserName = loginForm.UserName.ToLower();
var user = await DbEntities.RootUsers.Match(u => u.UserName == loginForm.UserName && u.DeletedAt == null).ExecuteFirstAsync();
var (verified, needsUpgrade) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, loginForm.Password);
if (user?.HashedPassword == default || !verified)
return result.Invalidate(Localizer[NoMatch]);
if (user.IsBanned)
return result.Invalidate(Localizer["User '{0}' banned.", user.UserName]);
if (needsUpgrade)
result.ErrorMessage = Localizer["Needs upgrade!"];
var userSettingsResult = await GetUserSettingsAsync(user.ID, loginForm);
var userSettings = (ViewUserSettings)userSettingsResult.Data;
result.Data = (user, userSettings);
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(LoginAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> UpdateUserAsync(UserForm userForm, string userId)
{
var result = new WebResult();
try
{
var currentUser = await DbEntities.RootUsers.Match(u => u.ID == userId).ExecuteFirstAsync();
if (!string.IsNullOrEmpty(userForm.Email) && currentUser.Email != userForm.Email)
{
var emailAlreadyUsed = await DbEntities.RootUsers.Match(u => u.Email == userForm.Email).ExecuteAnyAsync();
if (emailAlreadyUsed)
return result.Invalidate(Localizer["Email '{0}' already taken.", userForm.Email]);
}
await DB.Default.Update<RootUser>()
.Match(u => u.ID == userId)
.Modify(u => u.Email, userForm.Email)
.ExecuteAsync();
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> UpdateUserSettingsAsync(ViewUserSettings userSettings, string userId)
{
var result = new WebResult();
try
{
var isSupportedLanguage = AppConfigurationService.AppConfiguration.SupportedLanguages.Contains(userSettings.LanguageCode);
if (!isSupportedLanguage)
return result.Invalidate(Localizer["Language code '{0}' unsupported.", userSettings.LanguageCode]);
var languageCodeExists = await DbEntities.Languages
.Match(l => l.International2Code == userSettings.LanguageCode).ExecuteAnyAsync();
if (!languageCodeExists)
return result.Invalidate(Localizer["Language code '{0}' doesn't exist.", userSettings.LanguageCode]);
var language = await DbEntities.Languages.Match(l => l.International2Code == userSettings.LanguageCode)
.ExecuteFirstAsync();
_ = await DB.Default.Update<RootUser>()
.MatchID(userId)
.Modify(u => u.Settings.LanguageCode, language.International2Code)
.Modify(u => u.Settings.LightThemeIndexColour, userSettings.LightThemeIndexColour)
.Modify(u => u.Settings.DarkThemeIndexColour, userSettings.DarkThemeIndexColour)
.Modify(u => u.Settings.PreferSystemTheming, userSettings.PreferSystemTheming)
.Modify(u => u.Settings.ThemeIsDarkMode, userSettings.ThemeIsDarkMode)
.Modify(u => u.Settings.ThemeIsDarkGray, userSettings.ThemeIsDarkGray)
.Modify(u => u.Settings.ThemeIsLightGray, userSettings.ThemeIsLightGray)
.Modify(u => u.Settings.IconsThemeIndexColour, userSettings.IconsThemeIndexColour)
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
.ExecuteAsync();
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserSettingsAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> UpdateUserPasswordAsync(UserPasswordForm userPasswordForm, string userId)
{
var result = new WebResult();
try
{
var user = await DbEntities.RootUsers.Match(u => u.ID == userId && u.DeletedAt == null).ExecuteFirstAsync();
if (user == null)
return result.Invalidate(Localizer["Username '{0}' not found.", userId]);
var (verified, needsUpgrade) = PasswordHasher.Check(user.HashedPassword, userPasswordForm.OldPassword);
if (!verified)
return result.Invalidate(Localizer["Wrong password."]);
var newPasswordHashed = PasswordHasher.Hash(userPasswordForm.NewPassword);
user.HashedPassword = newPasswordHashed;
user.UpdatedAt = DateTime.UtcNow;
await DB.Default.SaveAsync(user);
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UpdateUserPasswordAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> RemoveUserAsync(UsersIds usersIds)
{
var result = new WebResult();
try
{
var removed = 0;
foreach (var id in usersIds.UserIdList.Distinct())
if (await Removal.Remove(id, CancellationToken.None))
removed++;
if (removed == 0)
return result.Invalidate(Localizer["User already deleted."]);
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveUserAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
// A root deletes itself only with its password, so a stolen session cannot.
public async Task<WebResult> RemoveSelfAsync(string rootId, string password)
{
var result = new WebResult();
try
{
var user = await DbEntities.RootUsers.MatchID(rootId).Match(u => u.DeletedAt == null).ExecuteFirstAsync();
var (verified, _) = PasswordHasher.Check(user?.HashedPassword ?? Decoy, password ?? string.Empty);
if (user?.HashedPassword == default || !verified)
return result.Invalidate(Localizer[NoMatch], StatusCodes.Status403Forbidden);
await Removal.Remove(user.ID, CancellationToken.None);
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(RemoveSelfAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> BanUserAsync(UsersIds usersIds)
{
var result = new WebResult();
try
{
await DB.Default.Update<RootUser>()
.Match(u => usersIds.UserIdList.Contains(u.ID))
.Modify(u => u.IsBanned, true)
.ExecuteAsync();
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(BanUserAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> UnbanUserAsync(UsersIds usersIds)
{
var result = new WebResult();
try
{
await DB.Default.Update<RootUser>()
.Match(u => usersIds.UserIdList.Contains(u.ID))
.Modify(u => u.IsBanned, false)
.ExecuteAsync();
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(UnbanUserAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public async Task<WebResult> GetUserSettingsAsync(string userId, LoginForm loginForm = default)
{
var result = new WebResult();
try
{
var user = await DbEntities.RootUsers.MatchID(userId).ExecuteFirstAsync();
if (user == default)
return result.Invalidate(Localizer["User not found."], StatusCodes.Status404NotFound);
if (loginForm != default && loginForm.ThemeIsDarkMode != user.Settings.ThemeIsDarkMode)
{
user.Settings.ThemeIsDarkMode = loginForm.ThemeIsDarkMode;
await DB.Default.Update<RootUser>()
.MatchID(userId)
.Modify(u => u.Settings.ThemeIsDarkMode, loginForm.ThemeIsDarkMode)
.ExecuteAsync();
}
result.Data = ToViewSettings(user.Settings);
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(GetUserSettingsAsync)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
public const string RecoverySent = "If that account has an email address, a recovery link is on its way. It works for one hour.";
// The same answer, after the same work, whether the account exists, has an email or can be written to: recovery must
// not tell anyone which logins or addresses exist. RecoveryJob does the rest, out of the request.
public async Task<WebResult> SetupAndSendRecoveryEmail(PasswordRecoveryForm passwordRecoveryForm, string host)
{
var result = new WebResult();
try
{
var userName = passwordRecoveryForm.UserName?.ToLowerInvariant();
var user = passwordRecoveryForm.IsEmailDisabled
? await DbEntities.RootUsers.Match(u => u.UserName == userName && u.DeletedAt == null).ExecuteFirstAsync()
: await DbEntities.RootUsers.Match(u => u.Email == passwordRecoveryForm.Email && u.DeletedAt == null).ExecuteFirstAsync();
await Jobs.Enqueue(JobKind.SendRecovery, JsonSerializer.Serialize(new RecoveryPayload(user?.ID, host)), RecoveryJob.Host,
dedupeKey: default, CancellationToken.None);
result.Data = Localizer[RecoverySent].Value;
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(SetupAndSendRecoveryEmail)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
Task<EmailRecovery> LiveRecovery(string recoveryCode)
{
var hash = RecoveryJob.Hash(recoveryCode);
var now = DateTime.UtcNow;
return DbEntities.EmailRecoveries.Match(er => er.CodeHash == hash && er.ExpiresAt > now).ExecuteFirstAsync();
}
public async Task<WebResult> IsValidRecoveryCode(string recoveryCode)
{
var result = new WebResult();
try
{
result.Data = await LiveRecovery(recoveryCode) != default;
return result;
}
catch (Exception ex)
{
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
// A recovered password ends every session the root had: whoever had them may be why it was recovered.
public async Task<WebResult> ChangePassword(NewPasswordForm newPasswordForm)
{
var result = new WebResult();
try
{
var recovery = await LiveRecovery(newPasswordForm.RecoveryCode);
var user = recovery == default ? default
: await DbEntities.RootUsers.MatchID(recovery.RootUserId).Match(u => u.DeletedAt == null && !u.IsBanned).ExecuteFirstAsync();
if (user == default)
return result.Invalidate(Localizer["Invalid recovery code."], StatusCodes.Status404NotFound);
await DB.Default.Update<RootUser>().MatchID(user.ID)
.Modify(u => u.HashedPassword, PasswordHasher.Hash(newPasswordForm.NewPassword))
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
.ExecuteAsync();
await DB.Default.DeleteAsync<EmailRecovery>(er => er.RootUserId == user.ID);
await Sessions.Revoke(user.ID, CancellationToken.None);
return result;
}
catch (Exception ex)
{
Logger.LogError(ex, $"{nameof(RootUsersService)}.{nameof(ChangePassword)}()");
return result.Invalidate(Localizer["Something went wrong."], exception: ex);
}
}
static ViewUserSettings ToViewSettings(RootUserSettings settings) => new()
{
LanguageCode = settings.LanguageCode,
LightThemeIndexColour = settings.LightThemeIndexColour,
DarkThemeIndexColour = settings.DarkThemeIndexColour,
PreferSystemTheming = settings.PreferSystemTheming,
ThemeIsDarkMode = settings.ThemeIsDarkMode,
IconsThemeIndexColour = settings.IconsThemeIndexColour,
ThemeIsDarkGray = settings.ThemeIsDarkGray,
ThemeIsLightGray = settings.ThemeIsLightGray
};
}
}