1302 lines
56 KiB
C#
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
|
|
}
|