using System.Security.Claims; using Dapper; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.OutputCaching; using Newtonsoft.Json.Linq; using Npgsql; using PluginBuilder.Configuration; using PluginBuilder.Controllers.Logic; using PluginBuilder.DataModels; using PluginBuilder.JsonConverters; using PluginBuilder.ModelBinders; using PluginBuilder.Services; using PluginBuilder.Util; using PluginBuilder.Util.Extensions; using PluginBuilder.ViewModels; using PluginBuilder.ViewModels.Admin; using PluginBuilder.ViewModels.Plugin; namespace PluginBuilder.Controllers; [Authorize(Roles = Roles.ServerAdmin)] [Route("/admin/")] [AutoValidateAntiforgeryToken] public class AdminController( UserManager userManager, RoleManager roleManager, DBConnectionFactory connectionFactory, AzureStorageClient azureStorageClient, EmailService emailService, NostrService nostrService, AdminSettingsCache adminSettingsCache, ReferrerNavigationService referrerNavigation, PluginBuilderOptions pbOptions, IOutputCacheStore outputCacheStore, PluginOwnershipService ownershipService) : Controller { // settings editor private const string ProtectedKeys = SettingsKeys.EmailSettings; private sealed class PluginVersionAdminRow { public int[] Ver { get; init; } = []; public int[] BtcpayMinVer { get; init; } = []; public bool BtcpayMinVerOverrideEnabled { get; init; } public int[]? BtcpayMaxVer { get; init; } public bool BtcpayMaxVerOverrideEnabled { get; init; } public bool PreRelease { get; init; } public string? ManifestInfo { get; init; } } [HttpGet("plugins")] public async Task ListPlugins(AdminPluginSettingViewModel? model = null) { model ??= new AdminPluginSettingViewModel(); var whereConditions = new List(); var parameters = new DynamicParameters(); if (!string.IsNullOrEmpty(model.SearchText)) { whereConditions.Add("(p.slug ILIKE @searchText OR u.\"Email\" ILIKE @searchText OR p.settings->>'pluginTitle' ILIKE @searchText)"); parameters.Add("searchText", $"%{model.SearchText}%"); } if (!string.IsNullOrEmpty(model.Status) && Enum.TryParse(model.Status, true, out var statusEnum)) { whereConditions.Add("p.visibility = CAST(@status AS plugin_visibility_enum)"); parameters.Add("status", statusEnum.ToString().ToLower()); } var whereClause = whereConditions.Count > 0 ? "WHERE " + string.Join(" AND ", whereConditions) : ""; parameters.Add("skip", model.Skip); parameters.Add("take", model.Count); await using var conn = await connectionFactory.Open(); var rows = await conn.QueryAsync($""" SELECT p.slug, p.visibility, v.ver, v.build_id, v.btcpay_min_ver, v.btcpay_max_ver, v.pre_release, v.updated_at, u."Email" as email, p.settings, EXISTS(SELECT 1 FROM plugin_listing_requests lr WHERE lr.plugin_slug = p.slug AND lr.status = 'pending') as has_pending_request FROM plugins p LEFT JOIN users_plugins up ON p.slug = up.plugin_slug AND up.is_primary_owner IS TRUE LEFT JOIN "AspNetUsers" u ON up.user_id = u."Id" LEFT JOIN ( SELECT DISTINCT ON (plugin_slug) plugin_slug, ver, build_id, btcpay_min_ver, btcpay_max_ver, pre_release, updated_at FROM versions ORDER BY plugin_slug, updated_at DESC ) v ON p.slug = v.plugin_slug {whereClause} ORDER BY p.slug OFFSET @skip LIMIT @take; """, parameters); List plugins = new(); foreach (var row in rows) { var pluginSettings = SafeJson.Deserialize((string)row.settings); AdminPluginViewModel plugin = new() { PluginSlug = row.slug, Visibility = Enum.Parse((string)row.visibility, true), PrimaryOwnerEmail = row.email, HasPendingListingRequest = row.has_pending_request, PluginTitle = pluginSettings?.PluginTitle }; if (row.ver != null) { plugin.Version = string.Join('.', row.ver); plugin.BuildId = row.build_id; plugin.BtcPayMinVer = string.Join('.', row.btcpay_min_ver); plugin.BtcPayMaxVer = row.btcpay_max_ver != null ? string.Join('.', row.btcpay_max_ver) : null; plugin.PreRelease = row.pre_release; plugin.UpdatedAt = row.updated_at; } plugins.Add(plugin); } model.Plugins = plugins; model.VerifiedEmailForPluginPublish = await conn.GetVerifiedEmailForPluginPublishSetting(); return View(model); } [HttpPost] public async Task UpdateVerifiedEmailForPublishRequirement(bool verifiedEmailForPluginPublish) { await using var conn = await connectionFactory.Open(); await conn.UpdateVerifiedEmailForPluginPublishSetting(verifiedEmailForPluginPublish); await adminSettingsCache.RefreshIsVerifiedEmailRequiredForPublish(conn); TempData[TempDataConstant.SuccessMessage] = "Email requirement setting for publishing plugin updated successfully"; return RedirectToAction("ListPlugins"); } [HttpGet("plugins/edit/{pluginSlug}")] public async Task PluginEdit(string pluginSlug, string? tab = null, string? openCompatibilityVersion = null) { referrerNavigation.StoreReferrer(); await using var conn = await connectionFactory.Open(); var plugin = await conn.GetPluginDetails(pluginSlug); if (plugin == null) return NotFound(); var model = new PluginEditViewModel { PluginSlug = plugin.PluginSlug, Identifier = plugin.Identifier, Settings = plugin.Settings, Visibility = plugin.Visibility, ActiveTab = PluginEditTabs.Normalize(tab), OpenCompatibilityVersion = openCompatibilityVersion, PluginSettings = SafeJson.Deserialize(plugin.Settings) }; await PopulatePluginEditActiveTabData(conn, model); return View(model); } [HttpPost("plugins/edit/{pluginSlug}")] public async Task PluginEdit( string pluginSlug, PluginEditViewModel model, [FromForm] bool removeLogoFile = false, [FromForm] string? removeImageUrl = null, [FromForm] List? imagesUrl = null, [FromForm] bool imagesUrlSubmitted = false, [FromForm] List? imagesOrder = null) { await using var conn = await connectionFactory.Open(); model.ActiveTab = PluginEditTabs.Settings; if (!ModelState.IsValid) { await PopulatePluginEditViewModel(conn, pluginSlug, model); return View(model); } var plugin = await conn.GetPluginDetails(pluginSlug); var pluginSettings = SafeJson.Deserialize(plugin?.Settings); if (!string.IsNullOrEmpty(model.PluginSettings.VideoUrl)) { if (!Uri.TryCreate(model.PluginSettings.VideoUrl, UriKind.Absolute, out var videoUri) || videoUri.Scheme != Uri.UriSchemeHttps) { ModelState.AddModelError($"{nameof(PluginEditViewModel.PluginSettings)}.{nameof(PluginSettings.VideoUrl)}", "Video URL must be a valid HTTPS URL."); await PopulatePluginEditViewModel(conn, pluginSlug, model); return View(model); } if (!model.PluginSettings.VideoUrl.IsSupportedVideoUrl()) { ModelState.AddModelError($"{nameof(PluginEditViewModel.PluginSettings)}.{nameof(PluginSettings.VideoUrl)}", "Video URL must be from a supported platform (YouTube, Vimeo)."); await PopulatePluginEditViewModel(conn, pluginSlug, model); return View(model); } } var newTitle = model.PluginSettings.PluginTitle?.Trim(); var currentTitle = pluginSettings.PluginTitle?.Trim(); if (!string.IsNullOrWhiteSpace(newTitle) && !string.Equals(newTitle, currentTitle, StringComparison.OrdinalIgnoreCase)) { PluginSlug? currentSlug = null; if (PluginSlug.TryParse(pluginSlug, out var parsedSlug)) currentSlug = parsedSlug; if (await conn.IsPluginTitleInUse(newTitle, currentSlug)) { ModelState.AddModelError($"{nameof(PluginEditViewModel.PluginSettings)}.{nameof(PluginSettings.PluginTitle)}", "This plugin title is already in use. Please choose a different title."); await PopulatePluginEditViewModel(conn, pluginSlug, model); return View(model); } } pluginSettings.PluginTitle = model.PluginSettings.PluginTitle; pluginSettings.Description = model.PluginSettings.Description; pluginSettings.GitRepository = model.PluginSettings.GitRepository; pluginSettings.GitRef = model.PluginSettings.GitRef; pluginSettings.BuildConfig = model.PluginSettings.BuildConfig; pluginSettings.Documentation = model.PluginSettings.Documentation; pluginSettings.PluginDirectory = model.PluginSettings.PluginDirectory; pluginSettings.VideoUrl = model.PluginSettings.VideoUrl; var existingImages = pluginSettings.Images ?? []; var existingImagesSet = new HashSet(existingImages, StringComparer.Ordinal); pluginSettings.Images = imagesUrlSubmitted ? (imagesUrl ?? []) .Where(s => !string.IsNullOrWhiteSpace(s) && !string.Equals(s, removeImageUrl, StringComparison.Ordinal)) .Distinct(StringComparer.Ordinal) .Where(existingImagesSet.Contains) .ToList() : [..existingImages]; model.PluginSettings.Images = [..pluginSettings.Images]; if (pluginSettings.Images.Count > 10) { ModelState.AddModelError(nameof(model.Images), "A maximum of 10 images is allowed per plugin."); await PopulatePluginEditViewModel(conn, pluginSlug, model); return View(model); } if (model.LogoFile != null) { if (!model.LogoFile.ValidateImageFile(out var errorMessage)) { ModelState.AddModelError(nameof(model.LogoFile), $"Image upload validation failed: {errorMessage}"); await PopulatePluginEditViewModel(conn, pluginSlug, model); return View(model); } try { var uniqueBlobName = $"{pluginSlug}-{Guid.NewGuid()}{Path.GetExtension(model.LogoFile.FileName)}"; pluginSettings.Logo = await azureStorageClient.UploadImageFile(model.LogoFile, uniqueBlobName); } catch (Exception) { ModelState.AddModelError(nameof(model.LogoFile), "Could not complete settings upload. An error occurred while uploading logo"); await PopulatePluginEditViewModel(conn, pluginSlug, model); return View(model); } } else if (removeLogoFile) { model.LogoFile = null; pluginSettings.Logo = null; } var uploadedImages = new List(); var imagesToUpload = (model.Images ?? []).Where(s => s.Length > 0).ToList(); if (imagesToUpload.Count > 0) { if ((pluginSettings.Images?.Count ?? 0) + imagesToUpload.Count > 10) { ModelState.AddModelError(nameof(model.Images), "A maximum of 10 images is allowed per plugin."); await PopulatePluginEditViewModel(conn, pluginSlug, model); return View(model); } foreach (var image in imagesToUpload) { if (!image.ValidateImageFile(out var errorMessage)) { ModelState.AddModelError(nameof(model.Images), $"Image upload validation failed: {errorMessage}"); await PopulatePluginEditViewModel(conn, pluginSlug, model); return View(model); } } try { uploadedImages = (await Task.WhenAll(imagesToUpload.Select(async image => { var blobName = $"{pluginSlug}-{Guid.NewGuid()}{Path.GetExtension(image.FileName)}"; return await azureStorageClient.UploadImageFile(image, blobName); }))).ToList(); } catch (Exception) { ModelState.AddModelError(nameof(model.Images), "Could not complete settings upload. An error occurred while uploading images"); await PopulatePluginEditViewModel(conn, pluginSlug, model); return View(model); } } var existingQueue = new Queue(pluginSettings.Images ?? []); var uploadedQueue = new Queue(uploadedImages); var orderedImages = new List((pluginSettings.Images?.Count ?? 0) + uploadedImages.Count); foreach (var marker in imagesOrder ?? []) { if (string.Equals(marker, "existing", StringComparison.OrdinalIgnoreCase) && existingQueue.Count > 0) orderedImages.Add(existingQueue.Dequeue()); else if (string.Equals(marker, "new", StringComparison.OrdinalIgnoreCase) && uploadedQueue.Count > 0) orderedImages.Add(uploadedQueue.Dequeue()); } orderedImages.AddRange(existingQueue); orderedImages.AddRange(uploadedQueue); if (orderedImages.Count > 10) { ModelState.AddModelError(nameof(model.Images), "A maximum of 10 images is allowed per plugin."); model.PluginSettings.Images = orderedImages; await PopulatePluginEditViewModel(conn, pluginSlug, model); return View(model); } pluginSettings.Images = orderedImages; model.PluginSettings.Images = orderedImages; var setPluginSettings = await conn.SetPluginSettings(pluginSlug, pluginSettings, model.Visibility); if (!setPluginSettings) return NotFound(); await outputCacheStore.EvictByTagAsync(CacheTags.Plugins, CancellationToken.None); TempData[TempDataConstant.SuccessMessage] = "Plugin settings updated successfully"; return RedirectToAction(nameof(PluginEdit), new { pluginSlug, tab = PluginEditTabs.Settings }); } [HttpPost("plugins/edit/{pluginSlug}/versions/{version}/btcpay-compatibility")] public async Task UpdateVersionBtcPayCompatibility( string pluginSlug, [ModelBinder(typeof(PluginVersionModelBinder))] PluginVersion version, [FromForm] string? btcpayMinVersion, [FromForm] string? btcpayMaxVersion, [FromForm] bool useManifestDefaults = false) { await using var conn = await connectionFactory.Open(); var manifestInfo = await conn.QueryFirstOrDefaultAsync( """ SELECT b.manifest_info FROM versions v JOIN builds b ON b.plugin_slug = v.plugin_slug AND b.id = v.build_id WHERE v.plugin_slug = @pluginSlug AND v.ver = @version LIMIT 1 """, new { pluginSlug, version = version.VersionParts }); if (manifestInfo is null) return NotFound(); var strictManifestParsed = PluginManifest.TryParse(manifestInfo, out var strictManifest, strictBTCPayVersionCondition: true); if (useManifestDefaults && !strictManifestParsed) { TempData[TempDataConstant.WarningMessage] = "Cannot reset compatibility because the manifest BTCPayServer condition is unsupported."; return RedirectToAction(nameof(PluginEdit), new { pluginSlug, tab = PluginEditTabs.Versions, openCompatibilityVersion = version.ToString() }); } var manifest = strictManifestParsed ? strictManifest : PluginManifest.Parse(manifestInfo); var manifestMinVersion = manifest.BTCPayMinVersion ?? PluginVersion.Zero; var manifestMaxVersion = manifest.BTCPayMaxVersion; var strictManifestMinVersion = strictManifestParsed ? strictManifest.BTCPayMinVersion ?? PluginVersion.Zero : null; var strictManifestMaxVersion = strictManifestParsed ? strictManifest.BTCPayMaxVersion : null; PluginVersion candidateMinVersion; PluginVersion? candidateMaxVersion; if (useManifestDefaults) { candidateMinVersion = manifestMinVersion; candidateMaxVersion = manifestMaxVersion; } else { if (string.IsNullOrWhiteSpace(btcpayMinVersion) || !PluginVersion.TryParse(btcpayMinVersion.Trim(), out candidateMinVersion)) { TempData[TempDataConstant.WarningMessage] = "BTCPay min version must be a valid version number."; return RedirectToAction(nameof(PluginEdit), new { pluginSlug, tab = PluginEditTabs.Versions, openCompatibilityVersion = version.ToString() }); } if (!string.IsNullOrWhiteSpace(btcpayMaxVersion)) { if (!PluginVersion.TryParse(btcpayMaxVersion.Trim(), out candidateMaxVersion)) { TempData[TempDataConstant.WarningMessage] = "BTCPay max version must be a valid version number."; return RedirectToAction(nameof(PluginEdit), new { pluginSlug, tab = PluginEditTabs.Versions, openCompatibilityVersion = version.ToString() }); } } else { candidateMaxVersion = null; } } if (candidateMaxVersion is not null && candidateMinVersion.CompareTo(candidateMaxVersion) > 0) { TempData[TempDataConstant.WarningMessage] = $"BTCPay max version must be greater than or equal to the minimum version {candidateMinVersion}."; return RedirectToAction(nameof(PluginEdit), new { pluginSlug, tab = PluginEditTabs.Versions, openCompatibilityVersion = version.ToString() }); } var clearMinOverride = useManifestDefaults || (strictManifestParsed && VersionEquals(candidateMinVersion, strictManifestMinVersion)); var clearMaxOverride = useManifestDefaults || (strictManifestParsed && VersionEquals(candidateMaxVersion, strictManifestMaxVersion)); await conn.ExecuteAsync( """ UPDATE versions SET btcpay_min_ver = @btcpayMinVersion, btcpay_min_ver_override_enabled = @minOverrideEnabled, btcpay_max_ver = @btcpayMaxVersion, btcpay_max_ver_override_enabled = @maxOverrideEnabled WHERE plugin_slug = @pluginSlug AND ver = @version """, new { pluginSlug, version = version.VersionParts, btcpayMinVersion = clearMinOverride ? manifestMinVersion.VersionParts : candidateMinVersion.VersionParts, minOverrideEnabled = !clearMinOverride, btcpayMaxVersion = clearMaxOverride ? manifestMaxVersion?.VersionParts : candidateMaxVersion?.VersionParts, maxOverrideEnabled = !clearMaxOverride }); await outputCacheStore.EvictByTagAsync(CacheTags.Plugins, CancellationToken.None); TempData[TempDataConstant.SuccessMessage] = clearMinOverride && clearMaxOverride ? $"Compatibility for version {version} reset to the manifest values." : $"Compatibility for version {version} updated successfully."; return RedirectToAction(nameof(PluginEdit), new { pluginSlug, tab = PluginEditTabs.Versions }); } [HttpGet("plugins/delete/{routeSlug}")] public async Task PluginDelete(string routeSlug) { referrerNavigation.StoreReferrer(); await using var conn = await connectionFactory.Open(); var plugin = await conn.GetPluginDetails(routeSlug); if (plugin == null) return NotFound(); return View(plugin); } [HttpPost("plugins/delete/{routeSlug}")] public async Task PluginDeleteConfirmed(string routeSlug) { await using var conn = await connectionFactory.Open(); var affectedRows = await conn.ExecuteAsync(""" DELETE FROM builds WHERE plugin_slug = @slug; DELETE FROM builds_ids WHERE plugin_slug = @slug; DELETE FROM builds_logs WHERE plugin_slug = @slug; DELETE FROM users_plugins WHERE plugin_slug = @slug; DELETE FROM versions WHERE plugin_slug = @slug; DELETE FROM plugins WHERE slug = @slug; """, new { slug = routeSlug }); if (affectedRows == 0) return NotFound(); await outputCacheStore.EvictByTagAsync(CacheTags.Plugins, CancellationToken.None); return referrerNavigation.RedirectToReferrerOr(this, "ListPlugins"); } [HttpGet("plugins/import-review/{pluginSlug}")] public IActionResult ImportReview(string pluginSlug) { return RedirectToAction(nameof(PluginEdit), new { pluginSlug, tab = PluginEditTabs.Reviews }); } [HttpPost("plugins/import-review/{pluginSlug}")] public async Task ImportReview(ImportReviewViewModel model, string pluginSlug) { if (model.Rating is < 1 or > 5 || string.IsNullOrEmpty(model.Body)) { TempData[TempDataConstant.WarningMessage] = "Invalid rating or body specified"; return RedirectToAction(nameof(PluginEdit), new { pluginSlug = model.PluginSlug, tab = PluginEditTabs.Reviews }); } if (!string.IsNullOrWhiteSpace(model.SourceUrl)) model.Body += $"\n\n[Click here to continue reading]({model.SourceUrl})"; await using var conn = await connectionFactory.Open(); PluginReviewViewModel vm = new() { PluginSlug = pluginSlug, Rating = model.Rating, Body = model.Body, CreatedAt = DateTime.UtcNow }; if (model.LinkExistingUser && !string.IsNullOrEmpty(model.SelectedUserId)) { var linkedUser = await userManager.FindByIdAsync(model.SelectedUserId); if (linkedUser == null) { TempData[TempDataConstant.WarningMessage] = "Invalid system user"; return RedirectToAction(nameof(PluginEdit), new { pluginSlug = model.PluginSlug, tab = PluginEditTabs.Reviews }); } var reviewerAccountDetails = await conn.GetAccountDetailSettings(linkedUser.Id) ?? new AccountSettings(); model = model.UpdatePluginReviewerData(reviewerAccountDetails); } if (!model.LinkExistingUser) { if (string.IsNullOrEmpty(model.ReviewerName)) { TempData[TempDataConstant.WarningMessage] = "Kindly provide the reviewer profile"; return RedirectToAction(nameof(PluginEdit), new { pluginSlug = model.PluginSlug, tab = PluginEditTabs.Reviews }); } model.ReviewerName = model.ReviewerName.TrimStart('/').TrimEnd(); model.SelectedUserId = null; switch (model.Source) { case ImportReviewViewModel.ImportReviewSourceEnum.Nostr: var nostrIdentifier = model.ReviewerName.Trim(); if (!nostrService.TryGetPubKeyHex(nostrIdentifier, out var pubKeyHex)) { TempData[TempDataConstant.WarningMessage] = "Invalid Nostr identifier (npub, nprofile or hex)."; return RedirectToAction(nameof(PluginEdit), new { pluginSlug = model.PluginSlug, tab = PluginEditTabs.Reviews }); } var nostrProfile = await nostrService.GetNostrProfileByAuthorHexAsync(pubKeyHex); model.ReviewerName = !string.IsNullOrWhiteSpace(nostrProfile?.Name) ? nostrProfile.Name! : nostrIdentifier; model.ReviewerProfileUrl = string.Format(ExternalProfileUrls.PrimalProfileFormat, Uri.EscapeDataString(pubKeyHex)); model.ReviewerAvatarUrl = nostrProfile?.PictureUrl; break; case ImportReviewViewModel.ImportReviewSourceEnum.WWW: var wwwInput = model.ReviewerName.Trim(); if (!wwwInput.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && !wwwInput.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) wwwInput = $"https://{wwwInput}"; if (!Uri.TryCreate(wwwInput, UriKind.Absolute, out var uri)) { TempData[TempDataConstant.WarningMessage] = "Invalid website URL."; return RedirectToAction(nameof(PluginEdit), new { pluginSlug = model.PluginSlug, tab = PluginEditTabs.Reviews }); } model.ReviewerProfileUrl = wwwInput; var host = uri.Host; if (host.StartsWith("www.", StringComparison.OrdinalIgnoreCase)) host = host[4..]; model.ReviewerName = !string.IsNullOrWhiteSpace(model.WwwDisplayName) ? model.WwwDisplayName.Trim() : host; if (!string.IsNullOrWhiteSpace(model.WwwAvatarUrl) && Uri.TryCreate(model.WwwAvatarUrl, UriKind.Absolute, out var avatarUri) && (avatarUri.Scheme == Uri.UriSchemeHttp || avatarUri.Scheme == Uri.UriSchemeHttps)) model.ReviewerAvatarUrl = model.WwwAvatarUrl; else model.ReviewerAvatarUrl = string.Format(ExternalProfileUrls.UnavatarSiteFormat, host); break; case ImportReviewViewModel.ImportReviewSourceEnum.X: var escapedXHandle = Uri.EscapeDataString(model.ReviewerName); model.ReviewerProfileUrl = $"{ExternalProfileUrls.XBaseUrl}{escapedXHandle}"; model.ReviewerAvatarUrl = string.Format(ExternalProfileUrls.XAvatarFormat, escapedXHandle); break; case ImportReviewViewModel.ImportReviewSourceEnum.Github: var escapedGhHandle = Uri.EscapeDataString(model.ReviewerName); model.ReviewerProfileUrl = $"{ExternalProfileUrls.GithubBaseUrl}{escapedGhHandle}"; model.ReviewerAvatarUrl = string.Format(ExternalProfileUrls.GithubAvatarFormat, escapedGhHandle, 48); break; default: model.ReviewerProfileUrl = null; model.ReviewerAvatarUrl = null; break; } if (model.ReviewerProfileUrl == null) { TempData[TempDataConstant.WarningMessage] = "Invalid source selected"; return RedirectToAction(nameof(PluginEdit), new { pluginSlug = model.PluginSlug, tab = PluginEditTabs.Reviews }); } } vm.ReviewerId = await conn.CreateOrUpdatePluginReviewer(model); await conn.UpsertPluginReview(vm); var url = Url.Action(nameof(HomeController.GetPluginDetails), "Home", new { pluginSlug = model.PluginSlug }, Request.Scheme); TempData[TempDataConstant.SuccessMessage] = "Review submitted successfully."; TempData[TempDataConstant.SuccessLinkUrl] = url; TempData[TempDataConstant.SuccessLinkText] = "Click here to view it"; return RedirectToAction(nameof(PluginEdit), new { pluginSlug = model.PluginSlug, tab = PluginEditTabs.Reviews }); } [HttpPost("plugins/{pluginSlug}/owners/{userId}/transfer-primary")] [ValidateAntiForgeryToken] public async Task TransferPrimaryOwner([ModelBinder(typeof(PluginSlugModelBinder))] PluginSlug pluginSlug, string userId) { var result = await ownershipService.TransferPrimaryAsync(pluginSlug, userId); TempData[result.Success ? TempDataConstant.SuccessMessage : TempDataConstant.WarningMessage] = result.Success ? "Primary owner assigned" : result.Error; return RedirectToAction(nameof(PluginEdit), new { pluginSlug, tab = PluginEditTabs.Owners }); } [HttpPost("plugins/{pluginSlug}/owners/add")] [ValidateAntiForgeryToken] public async Task AddOwner([ModelBinder(typeof(PluginSlugModelBinder))] PluginSlug pluginSlug, string email) { var result = await ownershipService.AddOwnerByEmailAsync(pluginSlug, email); TempData[result.Success ? TempDataConstant.SuccessMessage : TempDataConstant.WarningMessage] = result.Success ? "Owner added." : result.Error; return RedirectToAction(nameof(PluginEdit), new { pluginSlug, tab = PluginEditTabs.Owners }); } [HttpPost("plugins/{pluginSlug}/owners/{userId}/remove")] [ValidateAntiForgeryToken] public async Task RemoveOwner([ModelBinder(typeof(PluginSlugModelBinder))] PluginSlug pluginSlug, string userId) { var result = await ownershipService.RemoveOwnerAsync( pluginSlug, userId, null, true); TempData[result.Success ? TempDataConstant.SuccessMessage : TempDataConstant.WarningMessage] = result.Success ? "Owner removed." : result.Error; return RedirectToAction(nameof(PluginEdit), new { pluginSlug, tab = PluginEditTabs.Owners }); } // list users [HttpGet("users")] public async Task Users(AdminUsersListViewModel? model = null) { model ??= new AdminUsersListViewModel(); await using var conn = await connectionFactory.Open(); var usersQuery = userManager.Users.OrderBy(a => a.Email).AsQueryable(); if (!string.IsNullOrEmpty(model.SearchText)) usersQuery = usersQuery.Where(o => (o.UserName != null && o.UserName.Contains(model.SearchText)) || (o.Email != null && o.Email.Contains(model.SearchText))); var users = usersQuery.Skip(model.Skip).Take(model.Count).ToList(); List usersList = new(); foreach (var user in users) { var userRoles = await userManager.GetRolesAsync(user); var accountSettings = await conn.GetAccountDetailSettings(user!.Id) ?? new AccountSettings(); usersList.Add(new AdminUsersViewModel { Id = user.Id, Email = user.Email!, UserName = user.UserName!, EmailConfirmed = user.EmailConfirmed, Roles = userRoles, PendingNewEmail = accountSettings?.PendingNewEmail }); } model.Users = usersList; return View(model); } private async Task PopulatePluginEditViewModel(NpgsqlConnection conn, string pluginSlug, PluginEditViewModel model) { var plugin = await conn.GetPluginDetails(pluginSlug); if (plugin is null) return; model.PluginSlug = plugin.PluginSlug; model.Identifier ??= plugin.Identifier; model.Settings ??= plugin.Settings; model.ActiveTab = PluginEditTabs.Normalize(model.ActiveTab); model.PluginSettings ??= SafeJson.Deserialize(plugin.Settings) ?? new PluginSettings(); await PopulatePluginEditActiveTabData(conn, model); } private async Task PopulatePluginEditActiveTabData(NpgsqlConnection conn, PluginEditViewModel model) { switch (model.ActiveTab) { case PluginEditTabs.Owners: model.PluginUsers = await conn.GetPluginOwners(model.PluginSlug); break; case PluginEditTabs.Versions: model.PublishedVersions = await GetPublishedVersionsForAdmin(conn, model.PluginSlug); break; case PluginEditTabs.Reviews: model.ImportReview.PluginSlug = model.PluginSlug; model.ImportReview.ExistingUsers = CreateExistingUserSelectItems(); break; } } private List CreateExistingUserSelectItems() { return userManager.Users.Select(u => new SelectListItem { Value = u.Id, Text = u.Email }).ToList(); } private async Task> GetPublishedVersionsForAdmin(NpgsqlConnection conn, string pluginSlug) { var rows = await conn.QueryAsync( """ SELECT v.ver AS "Ver", v.btcpay_min_ver AS "BtcpayMinVer", v.btcpay_min_ver_override_enabled AS "BtcpayMinVerOverrideEnabled", v.btcpay_max_ver AS "BtcpayMaxVer", v.btcpay_max_ver_override_enabled AS "BtcpayMaxVerOverrideEnabled", v.pre_release AS "PreRelease", b.manifest_info AS "ManifestInfo" FROM versions v JOIN builds b ON b.plugin_slug = v.plugin_slug AND b.id = v.build_id WHERE v.plugin_slug = @pluginSlug ORDER BY v.ver DESC """, new { pluginSlug }); return rows.Select(row => new PublishedPluginVersionAdminViewModel { Version = FormatVersion(row.Ver), BtcPayMinVersion = FormatVersion(row.BtcpayMinVer), HasBtcPayMinVersionOverride = row.BtcpayMinVerOverrideEnabled, BtcPayMaxVersion = FormatVersion(row.BtcpayMaxVer), HasBtcPayMaxVersionOverride = row.BtcpayMaxVerOverrideEnabled, PreRelease = row.PreRelease, ManifestCondition = TryGetBtcPayDependencyCondition(row.ManifestInfo) }).ToList(); } private static string? TryGetBtcPayDependencyCondition(string? manifestInfo) { if (string.IsNullOrWhiteSpace(manifestInfo)) return null; try { var dependencies = JObject.Parse(manifestInfo)["Dependencies"] as JArray; return dependencies? .OfType() .FirstOrDefault(d => string.Equals(d["Identifier"]?.ToString(), "BTCPayServer", StringComparison.Ordinal))?["Condition"] ?.ToString(); } catch { return null; } } private static string? FormatVersion(int[]? versionParts) { return versionParts is { Length: > 0 } ? string.Join('.', versionParts) : null; } private static bool VersionEquals(PluginVersion? left, PluginVersion? right) { if (left is null && right is null) return true; if (left is null || right is null) return false; return left.CompareTo(right) == 0; } // edit roles [HttpGet("editroles/{userId}")] public async Task EditRoles(string userId) { var user = await userManager.FindByIdAsync(userId); if (user == null) return NotFound(); var userRoles = await userManager.GetRolesAsync(user); var allRoles = roleManager.Roles.ToList(); EditUserRolesViewModel model = new() { UserId = user.Id, UserName = user.UserName!, UserRoles = userRoles, AvailableRoles = allRoles }; return View(model); } [HttpPost("editroles/{userId}")] public async Task EditRoles(string userId, List userRoles) { var user = await userManager.FindByIdAsync(userId); if (user == null) return NotFound(); var currentRoles = await userManager.GetRolesAsync(user); var rolesToAdd = userRoles.Except(currentRoles).ToList(); var rolesToRemove = currentRoles.Except(userRoles).ToList(); // Validate if this is the last admin user and prevent it from being removed from the ServerAdmin role var currentUserId = User.FindFirstValue(ClaimTypes.NameIdentifier); if (currentUserId == userId && rolesToRemove.Contains(Roles.ServerAdmin)) { var admins = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin); if (admins.Count == 1) { ModelState.AddModelError("", "You cannot remove yourself as the last ServerAdmin."); // Rebuild the view model to pass it back to the view EditUserRolesViewModel model = new() { UserId = userId, UserRoles = currentRoles.ToList(), AvailableRoles = roleManager.Roles.ToList() }; return View(model); } } await userManager.AddToRolesAsync(user, rolesToAdd); await userManager.RemoveFromRolesAsync(user, rolesToRemove); return RedirectToAction("Users"); } // init reset password [HttpGet("/admin/userpasswordreset")] public async Task UserPasswordReset(string userId) { InitPasswordResetViewModel model = new(); var user = await userManager.FindByIdAsync(userId); if (user != null) model.Email = user.Email; return View(model); } [HttpPost("/admin/userpasswordreset")] public async Task UserPasswordReset(InitPasswordResetViewModel model) { if (!ModelState.IsValid) return View(model); // Require the user to have a confirmed email before they can log on var user = await userManager.FindByEmailAsync(model.Email); if (user is null) { ModelState.AddModelError(string.Empty, "User with suggested email doesn't exist"); return View(model); } var code = await userManager.GeneratePasswordResetTokenAsync(user); var callbackUrl = Url.Action(nameof(HomeController.PasswordReset), "Home", new { email = user.Email, code }, Request.Scheme); await emailService.ResetPasswordEmail(model.Email, callbackUrl!); TempData[TempDataConstant.SuccessMessage] = "Password reset initiated successfully. Email has been sent to user"; return RedirectToAction(nameof(Users)); } [HttpGet("/admin/userchangeemail")] public async Task UserChangeEmail(string userId) { await using var conn = await connectionFactory.Open(); UserChangeEmailViewModel model = new(); var user = await userManager.FindByIdAsync(userId); if (user != null) { var accountSettings = await conn.GetAccountDetailSettings(user!.Id) ?? new AccountSettings(); model.OldEmail = user.Email; model.PendingNewEmail = !string.IsNullOrEmpty(accountSettings?.PendingNewEmail) ? accountSettings.PendingNewEmail : null; } return View(model); } [HttpPost("/admin/userchangeemail")] public async Task UserChangeEmail(UserChangeEmailViewModel model) { if (!ModelState.IsValid) return View(model); await using var conn = await connectionFactory.Open(); var user = await userManager.FindByEmailAsync(model.OldEmail); if (user is null) { ModelState.AddModelError(string.Empty, "User with suggested email doesn't exist"); return View(model); } var accountSettings = await conn.GetAccountDetailSettings(user!.Id) ?? new AccountSettings(); accountSettings.PendingNewEmail = model.NewEmail; await conn.SetAccountDetailSettings(accountSettings, user.Id); var token = await userManager.GenerateChangeEmailTokenAsync(user, model.NewEmail); var link = Url.Action("VerifyEmailUpdate", "Home", new { uid = user.Id, token }, Request.Scheme, Request.Host.ToString())!; await emailService.SendVerifyEmail(model.NewEmail, link); TempData[TempDataConstant.SuccessMessage] = "A verification email has been sent to user's new email address: " + model.PendingNewEmail; return RedirectToAction(nameof(Users)); } [HttpGet("emailsettings")] public async Task EmailSettings() { var emailSettings = await emailService.GetEmailSettingsFromDb() ?? new EmailSettingsViewModel { Port = 465 }; return View(emailSettings); } [HttpPost("emailsettings")] public async Task EmailSettings(EmailSettingsViewModel model, bool passwordSet, string? command) { if (passwordSet) { var dbModel = await emailService.GetEmailSettingsFromDb(); if (dbModel != null) model.Password = dbModel.Password; ModelState.Remove("Password"); if (command?.Equals("resetpassword", StringComparison.OrdinalIgnoreCase) == true) { model.Password = null!; await emailService.SaveEmailSettingsToDatabase(model); TempData[TempDataConstant.SuccessMessage] = "SMTP password reset."; return RedirectToAction(nameof(EmailSettings)); } } if (!ModelState.IsValid) return View(model); if (!await ValidateSmtpConnection(model)) return View(model); await emailService.SaveEmailSettingsToDatabase(model); TempData[TempDataConstant.SuccessMessage] = $"SMTP settings updated. Emails will be sent from {model.From}."; return RedirectToAction(nameof(EmailSettings)); } private async Task ValidateSmtpConnection(EmailSettingsViewModel model) { try { var smtpClient = await emailService.CreateSmtpClient(model); await smtpClient.DisconnectAsync(true); } catch (Exception ex) { ModelState.AddModelError(string.Empty, $"Failed to connect to SMTP server: {ex.Message}"); return false; } return true; } [HttpGet("emailsender")] public async Task EmailSender(string to, string subject, string message) { if (!string.IsNullOrEmpty(to) && !emailService.IsValidEmailList(to)) { ModelState.AddModelError("To", "Invalid email format in the 'To' field. Please ensure all emails are valid."); return View(new EmailSenderViewModel()); } var emailSettings = await emailService.GetEmailSettingsFromDb(); if (emailSettings == null) { TempData[TempDataConstant.WarningMessage] = "Email testing can't be done before SMTP is set"; return RedirectToAction(nameof(EmailSettings)); } EmailSenderViewModel model = new() { From = emailSettings.From, To = to, Subject = subject, Message = message }; return View(model); } [HttpPost("emailsender")] public async Task EmailSender(EmailSenderViewModel model) { if (!ModelState.IsValid) return View(model); if (!emailService.IsValidEmailList(model.To)) { ModelState.AddModelError("To", "Invalid email format in the 'To' field. Please ensure all emails are valid."); return View(model); } var emailSettings = await emailService.GetEmailSettingsFromDb(); if (emailSettings == null) { TempData[TempDataConstant.WarningMessage] = "Email settings not found"; return View(model); } try { var emailsSent = await emailService.SendEmail(model.To, model.Subject, model.Message); TempData[TempDataConstant.SuccessMessage] = $"Emails sent successfully to {emailsSent.Count} recipient(s)."; } catch (Exception ex) { TempData[TempDataConstant.WarningMessage] = $"Failed to send test email: {ex.Message}"; return View(model); } return View(model); } [HttpGet("SettingsEditor")] public async Task SettingsEditor() { await using var conn = await connectionFactory.Open(); var result = await conn.SettingsGetAllAsync(); var list = result.ToList(); list.RemoveAll(setting => setting.key == ProtectedKeys); return View(list); } [HttpPost("SettingsEditor")] public async Task SettingsEditor(string key, string value) { if (key == ProtectedKeys) return BadRequest(); await using var conn = await connectionFactory.Open(); var result = await conn.SettingsSetAsync(key, value); await adminSettingsCache.RefreshAllAdminSettings(conn); return RedirectToAction(nameof(SettingsEditor)); } [HttpDelete("SettingsEditor")] public async Task SettingsEditorDelete(string key) { if (key == ProtectedKeys) return BadRequest(); await using var conn = await connectionFactory.Open(); var result = await conn.SettingsDeleteAsync(key); await adminSettingsCache.RefreshAllAdminSettings(conn); return Ok(); } [HttpGet("server/logs/{file?}")] public async Task LogsView(string? file = null, int offset = 0, bool download = false) { if (offset < 0) offset = 0; var vm = new LogsViewModel(); if (string.IsNullOrEmpty(pbOptions.DebugLogFile)) { TempData[TempDataConstant.WarningMessage] = "File Logging Option not specified. You need to set debuglog and optionally debugloglevel in the configuration or through runtime arguments"; return View("Logs", vm); } var logsDirectory = Directory.GetParent(pbOptions.DebugLogFile); if (logsDirectory is null) { TempData[TempDataConstant.WarningMessage] = "Could not load log files"; return View("Logs", vm); } var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(pbOptions.DebugLogFile); var fileExtension = Path.GetExtension(pbOptions.DebugLogFile); var allFiles = logsDirectory.GetFiles($"{fileNameWithoutExtension}*{fileExtension}") .OrderByDescending(info => info.LastWriteTime) .ToList(); vm.LogFileCount = allFiles.Count; vm.LogFiles = allFiles.Skip(offset).Take(5).ToList(); vm.LogFileOffset = offset; if (string.IsNullOrEmpty(file) || !file.EndsWith(fileExtension, StringComparison.Ordinal)) return View("Logs", vm); var selectedFile = allFiles.FirstOrDefault(f => f.Name.Equals(file, StringComparison.Ordinal)); if (selectedFile is null) return NotFound(); try { var fileStream = new FileStream(selectedFile.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); if (download) return File(fileStream, "text/plain", file, true); await using (fileStream) using (var reader = new StreamReader(fileStream)) { vm.Log = await reader.ReadToEndAsync(); } } catch { return NotFound(); } return View("Logs", vm); } #region Listing Requests Management [HttpGet("listing-requests")] public async Task ListingRequests(string? status = null) { await using var conn = await connectionFactory.Open(); var statusFilter = status?.ToLowerInvariant() ?? "pending"; var sql = """ SELECT lr.id AS "Id", lr.plugin_slug AS "PluginSlug", lr.status AS "Status", lr.submitted_at AS "SubmittedAt", lr.announcement_date AS "AnnouncementDate", p.settings->>'pluginTitle' AS "PluginTitle", u."Email" AS "PrimaryOwnerEmail" FROM plugin_listing_requests lr JOIN plugins p ON lr.plugin_slug = p.slug LEFT JOIN users_plugins up ON p.slug = up.plugin_slug AND up.is_primary_owner = true LEFT JOIN "AspNetUsers" u ON up.user_id = u."Id" WHERE (@status = 'all' OR lr.status = @status) ORDER BY CASE WHEN lr.status = 'pending' THEN 0 ELSE 1 END, lr.submitted_at DESC """; var requests = await conn.QueryAsync(sql, new { status = statusFilter }); var vm = new ListingRequestsViewModel { Requests = requests.ToList(), StatusFilter = statusFilter }; return View(vm); } [HttpGet("listing-requests/{requestId}")] public async Task ListingRequestDetail(int requestId) { await using var conn = await connectionFactory.Open(); var request = await conn.GetListingRequest(requestId); if (request == null) return NotFound(); var plugin = await conn.GetPluginDetails(new PluginSlug(request.PluginSlug)); var pluginSettings = SafeJson.Deserialize(plugin?.Settings); var owners = await conn.GetPluginOwners(new PluginSlug(request.PluginSlug)); var ownerVerifications = new List(); foreach (var owner in owners) { var accountSettings = SafeJson.Deserialize(owner.AccountDetail); var ownerNpub = accountSettings?.Nostr?.Npub?.Trim(); var githubHandle = ExternalAccountVerificationService.GetGithubHandle(accountSettings?.Github); ownerVerifications.Add(new OwnerVerificationViewModel { Email = owner.Email, EmailVerified = owner.EmailConfirmed, IsPrimary = owner.IsPrimary, GithubProfile = string.IsNullOrWhiteSpace(githubHandle) ? null : $"{ExternalProfileUrls.GithubBaseUrl}{Uri.EscapeDataString(githubHandle)}", NostrProfile = string.IsNullOrEmpty(ownerNpub) ? null : string.Format(ExternalProfileUrls.PrimalProfileFormat, Uri.EscapeDataString(ownerNpub)) }); } var reviewedByEmail = request.ReviewedBy != null ? (await userManager.FindByIdAsync(request.ReviewedBy))?.Email : null; var vm = new ListingRequestDetailViewModel { Id = request.Id, PluginSlug = request.PluginSlug, PluginTitle = pluginSettings?.PluginTitle, PluginDescription = pluginSettings?.Description, Logo = pluginSettings?.Logo, GitRepository = pluginSettings?.GitRepository, Documentation = pluginSettings?.Documentation, ReleaseNote = request.ReleaseNote, TelegramVerificationMessage = request.TelegramVerificationMessage, UserReviews = request.UserReviews, AnnouncementDate = request.AnnouncementDate, Status = request.Status, SubmittedAt = request.SubmittedAt, ReviewedAt = request.ReviewedAt, ReviewedByEmail = reviewedByEmail, RejectionReason = request.RejectionReason, Owners = ownerVerifications, PrimaryOwnerEmail = owners.FirstOrDefault(o => o.IsPrimary)?.Email ?? "Unknown" }; return View(vm); } [HttpPost("listing-requests/{requestId}/approve")] [ValidateAntiForgeryToken] public async Task ApproveListingRequest(int requestId) { await using var conn = await connectionFactory.Open(); var request = await conn.GetListingRequest(requestId); if (request == null) return NotFound(); if (request.Status != PluginListingRequestStatus.Pending) { TempData[TempDataConstant.WarningMessage] = "This request has already been processed"; return RedirectToAction(nameof(ListingRequestDetail), new { requestId }); } var userId = userManager.GetUserId(User)!; var pluginSlug = new PluginSlug(request.PluginSlug); var approved = await conn.ApproveListingRequest(requestId, userId); if (!approved) { TempData[TempDataConstant.WarningMessage] = "Failed to approve the listing request"; return RedirectToAction(nameof(ListingRequestDetail), new { requestId }); } var existingSettings = await conn.GetSettings(pluginSlug); var updated = await conn.SetPluginSettings(pluginSlug, existingSettings, PluginVisibilityEnum.Listed); if (!updated) { TempData[TempDataConstant.WarningMessage] = "Failed to update plugin visibility"; return RedirectToAction(nameof(ListingRequestDetail), new { requestId }); } var pluginOwners = await conn.GetPluginOwners(pluginSlug); var primaryOwner = pluginOwners.FirstOrDefault(o => o.IsPrimary); if (primaryOwner != null && !string.IsNullOrEmpty(primaryOwner.Email)) { var pluginPublicUrl = Url.Action(nameof(HomeController.GetPluginDetails), "Home", new { pluginSlug }, Request.Scheme); if (pluginPublicUrl != null) await emailService.NotifyPluginOwnerForRequestListingStatus(primaryOwner.Email, existingSettings?.PluginTitle ?? pluginSlug.ToString(), true, pluginPublicUrl); } await outputCacheStore.EvictByTagAsync(CacheTags.Plugins, CancellationToken.None); TempData[TempDataConstant.SuccessMessage] = $"Plugin '{request.PluginSlug}' has been approved and is now listed"; return RedirectToAction(nameof(ListingRequests)); } [HttpPost("listing-requests/{requestId}/reject")] [ValidateAntiForgeryToken] public async Task RejectListingRequest(int requestId, string rejectionReason) { await using var conn = await connectionFactory.Open(); var request = await conn.GetListingRequest(requestId); if (request == null) return NotFound(); if (request.Status != PluginListingRequestStatus.Pending) { TempData[TempDataConstant.WarningMessage] = "This request has already been processed"; return RedirectToAction(nameof(ListingRequestDetail), new { requestId }); } if (string.IsNullOrWhiteSpace(rejectionReason)) { TempData[TempDataConstant.WarningMessage] = "Rejection reason is required"; return RedirectToAction(nameof(ListingRequestDetail), new { requestId }); } var userId = userManager.GetUserId(User)!; var pluginSlug = new PluginSlug(request.PluginSlug); var rejected = await conn.RejectListingRequest(requestId, userId, rejectionReason.Trim()); if (!rejected) { TempData[TempDataConstant.WarningMessage] = "Failed to reject the listing request"; return RedirectToAction(nameof(ListingRequestDetail), new { requestId }); } var existingSettings = await conn.GetSettings(pluginSlug); var pluginOwners = await conn.GetPluginOwners(pluginSlug); var primaryOwner = pluginOwners.FirstOrDefault(o => o.IsPrimary); if (primaryOwner != null && !string.IsNullOrEmpty(primaryOwner.Email)) await emailService.NotifyPluginOwnerForRequestListingStatus(primaryOwner.Email, existingSettings?.PluginTitle ?? pluginSlug.ToString(), false, rejectionReason); TempData[TempDataConstant.SuccessMessage] = $"Plugin listing request for '{request.PluginSlug}' has been rejected"; return RedirectToAction(nameof(ListingRequests)); } #endregion }