btcpayserver-plugin-builder/PluginBuilder/Controllers/PluginController.cs

960 lines
44 KiB
C#

using Dapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OutputCaching;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Npgsql;
using PluginBuilder.Components.PluginVersion;
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.Plugin;
namespace PluginBuilder.Controllers;
[Authorize(Policy = Policies.OwnPlugin)]
[Route("/plugins/{pluginSlug}")]
public class PluginController(
DBConnectionFactory connectionFactory,
UserManager<IdentityUser> userManager,
EmailService emailService,
BuildService buildService,
AzureStorageClient azureStorageClient,
UserVerifiedLogic userVerifiedLogic,
IOutputCacheStore outputCacheStore,
PluginOwnershipService ownershipService,
VersionLifecycleService versionLifecycleService,
GitHostingProviderFactory gitHostingProviderFactory,
ILogger<PluginController> logger)
: Controller
{
[HttpGet("settings")]
public async Task<IActionResult> Settings(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug)
{
var userId = userManager.GetUserId(User);
await using var conn = await connectionFactory.Open();
var settings = await conn.GetSettings(pluginSlug);
if (settings is null)
return NotFound();
var pluginOwner = await conn.RetrievePluginPrimaryOwner(pluginSlug);
var vm = settings.ToPluginSettingViewModel();
vm.IsPluginPrimaryOwner = pluginOwner == userId;
return View(vm);
}
[HttpPost("settings")]
public async Task<IActionResult> Settings(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
PluginSettingViewModel settingViewModel,
[FromForm] bool removeLogoFile = false,
[FromForm] string? removeImageUrl = null,
[FromForm] List<string>? imagesUrl = null,
[FromForm] bool imagesUrlSubmitted = false,
[FromForm] List<string>? imagesOrder = null)
{
if (settingViewModel is null)
return NotFound();
if (string.IsNullOrEmpty(settingViewModel.GitRepository) ||
!Uri.TryCreate(settingViewModel.GitRepository, UriKind.Absolute, out var gitRepoUri) ||
gitRepoUri.Scheme != Uri.UriSchemeHttps)
{
ModelState.AddModelError(nameof(settingViewModel.GitRepository), "Git repository is required and must be an HTTPS URL");
return View(settingViewModel);
}
if (!string.IsNullOrEmpty(settingViewModel.Documentation) &&
(!Uri.TryCreate(settingViewModel.Documentation, UriKind.Absolute, out var docUri) ||
docUri.Scheme != Uri.UriSchemeHttps))
{
ModelState.AddModelError(nameof(settingViewModel.Documentation), "Documentation must be an HTTPS URL");
return View(settingViewModel);
}
if (!string.IsNullOrEmpty(settingViewModel.VideoUrl))
{
if (!Uri.TryCreate(settingViewModel.VideoUrl, UriKind.Absolute, out var videoUri) || videoUri.Scheme != Uri.UriSchemeHttps)
{
ModelState.AddModelError(nameof(settingViewModel.VideoUrl), "Video URL must be a valid HTTPS URL");
return View(settingViewModel);
}
if (!settingViewModel.VideoUrl.IsSupportedVideoUrl())
{
ModelState.AddModelError(nameof(settingViewModel.VideoUrl), "Video URL must be from a supported platform (YouTube, Vimeo)");
return View(settingViewModel);
}
}
var userId = userManager.GetUserId(User);
await using var conn = await connectionFactory.Open();
var existingSetting = await conn.GetSettings(pluginSlug);
var pluginOwner = await conn.RetrievePluginPrimaryOwner(pluginSlug);
settingViewModel.LogoUrl = existingSetting?.Logo;
settingViewModel.IsPluginPrimaryOwner = pluginOwner == userId;
var existingImages = existingSetting?.Images ?? [];
var existingImagesSet = new HashSet<string>(existingImages, StringComparer.Ordinal);
settingViewModel.ImagesUrl = imagesUrlSubmitted
? (imagesUrl ?? [])
.Where(s => !string.IsNullOrWhiteSpace(s) && !string.Equals(s, removeImageUrl, StringComparison.Ordinal))
.Distinct(StringComparer.Ordinal)
.Where(existingImagesSet.Contains)
.ToList()
: [..existingImages];
if (settingViewModel.ImagesUrl.Count > 10)
{
ModelState.AddModelError(nameof(settingViewModel.Images), "A maximum of 10 images is allowed per plugin.");
return View(settingViewModel);
}
if (settingViewModel.IsPluginPrimaryOwner && (string.IsNullOrEmpty(settingViewModel.Description) || string.IsNullOrEmpty(settingViewModel.PluginTitle)))
{
ModelState.AddModelError(nameof(settingViewModel.PluginTitle), "Plugin title and description are required");
return View(settingViewModel);
}
if (settingViewModel.IsPluginPrimaryOwner && !string.IsNullOrWhiteSpace(settingViewModel.PluginTitle))
{
var newTitle = settingViewModel.PluginTitle.Trim();
var currentTitle = existingSetting?.PluginTitle?.Trim();
if (!string.Equals(newTitle, currentTitle, StringComparison.OrdinalIgnoreCase) && await conn.IsPluginTitleInUse(newTitle, pluginSlug))
{
ModelState.AddModelError(nameof(settingViewModel.PluginTitle),
"This plugin title is already in use. Please choose a different title.");
return View(settingViewModel);
}
}
if (settingViewModel.Logo != null)
{
if (!settingViewModel.Logo.ValidateImageFile(out var errorMessage))
{
ModelState.AddModelError(nameof(settingViewModel.Logo), $"Image upload validation failed: {errorMessage}");
return View(settingViewModel);
}
try
{
var uniqueBlobName = $"{pluginSlug}-{Guid.NewGuid()}{Path.GetExtension(settingViewModel.Logo.FileName)}";
settingViewModel.LogoUrl = await azureStorageClient.UploadImageFile(settingViewModel.Logo, uniqueBlobName);
}
catch (Exception ex)
{
logger.LogError(ex, "Failed to upload logo for plugin {PluginSlug}", pluginSlug);
ModelState.AddModelError(nameof(settingViewModel.LogoUrl), "Could not complete settings upload. An error occurred while uploading logo");
return View(settingViewModel);
}
}
else if (removeLogoFile)
{
settingViewModel.Logo = null;
settingViewModel.LogoUrl = null;
}
var uploadedImages = new List<string>();
var imagesToUpload = (settingViewModel.Images ?? []).OfType<IFormFile>().Where(s => s.Length > 0).ToList();
if (imagesToUpload.Count > 0)
{
if (settingViewModel.ImagesUrl.Count + imagesToUpload.Count > 10)
{
ModelState.AddModelError(nameof(settingViewModel.Images), "A maximum of 10 images is allowed per plugin.");
return View(settingViewModel);
}
foreach (var image in imagesToUpload)
{
if (!image.ValidateImageFile(out var errorMessage))
{
ModelState.AddModelError(nameof(settingViewModel.Images), $"Image upload validation failed: {errorMessage}");
return View(settingViewModel);
}
}
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 ex)
{
logger.LogError(ex, "Failed to upload images for plugin {PluginSlug}", pluginSlug);
ModelState.AddModelError(nameof(settingViewModel.Images), "Could not complete settings upload. An error occurred while uploading images");
return View(settingViewModel);
}
}
var existingQueue = new Queue<string>(settingViewModel.ImagesUrl);
var uploadedQueue = new Queue<string>(uploadedImages);
var orderedImages = new List<string>(settingViewModel.ImagesUrl.Count + 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(settingViewModel.Images), "A maximum of 10 images is allowed per plugin.");
settingViewModel.ImagesUrl = orderedImages;
return View(settingViewModel);
}
settingViewModel.ImagesUrl = orderedImages;
if (!settingViewModel.IsPluginPrimaryOwner && existingSetting is not null)
{
settingViewModel.RequireGPGSignatureForRelease = existingSetting.RequireGPGSignatureForRelease;
settingViewModel.PluginTitle = existingSetting.PluginTitle;
settingViewModel.Description = existingSetting.Description;
}
await conn.SetPluginSettings(pluginSlug, settingViewModel.ToPluginSettings());
await outputCacheStore.EvictByTagAsync(CacheTags.Plugins, CancellationToken.None);
TempData[TempDataConstant.SuccessMessage] = "Settings updated successfully";
return RedirectToAction(nameof(Settings), new { pluginSlug });
}
[HttpGet("create")]
public async Task<IActionResult> CreateBuild(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug, long? copyBuild = null)
{
await using var conn = await connectionFactory.Open();
if (!await userVerifiedLogic.IsUserEmailVerifiedForPublish(User) || !await userVerifiedLogic.IsUserGithubVerified(User, conn))
{
TempData[TempDataConstant.WarningMessage] = "You need to verify your email address and github account in order to create and publish plugins";
return RedirectToAction("AccountDetails", "Account");
}
var settings = await conn.GetSettings(pluginSlug);
CreateBuildViewModel model = new()
{
GitRepository = settings?.GitRepository,
GitRef = settings?.GitRef,
PluginDirectory = settings?.PluginDirectory,
BuildConfig = settings?.BuildConfig
};
if (copyBuild is long buildId)
{
var buildInfo = await conn.QueryFirstOrDefaultAsync<string>("SELECT build_info FROM builds WHERE plugin_slug=@pluginSlug AND id=@buildId",
new { buildId, pluginSlug = pluginSlug.ToString() });
if (buildInfo != null)
{
var bi = BuildInfo.Parse(buildInfo);
model.GitRepository = bi.GitRepository;
model.GitRef = bi.GitRef;
model.PluginDirectory = bi.PluginDir;
model.BuildConfig = bi.BuildConfig;
}
}
return View(model);
}
[HttpPost("create")]
public async Task<IActionResult> CreateBuild(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
CreateBuildViewModel model)
{
if (!ModelState.IsValid)
return View(model);
await using var conn = await connectionFactory.Open();
if (!await userVerifiedLogic.IsUserEmailVerifiedForPublish(User) || !await userVerifiedLogic.IsUserGithubVerified(User, conn))
{
TempData[TempDataConstant.WarningMessage] = "You need to verify your email address and github account in order to create and publish plugins";
return RedirectToAction("AccountDetails", "Account");
}
try
{
var identifier = await buildService.FetchIdentifierFromCsprojAsync(model.GitRepository, model.GitRef, model.PluginDirectory);
var owns = await conn.EnsureIdentifierOwnership(pluginSlug, identifier);
if (!owns)
{
TempData[TempDataConstant.WarningMessage] = $"The plugin identifier '{identifier}' does not belong to plugin slug '{pluginSlug}'.";
return View(model);
}
}
catch (BuildServiceException ex)
{
TempData[TempDataConstant.WarningMessage] = $"Manifest validation failed: {ex.Message}";
return View(model);
}
var buildId = await conn.NewBuild(pluginSlug, model.ToBuildParameter());
if (buildId == 0)
{
var existingSetting = await conn.GetSettings(pluginSlug) ?? new PluginSettings();
existingSetting.GitRepository = model.GitRepository;
existingSetting.GitRef = model.GitRef;
existingSetting.PluginDirectory = model.PluginDirectory;
existingSetting.BuildConfig = model.BuildConfig;
await conn.SetPluginSettings(pluginSlug, existingSetting);
}
_ = buildService.Build(new FullBuildId(pluginSlug, buildId));
return RedirectToAction(nameof(Build), new { pluginSlug = pluginSlug.ToString(), buildId });
}
[HttpGet("listing-history")]
public async Task<IActionResult> ListingHistory(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug)
{
await using var conn = await connectionFactory.Open();
var plugin = await conn.GetPluginDetails(pluginSlug);
if (plugin is null)
return NotFound();
var pluginSettings = SafeJson.Deserialize<PluginSettings>(plugin.Settings);
var requests = await conn.GetAllListingRequestsForPlugin(pluginSlug);
var vm = new ListingHistoryViewModel
{
PluginSlug = pluginSlug.ToString(),
PluginTitle = pluginSettings?.PluginTitle,
Requests = requests.Select(r => new ListingHistoryItemViewModel
{
Id = r.Id,
Status = r.Status,
ReleaseNote = r.ReleaseNote,
SubmittedAt = r.SubmittedAt,
ReviewedAt = r.ReviewedAt,
RejectionReason = r.RejectionReason
}).ToList()
};
return View(vm);
}
[HttpGet("request-listing")]
public async Task<IActionResult> RequestListing(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug)
{
var model = new RequestListingViewModel { PluginSlug = pluginSlug.ToString() };
await using var conn = await connectionFactory.Open();
var plugin = await conn.GetPluginDetails(pluginSlug);
if (plugin is null)
return NotFound();
if (plugin.Visibility == PluginVisibilityEnum.Listed)
return RedirectToAction(nameof(Dashboard), new { pluginSlug });
if (plugin.Visibility == PluginVisibilityEnum.Hidden)
return NotFound();
var allRequests = await conn.GetAllListingRequestsForPlugin(pluginSlug);
var pluginOwners = await conn.GetPluginOwners(pluginSlug);
var pluginSettings = SafeJson.Deserialize<PluginSettings>(plugin.Settings);
var pendingRequest = await conn.GetPendingListingRequestForPlugin(pluginSlug);
var rejectedRequest = await conn.GetLatestRejectedListingRequestForPlugin(pluginSlug);
model.ReleaseNote = pluginSettings?.Description;
model.HasPreviousRejection = rejectedRequest != null;
model.HasRequests = allRequests.Any();
if (pendingRequest != null)
{
model.PendingListing = true;
model.TelegramVerificationMessage = pendingRequest.TelegramVerificationMessage;
model.UserReviews = pendingRequest.UserReviews;
model.ReleaseNote = pendingRequest.ReleaseNote;
model.AnnouncementDate = pendingRequest.AnnouncementDate;
var now = DateTimeOffset.UtcNow;
model.CanSendEmailReminder = now >= pendingRequest.SubmittedAt.AddDays(1);
TempData[TempDataConstant.WarningMessage] = "Your listing request has been sent and is pending validation";
}
else if (rejectedRequest != null)
{
// Pre-fill form with data from the latest rejected request
model.TelegramVerificationMessage = rejectedRequest.TelegramVerificationMessage;
model.UserReviews = rejectedRequest.UserReviews;
model.ReleaseNote = rejectedRequest.ReleaseNote;
model.AnnouncementDate = rejectedRequest.AnnouncementDate;
}
model = await ListingRequirementsMet(conn, pluginSettings, pluginOwners, model);
return View(model);
}
[HttpPost("request-listing")]
public async Task<IActionResult> RequestListing(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug, RequestListingViewModel model)
{
await using var conn = await connectionFactory.Open();
var plugin = await conn.GetPluginDetails(pluginSlug);
if (plugin is null)
return NotFound();
var pluginSettings = SafeJson.Deserialize<PluginSettings>(plugin.Settings);
if (plugin.Visibility == PluginVisibilityEnum.Hidden)
return NotFound();
if (plugin.Visibility == PluginVisibilityEnum.Listed)
return RedirectToAction(nameof(Dashboard), new { pluginSlug });
if (string.IsNullOrWhiteSpace(model.ReleaseNote))
ModelState.AddModelError(nameof(model.ReleaseNote), "Description is required.");
if (string.IsNullOrWhiteSpace(model.TelegramVerificationMessage) ||
!Uri.TryCreate(model.TelegramVerificationMessage, UriKind.Absolute, out var telegramUri) ||
telegramUri.Scheme != Uri.UriSchemeHttps ||
!telegramUri.Host.Equals("t.me", StringComparison.OrdinalIgnoreCase) ||
!telegramUri.AbsolutePath.Trim('/').StartsWith("btcpayserver", StringComparison.OrdinalIgnoreCase))
ModelState.AddModelError(nameof(model.TelegramVerificationMessage),
"Telegram verification message on BTCPay Server telegram (https://t.me/btcpayserver/... ) channel is required.");
if (string.IsNullOrWhiteSpace(model.UserReviews))
ModelState.AddModelError(nameof(model.UserReviews), "User-reviews link is required and must be an HTTPS URL.");
if (!ModelState.IsValid)
{
var owners = await conn.GetPluginOwners(pluginSlug);
model.PendingListing = await conn.HasPendingListingRequest(pluginSlug);
model = await ListingRequirementsMet(conn, pluginSettings, owners, model);
return View(model);
}
await conn.CreateListingRequest(pluginSlug, model.ReleaseNote.Trim(), model.TelegramVerificationMessage.Trim(), model.UserReviews.Trim(),
model.AnnouncementDate);
await SendRequestListingEmail(conn, pluginSlug.ToString());
TempData[TempDataConstant.SuccessMessage] = "Your listing request has been sent and is pending validation";
return RedirectToAction(nameof(Dashboard), new { pluginSlug });
}
private async Task<RequestListingViewModel> ListingRequirementsMet(NpgsqlConnection conn, PluginSettings pluginSettings, List<OwnerVm> owners,
RequestListingViewModel model)
{
if (pluginSettings == null || owners == null || owners.Count == 0)
{
model.Step = RequestListingViewModel.State.Invalid;
return model;
}
var docsMissing = string.IsNullOrWhiteSpace(pluginSettings?.GitRepository) || string.IsNullOrWhiteSpace(pluginSettings?.Documentation)
|| string.IsNullOrWhiteSpace(pluginSettings?.Logo)
|| string.IsNullOrWhiteSpace(pluginSettings?.Description)
|| string.IsNullOrWhiteSpace(pluginSettings?.VideoUrl);
var ownerNotVerified = false;
foreach (var owner in owners)
if (!await conn.IsSocialAccountsVerified(owner.UserId))
{
ownerNotVerified = true;
break;
}
model.Step = (docsMissing, ownerNotVerified) switch
{
(true, _) => RequestListingViewModel.State.UpdatePluginSettings,
(false, true) => RequestListingViewModel.State.UpdateOwnerAccountSettings,
(false, false) => RequestListingViewModel.State.Done
};
return model;
}
public async Task<IActionResult> SendReminder([ModelBinder(typeof(PluginSlugModelBinder))] PluginSlug pluginSlug)
{
await using var conn = await connectionFactory.Open();
var plugin = await conn.GetPluginDetails(pluginSlug);
if (plugin is null || plugin.Visibility != PluginVisibilityEnum.Unlisted)
return NotFound();
var request = await conn.GetPendingListingRequestForPlugin(pluginSlug);
if (request is null)
{
TempData[TempDataConstant.WarningMessage] = "No listing request exist";
return RedirectToAction(nameof(Dashboard), new { pluginSlug });
}
var now = DateTimeOffset.UtcNow;
if (now < request.SubmittedAt.AddDays(1))
{
TempData[TempDataConstant.WarningMessage] = "Please wait 24 hours before sending another reminder";
return RedirectToAction(nameof(Dashboard), new { pluginSlug });
}
await SendRequestListingEmail(conn, pluginSlug.ToString());
TempData[TempDataConstant.SuccessMessage] = "Request listing reminders sent to admins";
return RedirectToAction(nameof(RequestListing), new { pluginSlug });
}
private async Task SendRequestListingEmail(NpgsqlConnection conn, string pluginSlug)
{
var pluginPublicUrl = Url.Action(nameof(HomeController.GetPluginDetails), "Home", new { pluginSlug }, Request.Scheme);
var listingReviewUrl = Url.Action(nameof(AdminController.ListingRequests), "Admin", new { }, Request.Scheme);
await emailService.NotifyAdminOnNewRequestListing(conn, pluginSlug, pluginPublicUrl!, listingReviewUrl!);
}
[HttpPost("versions/{version}/release")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Release(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
[ModelBinder(typeof(PluginVersionModelBinder))]
PluginVersion version, string command, IFormFile? signatureFile)
{
switch (command)
{
case "remove":
var removeResult = await versionLifecycleService.RemoveAsync(pluginSlug, version);
if (!removeResult.Success)
return HandleVersionLifecycleFailure(pluginSlug, version, removeResult);
TempData[TempDataConstant.SuccessMessage] = $"Version {version} removed";
return RedirectToAction(nameof(Build), new { pluginSlug = pluginSlug.ToString(), buildId = removeResult.BuildId!.Value });
case "sign_release":
if (signatureFile is not { Length: > 0 })
{
TempData[TempDataConstant.WarningMessage] = "Please upload a valid GPG signature file (.asc or .sig)";
return RedirectToAction(nameof(Version), new { pluginSlug = pluginSlug.ToString(), version = version.ToString() });
}
var signatureBytes = await ReadFormFileBytes(signatureFile);
var signReleaseResult = await versionLifecycleService.ReleaseAsync(pluginSlug, version, userManager.GetUserId(User)!, signatureBytes);
if (!signReleaseResult.Success)
return HandleVersionLifecycleFailure(pluginSlug, version, signReleaseResult);
TempData[TempDataConstant.SuccessMessage] = $"Version {version} released";
return RedirectToAction(nameof(Version), new { pluginSlug = pluginSlug.ToString(), version = version.ToString() });
case "unrelease":
var unreleaseResult = await versionLifecycleService.UnreleaseAsync(pluginSlug, version);
if (!unreleaseResult.Success)
return HandleVersionLifecycleFailure(pluginSlug, version, unreleaseResult);
TempData[TempDataConstant.SuccessMessage] = $"Version {version} unreleased";
return RedirectToAction(nameof(Version), new { pluginSlug = pluginSlug.ToString(), version = version.ToString() });
default:
var releaseResult = await versionLifecycleService.ReleaseAsync(pluginSlug, version, userManager.GetUserId(User)!, null);
if (!releaseResult.Success)
return HandleVersionLifecycleFailure(pluginSlug, version, releaseResult);
TempData[TempDataConstant.SuccessMessage] = $"Version {version} released";
return RedirectToAction(nameof(Version), new { pluginSlug = pluginSlug.ToString(), version = version.ToString() });
}
}
[HttpGet("versions/{version}")]
public async Task<IActionResult> Version(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
[ModelBinder(typeof(PluginVersionModelBinder))]
PluginVersion version)
{
await using var conn = await connectionFactory.Open();
var buildId = conn.ExecuteScalar<long>("SELECT build_id FROM versions WHERE plugin_slug=@pluginSlug AND ver=@version",
new { pluginSlug = pluginSlug.ToString(), version = version.VersionParts });
return RedirectToAction(nameof(Build), new { pluginSlug = pluginSlug.ToString(), buildId });
}
[HttpGet("builds/{buildId}")]
public async Task<IActionResult> Build(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
long buildId,
bool openCompatibilityModal = false)
{
await using var conn = await connectionFactory.Open();
var row =
await conn
.QueryFirstOrDefaultAsync<(string manifest_info, string build_info, string state, DateTimeOffset created_at, bool published, bool pre_release,
string signatureproof, int[]? btcpay_min_ver, bool btcpay_min_ver_override_enabled,
int[]? btcpay_max_ver, bool btcpay_max_ver_override_enabled)>(
"SELECT manifest_info, build_info, state, created_at, v.ver IS NOT NULL, v.pre_release, v.signatureproof, v.btcpay_min_ver, " +
"v.btcpay_min_ver_override_enabled AS btcpay_min_ver_override_enabled, " +
"v.btcpay_max_ver, " +
"v.btcpay_max_ver_override_enabled AS btcpay_max_ver_override_enabled FROM builds b " +
"LEFT JOIN versions v ON b.plugin_slug=v.plugin_slug AND b.id=v.build_id " +
"WHERE b.plugin_slug=@pluginSlug AND id=@buildId " +
"LIMIT 1",
new { pluginSlug = pluginSlug.ToString(), buildId });
var logLines = await conn.QueryAsync<string>(
"SELECT logs FROM builds_logs " +
"WHERE plugin_slug=@pluginSlug AND build_id=@buildId " +
"ORDER BY created_at;",
new { pluginSlug = pluginSlug.ToString(), buildId });
var logs = string.Join("\r\n", logLines);
var pluginSetting = await conn.GetSettings(pluginSlug);
var signatureProof = SafeJson.Deserialize<SignatureProof>(row.signatureproof);
BuildViewModel vm = new();
var buildInfo = row.build_info is null ? null : BuildInfo.Parse(row.build_info);
var manifest = row.manifest_info is null ? null : PluginManifest.Parse(row.manifest_info);
vm.FullBuildId = new FullBuildId(pluginSlug, buildId);
vm.ManifestInfo = ManifestHelper.NiceJson(row.manifest_info, signatureProof?.Fingerprint);
vm.BuildInfo = buildInfo?.ToString(Formatting.Indented);
vm.DownloadLink = buildInfo?.Url;
vm.State = row.state;
vm.CreatedDate = (DateTimeOffset.UtcNow - row.created_at).ToTimeAgo();
vm.Commit = buildInfo?.GitCommit?.Substring(0, 8);
vm.Repository = buildInfo?.GitRepository;
vm.GitRef = buildInfo?.GitRef;
vm.Version = PluginVersionViewModel.CreateOrNull(manifest?.Version?.ToString(), row.published, row.pre_release, row.state, pluginSlug.ToString());
vm.RepositoryLink = GetUrl(buildInfo, gitHostingProviderFactory);
vm.DownloadLink = buildInfo?.Url;
//vm.Error = buildInfo?.Error;
vm.RequireGPGSignatureForRelease = pluginSetting?.RequireGPGSignatureForRelease ?? false;
vm.ManifestInfoSha256Hash = ManifestHelper.GetManifestHash(ManifestHelper.NiceJson(row.manifest_info), vm.RequireGPGSignatureForRelease);
vm.Published = row.published;
var effectiveManifestMinVersion = (manifest?.BTCPayMinVersion ?? PluginVersion.Zero).ToString();
var effectiveManifestMaxVersion = manifest?.BTCPayMaxVersion?.ToString();
vm.BTCPayMinVersion = row.btcpay_min_ver is { Length: > 0 } ? string.Join('.', row.btcpay_min_ver) : effectiveManifestMinVersion;
vm.BTCPayMaxVersion = row.btcpay_max_ver is { Length: > 0 } ? string.Join('.', row.btcpay_max_ver) : effectiveManifestMaxVersion;
vm.HasBTCPayCompatibilityOverride = row.btcpay_min_ver_override_enabled || row.btcpay_max_ver_override_enabled;
vm.CanEditBTCPayCompatibility = vm.Version is not null;
vm.OpenBTCPayCompatibilityModal = openCompatibilityModal;
if (logs != "")
vm.Logs = logs;
return View(vm);
}
[HttpPost("builds/{buildId}/btcpay-compatibility")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpdateVersionBTCPayCompatibility(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
long buildId,
[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.build_id = @buildId
LIMIT 1
""",
new { pluginSlug = pluginSlug.ToString(), buildId });
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(Build), new { pluginSlug = pluginSlug.ToString(), buildId, openCompatibilityModal = true });
}
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(Build), new { pluginSlug = pluginSlug.ToString(), buildId, openCompatibilityModal = true });
}
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(Build), new { pluginSlug = pluginSlug.ToString(), buildId, openCompatibilityModal = true });
}
}
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(Build), new { pluginSlug = pluginSlug.ToString(), buildId, openCompatibilityModal = true });
}
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 build_id = @buildId
""",
new
{
pluginSlug = pluginSlug.ToString(),
buildId,
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 reset to the manifest defaults."
: "Compatibility updated successfully.";
return RedirectToAction(nameof(Build), new { pluginSlug = pluginSlug.ToString(), buildId });
}
[HttpGet("")]
public async Task<IActionResult> Dashboard(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug)
{
await using var conn = await connectionFactory.Open();
var rows =
await conn
.QueryAsync<(long id, string state, string? manifest_info, string? build_info, DateTimeOffset created_at, bool published, bool pre_release)>
("SELECT id, state, manifest_info, build_info, created_at, v.ver IS NOT NULL, v.pre_release " +
"FROM builds b " +
"LEFT JOIN versions v ON b.plugin_slug=v.plugin_slug AND b.id=v.build_id " +
"WHERE b.plugin_slug = @pluginSlug " +
"ORDER BY id DESC " +
"LIMIT 50", new { pluginSlug = pluginSlug.ToString() });
BuildListViewModel vm = new();
foreach (var row in rows)
{
BuildListViewModel.BuildViewModel b = new();
var buildInfo = row.build_info is null ? null : BuildInfo.Parse(row.build_info);
var manifest = row.manifest_info is null ? null : PluginManifest.Parse(row.manifest_info);
vm.Builds.Add(b);
b.BuildId = row.id;
b.State = row.state;
b.Commit = buildInfo?.GitCommit?.Substring(0, 8);
b.Repository = buildInfo?.GitRepository;
b.GitRef = buildInfo?.GitRef;
b.Version = PluginVersionViewModel.CreateOrNull(manifest?.Version?.ToString(), row.published, row.pre_release, row.state, pluginSlug.ToString());
b.Date = (DateTimeOffset.UtcNow - row.created_at).ToTimeAgo();
b.RepositoryLink = GetUrl(buildInfo, gitHostingProviderFactory);
b.DownloadLink = buildInfo?.Url;
b.Error = buildInfo?.Error;
}
var pluginSettings = await conn.GetPluginDetails(pluginSlug);
return View(vm);
}
public static string? GetUrl(BuildInfo? buildInfo, GitHostingProviderFactory? providerFactory = null)
{
if (buildInfo?.GitRepository is not string repo || buildInfo?.GitCommit is not string commit)
return null;
if (providerFactory != null)
{
var provider = providerFactory.GetProvider(repo);
if (provider != null)
return provider.GetSourceUrl(repo, commit, buildInfo.PluginDir);
}
// Fallback for backward compatibility (git@ URLs, etc.)
string? repoName = null;
if (repo.StartsWith("git@github.com:", StringComparison.OrdinalIgnoreCase))
repoName = repo.Substring("git@github.com:".Length);
else if (repo.StartsWith("https://github.com/"))
repoName = repo.Substring("https://github.com/".Length);
if (repoName is null)
return null;
if (repoName.EndsWith(".git", StringComparison.OrdinalIgnoreCase))
repoName = repoName.Substring(0, repoName.Length - 4);
var link = $"https://github.com/{repoName}/tree/{commit}";
if (buildInfo?.PluginDir is string pluginDir)
link += $"/{pluginDir}";
return link;
}
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;
}
private IActionResult HandleVersionLifecycleFailure(PluginSlug pluginSlug, PluginVersion version, VersionLifecycleResult result)
{
if (result.FailureCode == VersionLifecycleFailureCode.NotFound)
return NotFound();
TempData[TempDataConstant.WarningMessage] = result.Message ?? "Version lifecycle operation failed";
return RedirectToAction(nameof(Version), new { pluginSlug = pluginSlug.ToString(), version = version.ToString() });
}
private static async Task<byte[]> ReadFormFileBytes(IFormFile file)
{
using var ms = new MemoryStream((int)file.Length);
await file.CopyToAsync(ms);
return ms.ToArray();
}
[HttpGet("owners")]
public async Task<IActionResult> Owners([ModelBinder(typeof(PluginSlugModelBinder))] PluginSlug pluginSlug)
{
var currentUserId = userManager.GetUserId(User) ?? throw new InvalidOperationException();
await using var conn = await connectionFactory.Open();
var owners = await conn.GetPluginOwners(pluginSlug);
var vm = new PluginOwnersPageViewModel
{
PluginSlug = pluginSlug.ToString(),
CurrentUserId = currentUserId,
IsPrimaryOwner = owners.Any(o => o.UserId == currentUserId && o.IsPrimary),
Owners = owners
};
return View(vm);
}
[HttpPost("owners")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AddOwner([ModelBinder(typeof(PluginSlugModelBinder))] PluginSlug pluginSlug, [FromForm] string email)
{
try
{
await using var conn = await connectionFactory.Open();
var primaryOwner = await conn.RetrievePluginPrimaryOwner(pluginSlug);
if (primaryOwner != userManager.GetUserId(User))
{
TempData[TempDataConstant.WarningMessage] = "Only primary owners can add new owners.";
return RedirectToAction(nameof(Owners), new { pluginSlug });
}
var result = await ownershipService.AddOwnerByEmailAsync(pluginSlug, email);
if (!result.Success)
{
TempData[TempDataConstant.WarningMessage] = result.Error;
return RedirectToAction(nameof(Owners), new { pluginSlug });
}
TempData[TempDataConstant.SuccessMessage] = "User added.";
return RedirectToAction(nameof(Owners), new { pluginSlug });
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error while adding owner");
TempData[TempDataConstant.WarningMessage] = "Unexpected error while adding owner";
return RedirectToAction(nameof(Owners), new { pluginSlug });
}
}
[HttpPost("owners/{userId}/transfer-primary-owner")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> TransferPrimaryOwner(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
string userId)
{
try
{
await using var conn = await connectionFactory.Open();
var currentPrimaryId = await conn.RetrievePluginPrimaryOwner(pluginSlug);
if (currentPrimaryId != userManager.GetUserId(User))
{
TempData[TempDataConstant.WarningMessage] = "Only the primary owner can transfer primary.";
return RedirectToAction(nameof(Owners), new { pluginSlug });
}
var result = await ownershipService.TransferPrimaryAsync(pluginSlug, userId);
if (!result.Success)
{
TempData[TempDataConstant.WarningMessage] = result.Error;
return RedirectToAction(nameof(Owners), new { pluginSlug });
}
TempData[TempDataConstant.SuccessMessage] = "Primary owner transferred.";
return RedirectToAction(nameof(Owners), new { pluginSlug });
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error while transferring primary owner");
TempData[TempDataConstant.WarningMessage] = "Unexpected error while transferring primary owner";
return RedirectToAction(nameof(Owners), new { pluginSlug });
}
}
[HttpPost("owners/{userId}/remove")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> RemoveOwner(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
string userId)
{
try
{
var currentUserId = userManager.GetUserId(User);
var isSelfRemoval = string.Equals(currentUserId, userId, StringComparison.Ordinal);
var result = await ownershipService.RemoveOwnerAsync(
pluginSlug,
userId,
currentUserId,
false);
if (!result.Success)
{
TempData[TempDataConstant.WarningMessage] = result.Error;
return RedirectToAction(nameof(Owners), new { pluginSlug });
}
TempData[TempDataConstant.SuccessMessage] = isSelfRemoval ? "You have left plugin ownership." : "Owner removed.";
if (isSelfRemoval)
return RedirectToAction(nameof(HomeController.Dashboard), "Home");
return RedirectToAction(nameof(Owners), new { pluginSlug });
}
catch (Exception ex)
{
logger.LogError(ex, "Unexpected error while removing owner");
TempData[TempDataConstant.WarningMessage] = "Unexpected error while removing owner";
return RedirectToAction(nameof(Owners), new { pluginSlug });
}
}
}