2026-10-04 03:16:59 +02:00
using Microsoft.Extensions.Localization ;
using MongoDB.Entities ;
using PrivaPub.Infrastructure.Jobs ;
using PrivaPub.Models.Jobs ;
using PrivaPub.Models.User ;
using PrivaPub.Resources ;
using System.Security.Cryptography ;
using System.Text ;
using System.Text.Json ;
namespace PrivaPub.Services
{
public sealed record RecoveryPayload ( string RootUserId , string Host );
// Sends a password recovery link. Every request queues one of these, for an account that exists or not, so the answer
// to the request and its timing say nothing; SMTP's slowness and failures happen here, where nobody waits on them.
2026-10-17 03:13:16 +02:00
// The code exists only in the email: the database keeps its hash, for an hour. Only a verified address is written to.
2026-10-04 03:16:59 +02:00
public class RecoveryJob : IJobHandler
{
public const string Host = "recovery" ;
public static readonly TimeSpan CodeLifetime = TimeSpan . FromHours ( 1 );
2026-10-17 03:13:16 +02:00
readonly IMailer _mailer ;
2026-10-04 03:16:59 +02:00
readonly IStringLocalizer < GenericRes > _localizer ;
readonly ILogger < RecoveryJob > _logger ;
2026-10-17 03:13:16 +02:00
public RecoveryJob ( IMailer mailer , IStringLocalizer < GenericRes > localizer , ILogger < RecoveryJob > logger )
2026-10-04 03:16:59 +02:00
{
2026-10-17 03:13:16 +02:00
_mailer = mailer ;
2026-10-04 03:16:59 +02:00
_localizer = localizer ;
_logger = logger ;
}
public JobKind Kind => JobKind . SendRecovery ;
public int Concurrency => 1 ;
public int MaxAttempts => 3 ;
public int PerHostLimit => 1 ;
public static string Hash ( string code ) => Convert . ToHexStringLower ( SHA256 . HashData ( Encoding . UTF8 . GetBytes ( code ?? string . Empty )));
public async Task < JobOutcome > Handle ( Job job , CancellationToken token )
{
var payload = JsonSerializer . Deserialize < RecoveryPayload >( job . Payload );
if ( string . IsNullOrEmpty ( payload ?. RootUserId ))
return JobOutcome . Done ; //asked for an account that does not exist: there is nobody to write to
var user = await DB . Default . Find < RootUser >(). MatchID ( payload . RootUserId ). ExecuteFirstAsync ( token );
2026-10-17 03:13:16 +02:00
if ( user is not { DeletedAt : null , IsBanned : false , IsEmailValidated : true } || string . IsNullOrEmpty ( user . Email ))
2026-10-04 03:16:59 +02:00
return JobOutcome . Done ;
var code = Convert . ToHexStringLower ( RandomNumberGenerator . GetBytes ( 32 ));
await DB . Default . DeleteAsync < EmailRecovery >( r => r . RootUserId == user . ID );
await DB . Default . SaveAsync ( new EmailRecovery { RootUserId = user . ID , CodeHash = Hash ( code ), ExpiresAt = DateTime . UtcNow + CodeLifetime }, token );
try
{
2026-10-17 03:13:16 +02:00
await _mailer . Send ( user . Email , _localizer [ "PrivaPub - Password recovery link" ],
string . Format ( _localizer [
"Hello,\n\nSomeone asked to reset the password of the PrivaPub login {0}. If it was you, open this link within an hour:\n{1}\n\nIf it was not you, ignore this email: nothing changes." ],
user . UserName , $"{payload.Host}/password-recovery?rc={code}" ), token );
2026-10-04 03:16:59 +02:00
return JobOutcome . Done ;
}
catch ( Exception ex ) when ( ex is not OperationCanceledException || ! token . IsCancellationRequested )
{
_logger . LogWarning ( ex , "The recovery email could not be sent" );
return JobOutcome . Retry ( "smtp" );
}
}
}
}