using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using PluginBuilder.DataModels; using PluginBuilder.Services; using PluginBuilder.Util; using PluginBuilder.Util.Extensions; using PluginBuilder.ViewModels.Account; namespace PluginBuilder.Controllers; [Authorize] [Route("/account/")] public class AccountController( GPGKeyService _gpgService, DBConnectionFactory connectionFactory, UserManager userManager, ExternalAccountVerificationService externalAccountVerificationService, EmailService emailService, NostrService nostrService, ILogger logger) : Controller { [HttpGet("verifyemail")] public async Task VerifyEmail() { var user = await userManager.GetUserAsync(User); if (user == null) throw new Exception("User not found"); var emailSettings = await emailService.GetEmailSettingsFromDb(); var needToVerifyEmail = emailSettings?.PasswordSet == true && !await userManager.IsEmailConfirmedAsync(user); if (needToVerifyEmail) { var token = await userManager.GenerateEmailConfirmationTokenAsync(user); var link = Url.Action(nameof(HomeController.ConfirmEmail), "Home", new { uid = user.Id, token }, Request.Scheme, Request.Host.ToString())!; await emailService.SendVerifyEmail(user.Email!, link); var action = nameof(HomeController.VerifyEmail); var ctrl = nameof(HomeController).Replace("Controller", ""); return RedirectToAction(action, ctrl, new { email = user.Email! }); } return RedirectToAction(nameof(AccountDetails)); } [HttpGet("details")] public async Task AccountDetails() { await using var conn = await connectionFactory.Open(); var user = await userManager.GetUserAsync(User); var emailSettings = await emailService.GetEmailSettingsFromDb(); var needToVerifyEmail = emailSettings?.PasswordSet == true && !await userManager.IsEmailConfirmedAsync(user!); var settings = await conn.GetAccountDetailSettings(user!.Id) ?? new AccountSettings(); var isGithubVerified = await conn.IsGithubAccountVerified(user.Id); var isNostrVerified = !string.IsNullOrWhiteSpace(settings.Nostr?.Npub) && !string.IsNullOrWhiteSpace(settings.Nostr.Proof); AccountDetailsViewModel model = new() { AccountEmail = user.Email!, AccountEmailConfirmed = user.EmailConfirmed, NeedToVerifyEmail = needToVerifyEmail, GithubAccountVerified = isGithubVerified, Settings = settings, IsNostrVerified = isNostrVerified }; return View(model); } [HttpPost("details")] public async Task AccountDetails(AccountDetailsViewModel model) { if (!ModelState.IsValid) return View(model); var user = await userManager.GetUserAsync(User)!; await using var conn = await connectionFactory.Open(); var accountSettings = await conn.GetAccountDetailSettings(user!.Id) ?? new AccountSettings(); accountSettings.Twitter = model.Settings.Twitter; accountSettings.Email = model.Settings.Email; if (!string.IsNullOrEmpty(model.Settings.GPGKey?.PublicKey)) { var isPublicKeyValid = _gpgService.ValidateArmouredPublicKey(model.Settings.GPGKey.PublicKey.Trim(), out var message, out var keyViewModel); if (!isPublicKeyValid) { TempData[TempDataConstant.WarningMessage] = $"GPG Key is not valid: {message}"; return View(model); } accountSettings.GPGKey = keyViewModel; } else { accountSettings.GPGKey = null; } await conn.SetAccountDetailSettings(accountSettings, user!.Id); TempData[TempDataConstant.SuccessMessage] = "Account details updated successfully"; return RedirectToAction(nameof(AccountDetails)); } [HttpGet("VerifyGithubAccount")] public async Task VerifyGithubAccount() { await using var conn = await connectionFactory.Open(); var user = await userManager.GetUserAsync(User); var isGithubVerified = await conn.IsGithubAccountVerified(user!.Id); if (isGithubVerified) { TempData[TempDataConstant.SuccessMessage] = "GitHub account already verified"; return RedirectToAction(nameof(AccountDetails)); } var accountSettings = await conn.GetAccountDetailSettings(user!.Id) ?? new AccountSettings(); accountSettings.Github = null; await conn.SetAccountDetailSettings(accountSettings, user!.Id); return View(new VerifyGitHubViewModel { Token = user!.Id }); } [HttpPost("VerifyGithubAccount")] public async Task VerifyGithubAccount(VerifyGitHubViewModel model) { try { var user = await userManager.GetUserAsync(User); var githubUsername = await externalAccountVerificationService.VerifyGistToken(model.GistUrl, user!.Id); if (string.IsNullOrEmpty(githubUsername)) { TempData[TempDataConstant.WarningMessage] = "Unable to verify Github profile. Kindly ensure all data is correct and try again"; return View(model); } await using var conn = await connectionFactory.Open(); var accountSettings = await conn.GetAccountDetailSettings(user!.Id) ?? new AccountSettings(); accountSettings.Github = githubUsername; await conn.SetAccountDetailSettings(accountSettings, user!.Id); await conn.VerifyGithubAccount(user!.Id, model.GistUrl); TempData[TempDataConstant.SuccessMessage] = "Github account verified successfully"; return RedirectToAction(nameof(AccountDetails)); } catch (Exception ex) { TempData[TempDataConstant.WarningMessage] = $"Unable to validate Github profile: {ex.Message}"; return View(model); } } [HttpGet("nostr/nip07-payload")] public async Task GetNip07VerificationPayload() { var user = await userManager.GetUserAsync(User) ?? throw new Exception("User not found"); var challengeToken = nostrService.GetOrCreateActiveChallenge(user.Id); var message = $"Verifying my https://{Request.Host.Host} account. Proof: {challengeToken}"; return Json(new StartNip07Response(challengeToken, message)); } [HttpPost("nostr/verify-nip07")] public async Task NostrVerifyNip07([FromBody] VerifyNip07Request req) { var user = await userManager.GetUserAsync(User) ?? throw new Exception("User not found"); if (!ModelState.IsValid) return BadRequest("invalid_payload"); if (!nostrService.IsValidChallenge(user.Id, req.ChallengeToken)) return BadRequest("invalid_or_expired_challenge"); if (!NostrService.HasTag(req.Event, "challenge", req.ChallengeToken)) return BadRequest("missing_challenge"); if (!NostrService.VerifyEvent(req.Event)) return BadRequest("invalid_signature"); await using var conn = await connectionFactory.Open(); var npub = NostrService.HexPubToNpub(req.Event.Pubkey); var npubOwnerId = await conn.GetUserIdByNpubAsync(npub); if (npubOwnerId is not null && !string.Equals(npubOwnerId, user.Id, StringComparison.Ordinal)) return BadRequest("npub_already_linked_to_other_account"); var settings = await conn.GetAccountDetailSettings(user.Id) ?? new AccountSettings(); settings.Nostr ??= new NostrSettings(); settings.Nostr.Npub = npub; settings.Nostr.Proof = req.Event.Id; var profile = await nostrService.GetNostrProfileByAuthorHexAsync(req.Event.Pubkey); if (profile is not null) settings.Nostr.Profile = profile; await conn.SetAccountDetailSettings(settings, user.Id); TempData[TempDataConstant.SuccessMessage] = "Nostr account verified successfully"; return Ok(); } [HttpGet("nostr/verify-public-note")] public async Task NostrVerifyPublicNote() { var user = await userManager.GetUserAsync(User) ?? throw new Exception("User not found"); var token = nostrService.GetOrCreateActiveChallenge(user.Id); var message = $"Verifying my {Request.Host.Host} account. Proof: {token}"; return View(new VerifyNostrManualViewModel { ChallengeToken = token, Message = message }); } [HttpPost("nostr/verify-public-note")] public async Task NostrVerifyPublicNote(VerifyNostrManualViewModel model) { if (!ModelState.IsValid) return View(model); var user = await userManager.GetUserAsync(User) ?? throw new Exception("User not found"); if (!nostrService.IsValidChallenge(user.Id, model.ChallengeToken)) { TempData[TempDataConstant.WarningMessage] = "Invalid or expired challenge. Please retry."; return View(model); } var refStr = NostrService.ExtractNip19(model.NoteRef); if (refStr is null) { TempData[TempDataConstant.WarningMessage] = "Please provide a note/nevent URL or a NIP-19 (note1…/nevent1…)."; return View(model); } string? eventIdHex; if (refStr.StartsWith("note1", StringComparison.OrdinalIgnoreCase)) { if (!NostrService.TryDecodeNoteToEventIdHex(refStr, out eventIdHex)) { TempData[TempDataConstant.WarningMessage] = "Invalid note reference."; return View(model); } } else if (refStr.StartsWith("nevent1", StringComparison.OrdinalIgnoreCase)) { if (!NostrService.TryDecodeNeventToEventIdHex(refStr, out eventIdHex)) { TempData[TempDataConstant.WarningMessage] = "Invalid nevent reference."; return View(model); } } else if (NostrService.IsHex64(refStr)) { eventIdHex = refStr.ToLowerInvariant(); } else { TempData[TempDataConstant.WarningMessage] = "Unsupported reference format."; return View(model); } var evJson = await nostrService.FetchEventFromRelaysAsync(eventIdHex!); if (evJson is null) { TempData[TempDataConstant.WarningMessage] = "Event not found on relays."; return View(model); } var ev = evJson.ToObject()!; if (!NostrService.VerifyEvent(ev)) { TempData[TempDataConstant.WarningMessage] = "Invalid Nostr signature."; return View(model); } if (!ContentHasProof(ev.Content, model.ChallengeToken)) { TempData[TempDataConstant.WarningMessage] = "Challenge token not found in note content."; return View(model); } await using var conn = await connectionFactory.Open(); var npub = NostrService.HexPubToNpub(ev.Pubkey); var npubOwnerId = await conn.GetUserIdByNpubAsync(npub); if (npubOwnerId is not null && !string.Equals(npubOwnerId, user.Id, StringComparison.Ordinal)) { TempData[TempDataConstant.WarningMessage] = "This Nostr Npub is already linked to another account."; return View(model); } var settings = await conn.GetAccountDetailSettings(user.Id) ?? new AccountSettings(); settings.Nostr ??= new NostrSettings(); settings.Nostr.Npub = npub; settings.Nostr.Proof = model.NoteRef; try { var profile = await nostrService.GetNostrProfileByAuthorHexAsync(ev.Pubkey); if (profile is not null) settings.Nostr.Profile = profile; } catch (Exception ex) { logger.LogError(ex, "Failed to fetch nostr profile"); } await conn.SetAccountDetailSettings(settings, user.Id); TempData[TempDataConstant.SuccessMessage] = "Nostr account verified successfully"; return RedirectToAction(nameof(AccountDetails)); } private static bool ContentHasProof(string? content, string token) { return !string.IsNullOrEmpty(content) && content.Contains(token); } }