btcpayserver-plugin-builder/PluginBuilder/Controllers/AdminController.cs
2026-04-17 13:53:50 +01:00

1302 lines
56 KiB
C#

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<IdentityUser> userManager,
RoleManager<IdentityRole> 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<IActionResult> ListPlugins(AdminPluginSettingViewModel? model = null)
{
model ??= new AdminPluginSettingViewModel();
var whereConditions = new List<string>();
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<PluginVisibilityEnum>(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<AdminPluginViewModel> plugins = new();
foreach (var row in rows)
{
var pluginSettings = SafeJson.Deserialize<PluginSettings>((string)row.settings);
AdminPluginViewModel plugin = new()
{
PluginSlug = row.slug,
Visibility = Enum.Parse<PluginVisibilityEnum>((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<IActionResult> 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<IActionResult> 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<PluginSettings>(plugin.Settings)
};
await PopulatePluginEditActiveTabData(conn, model);
return View(model);
}
[HttpPost("plugins/edit/{pluginSlug}")]
public async Task<IActionResult> PluginEdit(
string pluginSlug,
PluginEditViewModel model,
[FromForm] bool removeLogoFile = false,
[FromForm] string? removeImageUrl = null,
[FromForm] List<string>? imagesUrl = null,
[FromForm] bool imagesUrlSubmitted = false,
[FromForm] List<string>? 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<PluginSettings>(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<string>(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<string>();
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<string>(pluginSettings.Images ?? []);
var uploadedQueue = new Queue<string>(uploadedImages);
var orderedImages = new List<string>((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<IActionResult> 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<string?>(
"""
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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<AdminUsersViewModel> 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<PluginSettings>(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<SelectListItem> CreateExistingUserSelectItems()
{
return userManager.Users.Select(u => new SelectListItem
{
Value = u.Id,
Text = u.Email
}).ToList();
}
private async Task<List<PublishedPluginVersionAdminViewModel>> GetPublishedVersionsForAdmin(NpgsqlConnection conn, string pluginSlug)
{
var rows = await conn.QueryAsync<PluginVersionAdminRow>(
"""
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<JObject>()
.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<IActionResult> 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<IActionResult> EditRoles(string userId, List<string> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> EmailSettings()
{
var emailSettings = await emailService.GetEmailSettingsFromDb() ?? new EmailSettingsViewModel { Port = 465 };
return View(emailSettings);
}
[HttpPost("emailsettings")]
public async Task<IActionResult> 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<bool> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<ListingRequestItemViewModel>(sql, new { status = statusFilter });
var vm = new ListingRequestsViewModel
{
Requests = requests.ToList(),
StatusFilter = statusFilter
};
return View(vm);
}
[HttpGet("listing-requests/{requestId}")]
public async Task<IActionResult> 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<PluginSettings>(plugin?.Settings);
var owners = await conn.GetPluginOwners(new PluginSlug(request.PluginSlug));
var ownerVerifications = new List<OwnerVerificationViewModel>();
foreach (var owner in owners)
{
var accountSettings = SafeJson.Deserialize<AccountSettings>(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<IActionResult> 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<IActionResult> 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
}