Account deletion

This commit is contained in:
2025-05-24 23:29:36 +08:00
parent 80b7812a87
commit 59bc9edd4b
13 changed files with 191 additions and 20 deletions

View File

@ -219,6 +219,24 @@ public class AccountController(
return profile;
}
[HttpDelete("me")]
[Authorize]
public async Task<ActionResult> RequestDeleteAccount()
{
if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Unauthorized();
try
{
await accounts.RequestAccountDeletion(currentUser);
}
catch (InvalidOperationException)
{
return BadRequest("You already requested account deletion within 24 hours.");
}
return Ok();
}
public class StatusRequest
{
public StatusAttitude Attitude { get; set; }

View File

@ -8,17 +8,18 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Logging.Abstractions;
using NodaTime;
namespace DysonNetwork.Sphere.Account;
public class AccountService(
AppDatabase db,
ICacheService cache,
IStringLocalizerFactory factory
MagicSpellService spells,
ICacheService cache
)
{
public const string AccountCachePrefix = "Account_";
public async Task PurgeAccountCache(Account account)
{
await cache.RemoveGroupAsync($"{AccountCachePrefix}{account.Id}");
@ -46,6 +47,18 @@ public class AccountService(
return profile?.Level;
}
public async Task RequestAccountDeletion(Account account)
{
var spell = await spells.CreateMagicSpell(
account,
MagicSpellType.AccountRemoval,
new Dictionary<string, object>(),
SystemClock.Instance.GetCurrentInstant().Plus(Duration.FromHours(24)),
preventRepeat: true
);
await spells.NotifyMagicSpell(spell);
}
/// Maintenance methods for server administrator
public async Task EnsureAccountProfileCreated()
{

View File

@ -1,106 +0,0 @@
using MailKit.Net.Smtp;
using Microsoft.AspNetCore.Components;
using MimeKit;
namespace DysonNetwork.Sphere.Account.Email;
public class EmailServiceConfiguration
{
public string Server { get; set; } = null!;
public int Port { get; set; }
public bool UseSsl { get; set; }
public string Username { get; set; } = null!;
public string Password { get; set; } = null!;
public string FromAddress { get; set; } = null!;
public string FromName { get; set; } = null!;
public string SubjectPrefix { get; set; } = null!;
}
public class EmailService
{
private readonly EmailServiceConfiguration _configuration;
private readonly RazorViewRenderer _viewRenderer;
private readonly ILogger<EmailService> _logger;
public EmailService(IConfiguration configuration, RazorViewRenderer viewRenderer, ILogger<EmailService> logger)
{
var cfg = configuration.GetSection("Email").Get<EmailServiceConfiguration>();
_configuration = cfg ?? throw new ArgumentException("Email service was not configured.");
_viewRenderer = viewRenderer;
_logger = logger;
}
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody)
{
await SendEmailAsync(recipientName, recipientEmail, subject, textBody, null);
}
public async Task SendEmailAsync(string? recipientName, string recipientEmail, string subject, string textBody,
string? htmlBody)
{
subject = $"[{_configuration.SubjectPrefix}] {subject}";
var emailMessage = new MimeMessage();
emailMessage.From.Add(new MailboxAddress(_configuration.FromName, _configuration.FromAddress));
emailMessage.To.Add(new MailboxAddress(recipientName, recipientEmail));
emailMessage.Subject = subject;
var bodyBuilder = new BodyBuilder
{
TextBody = textBody
};
if (!string.IsNullOrEmpty(htmlBody))
bodyBuilder.HtmlBody = htmlBody;
emailMessage.Body = bodyBuilder.ToMessageBody();
using var client = new SmtpClient();
await client.ConnectAsync(_configuration.Server, _configuration.Port, _configuration.UseSsl);
await client.AuthenticateAsync(_configuration.Username, _configuration.Password);
await client.SendAsync(emailMessage);
await client.DisconnectAsync(true);
}
private static string _ConvertHtmlToPlainText(string html)
{
// Remove style tags and their contents
html = System.Text.RegularExpressions.Regex.Replace(html, "<style[^>]*>.*?</style>", "",
System.Text.RegularExpressions.RegexOptions.Singleline);
// Replace header tags with text + newlines
html = System.Text.RegularExpressions.Regex.Replace(html, "<h[1-6][^>]*>(.*?)</h[1-6]>", "$1\n\n",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
// Replace line breaks
html = html.Replace("<br>", "\n").Replace("<br/>", "\n").Replace("<br />", "\n");
// Remove all remaining HTML tags
html = System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", "");
// Decode HTML entities
html = System.Net.WebUtility.HtmlDecode(html);
// Remove excess whitespace
html = System.Text.RegularExpressions.Regex.Replace(html, @"\s+", " ").Trim();
return html;
}
public async Task SendTemplatedEmailAsync<TComponent, TModel>(string? recipientName, string recipientEmail,
string subject, TModel model)
where TComponent : IComponent
{
try
{
var htmlBody = await _viewRenderer.RenderComponentToStringAsync<TComponent, TModel>(model);
var fallbackTextBody = _ConvertHtmlToPlainText(htmlBody);
await SendEmailAsync(recipientName, recipientEmail, subject, fallbackTextBody, htmlBody);
}
catch (Exception err)
{
_logger.LogError(err, "Failed to render email template...");
throw;
}
}
}

View File

@ -1,7 +0,0 @@
namespace DysonNetwork.Sphere.Account.Email;
public class LandingEmailModel
{
public required string Name { get; set; }
public required string VerificationLink { get; set; }
}

View File

@ -1,45 +0,0 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Abstractions;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using RouteData = Microsoft.AspNetCore.Routing.RouteData;
namespace DysonNetwork.Sphere.Account.Email;
public class RazorViewRenderer(
IServiceProvider serviceProvider,
ILoggerFactory loggerFactory,
ILogger<RazorViewRenderer> logger
)
{
public async Task<string> RenderComponentToStringAsync<TComponent, TModel>(TModel? model)
where TComponent : IComponent
{
await using var htmlRenderer = new HtmlRenderer(serviceProvider, loggerFactory);
return await htmlRenderer.Dispatcher.InvokeAsync(async () =>
{
try
{
var dictionary = model?.GetType().GetProperties()
.ToDictionary(
prop => prop.Name,
prop => prop.GetValue(model, null)
) ?? new Dictionary<string, object?>();
var parameterView = ParameterView.FromDictionary(dictionary);
var output = await htmlRenderer.RenderComponentAsync<TComponent>(parameterView);
return output.ToHtmlString();
}
catch (Exception ex)
{
logger.LogError(ex, "Error rendering component {ComponentName}", typeof(TComponent).Name);
throw;
}
});
}
}

View File

@ -1,6 +1,6 @@
using System.Globalization;
using System.Security.Cryptography;
using DysonNetwork.Sphere.Account.Email;
using DysonNetwork.Sphere.Email;
using DysonNetwork.Sphere.Pages.Emails;
using DysonNetwork.Sphere.Permission;
using DysonNetwork.Sphere.Resources.Localization;
@ -24,9 +24,25 @@ public class MagicSpellService(
MagicSpellType type,
Dictionary<string, object> meta,
Instant? expiredAt = null,
Instant? affectedAt = null
Instant? affectedAt = null,
bool preventRepeat = false
)
{
if (preventRepeat)
{
var now = SystemClock.Instance.GetCurrentInstant();
var existingSpell = await db.MagicSpells
.Where(s => s.AccountId == account.Id)
.Where(s => s.Type == type)
.Where(s => s.ExpiresAt == null || s.ExpiresAt > now)
.FirstOrDefaultAsync();
if (existingSpell != null)
{
throw new InvalidOperationException($"Account already has an active magic spell of type {type}");
}
}
var spellWord = _GenerateRandomString(128);
var spell = new MagicSpell
{
@ -79,7 +95,19 @@ public class MagicSpellService(
new LandingEmailModel
{
Name = contact.Account.Name,
VerificationLink = link
Link = link
}
);
break;
case MagicSpellType.AccountRemoval:
await email.SendTemplatedEmailAsync<AccountDeletionEmail, AccountDeletionEmailModel>(
contact.Account.Name,
contact.Content,
localizer["EmailAccountDeletionTitle"],
new AccountDeletionEmailModel
{
Name = contact.Account.Name,
Link = link
}
);
break;