Revert Merge pull request #89 from btcpayserver/gpg_key_encryption

This commit is contained in:
rockstardev 2025-10-16 09:36:55 -05:00
parent 86270bf8fc
commit 87bb60ebed
No known key found for this signature in database
GPG Key ID: 4F224698945A6EE7
20 changed files with 136 additions and 557 deletions

View File

@ -39,7 +39,6 @@ public class PublicDirectoryUITests(ITestOutputHelper output) : PageTest
// Listed should be visible
await conn.SetPluginSettingsAndVisibility(slug, "{}", "listed");
await tester.GoToUrl("/public/plugins");
await tester.Page!.WaitForSelectorAsync("a[href='/public/plugins/rockstar-stylist']");
Assert.True(await tester.Page.Locator("a[href='/public/plugins/rockstar-stylist']").IsVisibleAsync());
@ -68,7 +67,7 @@ public class PublicDirectoryUITests(ITestOutputHelper output) : PageTest
await tester.Page.WaitForSelectorAsync("a[href='/public/plugins/rockstar-stylist']", new PageWaitForSelectorOptions { State = WaitForSelectorState.Hidden });
Assert.False(await tester.Page.Locator("a[href='/public/plugins/rockstar-stylist']").IsVisibleAsync());
// Try to access hidden plugin page without being owner or admin
// Public page should not be accessible if hidden and owner is not logged in
var response = await tester.GoToUrl("/public/plugins/rockstar-stylist");
Assert.Equal(404, response?.Status);

View File

@ -102,14 +102,13 @@ public class UnitTest1 : UnitTestBase
Assert.Equal("rockstar-stylist", version.ProjectSlug);
// Let's see what happen if there is two versions of the same plugin
await conn.ExecuteAsync("INSERT INTO versions VALUES('rockstar-stylist', ARRAY[1,0,2,1], 0, ARRAY[1,4,6,0], 'f', CURRENT_TIMESTAMP, NULL)");
await conn.ExecuteAsync("INSERT INTO versions VALUES('rockstar-stylist', ARRAY[1,0,2,1], 0, ARRAY[1,4,6,0], 'f', CURRENT_TIMESTAMP)");
versions = await client.GetPublishedVersions("1.4.6.0", true);
version = Assert.Single(versions);
Assert.Equal("1.0.2.1", version.Version);
versions = await client.GetPublishedVersions("1.4.6.0", true, true);
Assert.Equal("1.0.2.1", versions[1].Version);
Assert.Equal("1.0.2.0", versions[0].Version);
Assert.Equal("1.0.2.1", versions[0].Version);
Assert.Equal("1.0.2.0", versions[1].Version);
// listed - always render
await conn.ExecuteAsync("UPDATE plugins SET visibility = 'listed' WHERE slug = 'rockstar-stylist'");

View File

@ -12,7 +12,6 @@ namespace PluginBuilder.Controllers;
[Authorize]
[Route("/account/")]
public class AccountController(
GPGKeyService _gpgService,
DBConnectionFactory connectionFactory,
UserManager<IdentityUser> userManager,
ExternalAccountVerificationService externalAccountVerificationService,
@ -71,7 +70,7 @@ public class AccountController(
{
if (!ModelState.IsValid) return View(model);
var user = await userManager.GetUserAsync(User);
var user = await userManager.GetUserAsync(User)!;
await using var conn = await connectionFactory.Open();
var accountSettings = await conn.GetAccountDetailSettings(user!.Id) ?? new AccountSettings();
@ -79,21 +78,7 @@ public class AccountController(
accountSettings.Nostr = model.Settings.Nostr;
accountSettings.Twitter = model.Settings.Twitter;
accountSettings.Email = model.Settings.Email;
if (!string.IsNullOrEmpty(model.Settings.GPGKey?.PublicKey))
{
var isPublicKeyValid = _gpgService.ValidateArmouredPublicKey(model.Settings.GPGKey.PublicKey.Trim(), out var message, out var keyViewModel);
if (!isPublicKeyValid)
{
TempData[TempDataConstant.WarningMessage] = $"GPG Key is not valid: {message}";
return View(model);
}
accountSettings.GPGKey = keyViewModel;
}
else
{
accountSettings.GPGKey = new();
}
await conn.SetAccountDetailSettings(accountSettings, user.Id);
await conn.SetAccountDetailSettings(accountSettings, user!.Id);
TempData[TempDataConstant.SuccessMessage] = "Account details updated successfully";
return RedirectToAction(nameof(AccountDetails));

View File

@ -5,7 +5,6 @@ using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Npgsql;
using PluginBuilder.Configuration;
using PluginBuilder.Controllers.Logic;
@ -92,7 +91,6 @@ public class AdminController(
model.Plugins = plugins;
model.VerifiedEmailForPluginPublish = await conn.GetVerifiedEmailForPluginPublishSetting();
model.VerifiedGPGSignatureForPluginRelease = await conn.RequiresGPGSignatureForPluginRelease();
return View(model);
}
@ -100,7 +98,7 @@ public class AdminController(
public async Task<IActionResult> UpdateVerifiedEmailForPublishRequirement(bool verifiedEmailForPluginPublish)
{
await using var conn = await connectionFactory.Open();
await conn.UpdatePluginAdminSettings(SettingsKeys.VerifiedEmailForPluginPublish, verifiedEmailForPluginPublish);
await conn.UpdateVerifiedEmailForPluginPublishSetting(verifiedEmailForPluginPublish);
await userVerifiedCache.RefreshIsVerifiedEmailRequiredForPublish(conn);
TempData[TempDataConstant.SuccessMessage] = "Email requirement setting for publishing plugin updated successfully";
return RedirectToAction("ListPlugins");

View File

@ -46,7 +46,6 @@ public class UserVerifiedLogic(
public class UserVerifiedCache
{
public bool IsEmailVerificationRequiredForPublish { get; private set; }
public bool IsGPGSignatureRequiredForRelease { get; private set; }
public bool IsEmailVerificationRequiredForLogin { get; private set; }
public bool IsGithubVerificationRequired { get; private set; }
@ -56,11 +55,6 @@ public class UserVerifiedCache
IsEmailVerificationRequiredForPublish = await conn.GetVerifiedEmailForPluginPublishSetting();
}
public async Task RefreshIsGPGSignatureRequirementForRelease(NpgsqlConnection conn)
{
IsGPGSignatureRequiredForRelease = await conn.RequiresGPGSignatureForPluginRelease();
}
public async Task RefreshIsVerifiedEmailRequiredForLogin(NpgsqlConnection conn)
{
IsEmailVerificationRequiredForLogin = await conn.GetVerifiedEmailForLoginSetting();
@ -75,7 +69,6 @@ public class UserVerifiedCache
public async Task RefreshAllUserVerifiedSettings(NpgsqlConnection conn)
{
await RefreshIsVerifiedEmailRequiredForPublish(conn);
await RefreshIsGPGSignatureRequirementForRelease(conn);
await RefreshIsVerifiedEmailRequiredForLogin(conn);
await RefreshIsVerifiedGithubRequired(conn);
}

View File

@ -1,4 +1,3 @@
using System.Text;
using Dapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
@ -22,7 +21,6 @@ public class PluginController(
DBConnectionFactory connectionFactory,
UserManager<IdentityUser> userManager,
BuildService buildService,
GPGKeyService gpgKeyService,
AzureStorageClient azureStorageClient,
UserVerifiedLogic userVerifiedLogic,
FirstBuildEvent firstBuildEvent,
@ -195,58 +193,32 @@ public class PluginController(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
[ModelBinder(typeof(PluginVersionModelBinder))]
PluginVersion version, string command, IFormFile? signatureFile)
PluginVersion version, string command)
{
await using var conn = await connectionFactory.Open();
var pluginBuild = await conn.QueryFirstOrDefaultAsync<(long buildId, string identifier)>(
if (command == "remove")
{
var pluginBuild = await conn.QueryFirstOrDefaultAsync<(long buildId, string identifier)>(
"SELECT v.build_id, p.identifier FROM versions v JOIN plugins p ON v.plugin_slug = p.slug WHERE plugin_slug=@pluginSlug AND ver=@version",
new { pluginSlug = pluginSlug.ToString(), version = version.VersionParts });
var requireGPGSignature = await conn.RequiresGPGSignatureForPluginRelease();
switch (command)
{
case "remove":
FullBuildId fullBuildId = new(pluginSlug, pluginBuild.buildId);
await conn.ExecuteAsync("DELETE FROM versions WHERE plugin_slug=@pluginSlug AND ver=@version",
new { pluginSlug = pluginSlug.ToString(), version = version.VersionParts });
await buildService.UpdateBuild(fullBuildId, BuildStates.Removed, null);
return RedirectToAction(nameof(Build), new { pluginSlug = pluginSlug.ToString(), pluginBuild.buildId });
case "sign_release":
var build_info = await conn.QueryFirstOrDefaultAsync<string>("SELECT build_info FROM builds b WHERE b.plugin_slug=@pluginSlug AND b.id=@buildId LIMIT 1",
new { pluginSlug = pluginSlug.ToString(), pluginBuild.buildId });
if (build_info is null || BuildInfo.Parse(build_info) is not { } buildInfo || string.IsNullOrEmpty(buildInfo.GitCommit))
{
TempData[TempDataConstant.WarningMessage] = "Build information for plugin not available";
return RedirectToAction(nameof(Version), new { pluginSlug = pluginSlug.ToString(), version = version.ToString() });
}
var rawSignedBytes = Encoding.UTF8.GetBytes(buildInfo.GitCommit);
var signatureVerification = await gpgKeyService.VerifyDetachedSignature(pluginSlug.ToString(), userManager.GetUserId(User)!,
rawSignedBytes, signatureFile);
if (!signatureVerification.valid)
{
TempData[TempDataConstant.WarningMessage] = signatureVerification.message;
return RedirectToAction(nameof(Version), new { pluginSlug = pluginSlug.ToString(), version = version.ToString() });
}
await conn.UpdateVersionReleaseStatus(pluginSlug, command, version, signatureVerification.proof);
break;
default:
if (requireGPGSignature && command == "release")
{
TempData[TempDataConstant.WarningMessage] = "A verified GPG signature is required to release this version";
return RedirectToAction(nameof(Version), new { pluginSlug = pluginSlug.ToString(), version = version.ToString() });
}
await conn.UpdateVersionReleaseStatus(pluginSlug, command, version);
break;
FullBuildId fullBuildId = new(pluginSlug, pluginBuild.buildId);
await conn.ExecuteAsync("DELETE FROM versions WHERE plugin_slug=@pluginSlug AND ver=@version",
new { pluginSlug = pluginSlug.ToString(), version = version.VersionParts });
await buildService.UpdateBuild(fullBuildId, BuildStates.Removed, null);
return RedirectToAction(nameof(Build), new { pluginSlug = pluginSlug.ToString(), pluginBuild.buildId });
}
// Email notifications are now handled on first build creation, not on release.
await conn.ExecuteAsync("UPDATE versions SET pre_release=@preRelease WHERE plugin_slug=@pluginSlug AND ver=@version",
new
{
pluginSlug = pluginSlug.ToString(),
version = version.VersionParts,
preRelease = command == "unrelease"
});
TempData[TempDataConstant.SuccessMessage] =
$"Version {version} {(command is "release" or "sign_release" ? "released" : "unreleased")}";
$"Version {version} {(command == "release" ? "released" : "unreleased")}";
return RedirectToAction(nameof(Version), new { pluginSlug = pluginSlug.ToString(), version = version.ToString() });
}
@ -272,8 +244,8 @@ public class PluginController(
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)>(
"SELECT manifest_info, build_info, state, created_at, v.ver IS NOT NULL, v.pre_release, v.signatureproof FROM builds b " +
.QueryFirstOrDefaultAsync<(string manifest_info, string build_info, string state, DateTimeOffset created_at, bool published, bool pre_release)>(
"SELECT manifest_info, build_info, state, 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 AND id=@buildId " +
"LIMIT 1",
@ -284,25 +256,21 @@ public class PluginController(
"ORDER BY created_at;",
new { pluginSlug = pluginSlug.ToString(), buildId });
var logs = string.Join("\r\n", logLines);
var signatureProof = string.IsNullOrWhiteSpace(row.signatureproof)
? new SignatureProof() : JsonConvert.DeserializeObject<SignatureProof>(row.signatureproof, CamelCaseSerializerSettings.Instance) ?? new 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 = NiceJson(row.manifest_info, signatureProof?.Fingerprint);
vm.ManifestInfo = NiceJson(row.manifest_info);
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;
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);
vm.DownloadLink = buildInfo?.Url;
vm.RequireGPGSignatureForRelease = await conn.RequiresGPGSignatureForPluginRelease();
//vm.Error = buildInfo?.Error;
vm.Published = row.published;
//var buildId = await conn.NewBuild(pluginSlug);
@ -312,14 +280,12 @@ public class PluginController(
return View(vm);
}
private string? NiceJson(string? json, string? fingerprint = null)
private string? NiceJson(string? json)
{
if (json is null)
return null;
var data = JObject.Parse(json);
data = new JObject(data.Properties().OrderBy(p => p.Name));
if (!string.IsNullOrWhiteSpace(fingerprint))
data["SignatureFingerprint"] = fingerprint;
return data.ToString(Formatting.Indented);
}

View File

@ -1,2 +0,0 @@
ALTER TABLE versions
ADD COLUMN signatureproof JSONB NULL;

View File

@ -1,5 +1,4 @@
using System.ComponentModel.DataAnnotations;
using PluginBuilder.ViewModels;
namespace PluginBuilder.DataModels;
@ -17,5 +16,4 @@ public class AccountSettings
[Display(Name = "Public Email address")]
public string? Email { get; set; }
public string? PendingNewEmail { get; set; }
public PgpKeyViewModel? GPGKey { get; set; }
}

View File

@ -5,7 +5,6 @@ public static class SettingsKeys
{
public const string EmailSettings = nameof(EmailSettings);
public const string VerifiedEmailForPluginPublish = nameof(VerifiedEmailForPluginPublish);
public const string VerifiedGPGSignatureForPluginRelease = nameof(VerifiedGPGSignatureForPluginRelease);
public const string VerifiedEmailForLogin = nameof(VerifiedEmailForLogin);
public const string FirstPluginBuildReviewers = nameof(FirstPluginBuildReviewers);
public const string VerifiedGithub = nameof(VerifiedGithub);

View File

@ -14,102 +14,102 @@
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.35" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.10" />
<PackageReference Include="MailKit" Version="4.8.0" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.10" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.10" />
<PackageReference Include="Dapper" Version="2.1.35"/>
<PackageReference Include="Microsoft.AspNetCore.SignalR.Common" Version="8.0.10"/>
<PackageReference Include="MailKit" Version="4.8.0"/>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="8.0.10"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="8.0.10"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.10"/>
<PackageReference Include="Microsoft.SourceLink.GitHub" Version="8.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Npgsql" Version="8.0.4" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.3"/>
<PackageReference Include="Npgsql" Version="8.0.4"/>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="8.0.8"/>
<PackageReference Include="Serilog" Version="4.3.0" />
<PackageReference Include="Serilog.Extensions.Logging" Version="9.0.2" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="WindowsAzure.Storage" Version="9.3.3" />
<PackageReference Include="WindowsAzure.Storage" Version="9.3.3"/>
</ItemGroup>
<PropertyGroup>
<DisableScopedCssBundling>true</DisableScopedCssBundling>
</PropertyGroup>
<ItemGroup>
<EmbeddedResource Include="Data\Scripts\*.sql" />
<EmbeddedResource Include="Data\Scripts\*.sql"/>
</ItemGroup>
<ItemGroup>
<None Remove="Data\Scripts\01.migrations.sql" />
<None Remove="Data\Scripts\02.AspNetIdentity.sql" />
<None Remove="Data\Scripts\03.Init.sql" />
<None Remove="Data\Scripts\04.FixGetLatestVersions.sql" />
<None Remove="Data\Scripts\05.ProtectPluginIdentifier.sql" />
<None Remove="Data\Scripts\06.Events.sql" />
<None Remove="Data\Scripts\07.Settings.sql" />
<None Remove="Data\Scripts\01.migrations.sql"/>
<None Remove="Data\Scripts\02.AspNetIdentity.sql"/>
<None Remove="Data\Scripts\03.Init.sql"/>
<None Remove="Data\Scripts\04.FixGetLatestVersions.sql"/>
<None Remove="Data\Scripts\05.ProtectPluginIdentifier.sql"/>
<None Remove="Data\Scripts\06.Events.sql"/>
<None Remove="Data\Scripts\07.Settings.sql"/>
</ItemGroup>
<ItemGroup>
<None Include="Components\Icon\Default.cshtml" />
<None Include="wwwroot\fonts\montserrat-v14-latin-ext_latin-300.woff2" />
<None Include="wwwroot\fonts\montserrat-v14-latin-ext_latin-300italic.woff2" />
<None Include="wwwroot\fonts\montserrat-v14-latin-ext_latin-700.woff2" />
<None Include="wwwroot\fonts\montserrat-v14-latin-ext_latin-700italic.woff2" />
<None Include="wwwroot\fonts\montserrat-v14-latin-ext_latin-italic.woff2" />
<None Include="wwwroot\fonts\montserrat-v14-latin-ext_latin-regular.woff2" />
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-300.woff2" />
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-300italic.woff2" />
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-600.woff2" />
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-600italic.woff2" />
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-700.woff2" />
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-700italic.woff2" />
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-800.woff2" />
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-800italic.woff2" />
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-italic.woff2" />
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-regular.woff2" />
<None Include="wwwroot\fonts\roboto-mono-v12-vietnamese_latin-ext_latin_greek_cyrillic-ext_cyrillic-regular.woff2" />
<None Include="wwwroot\fonts\roboto-v20-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-500.woff2" />
<None Include="wwwroot\fonts\roboto-v20-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-500italic.woff2" />
<None Include="wwwroot\fonts\roboto-v20-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.woff2" />
<None Include="wwwroot\fonts\roboto-v20-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.woff2" />
<None Include="wwwroot\fonts\roboto-v20-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.woff2" />
<None Include="wwwroot\fonts\roboto-v20-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.woff2" />
<None Include="wwwroot\vendor\bootstrap\bootstrap.bundle.min.js" />
<None Include="wwwroot\vendor\bootstrap\bootstrap.bundle.min.js.map" />
<None Include="wwwroot\vendor\font-awesome\fonts\fontawesome-webfont.svg" />
<None Include="wwwroot\vendor\font-awesome\fonts\fontawesome-webfont.woff2" />
<None Include="wwwroot\vendor\font-awesome\less\animated.less" />
<None Include="wwwroot\vendor\font-awesome\less\bordered-pulled.less" />
<None Include="wwwroot\vendor\font-awesome\less\core.less" />
<None Include="wwwroot\vendor\font-awesome\less\fixed-width.less" />
<None Include="wwwroot\vendor\font-awesome\less\font-awesome.less" />
<None Include="wwwroot\vendor\font-awesome\less\icons.less" />
<None Include="wwwroot\vendor\font-awesome\less\larger.less" />
<None Include="wwwroot\vendor\font-awesome\less\list.less" />
<None Include="wwwroot\vendor\font-awesome\less\mixins.less" />
<None Include="wwwroot\vendor\font-awesome\less\path.less" />
<None Include="wwwroot\vendor\font-awesome\less\rotated-flipped.less" />
<None Include="wwwroot\vendor\font-awesome\less\screen-reader.less" />
<None Include="wwwroot\vendor\font-awesome\less\stacked.less" />
<None Include="wwwroot\vendor\font-awesome\less\variables.less" />
<None Include="wwwroot\vendor\font-awesome\scss\font-awesome.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_animated.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_bordered-pulled.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_core.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_fixed-width.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_icons.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_larger.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_list.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_mixins.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_path.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_rotated-flipped.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_screen-reader.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_stacked.scss" />
<None Include="wwwroot\vendor\font-awesome\scss\_variables.scss" />
<None Include="wwwroot\vendor\jquery\jquery.js" />
<None Include="wwwroot\vendor\jquery\jquery.min.js" />
<None Include="Components\Icon\Default.cshtml"/>
<None Include="wwwroot\fonts\montserrat-v14-latin-ext_latin-300.woff2"/>
<None Include="wwwroot\fonts\montserrat-v14-latin-ext_latin-300italic.woff2"/>
<None Include="wwwroot\fonts\montserrat-v14-latin-ext_latin-700.woff2"/>
<None Include="wwwroot\fonts\montserrat-v14-latin-ext_latin-700italic.woff2"/>
<None Include="wwwroot\fonts\montserrat-v14-latin-ext_latin-italic.woff2"/>
<None Include="wwwroot\fonts\montserrat-v14-latin-ext_latin-regular.woff2"/>
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-300.woff2"/>
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-300italic.woff2"/>
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-600.woff2"/>
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-600italic.woff2"/>
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-700.woff2"/>
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-700italic.woff2"/>
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-800.woff2"/>
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-800italic.woff2"/>
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-italic.woff2"/>
<None Include="wwwroot\fonts\open-sans-v17-latin-ext_latin-regular.woff2"/>
<None Include="wwwroot\fonts\roboto-mono-v12-vietnamese_latin-ext_latin_greek_cyrillic-ext_cyrillic-regular.woff2"/>
<None Include="wwwroot\fonts\roboto-v20-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-500.woff2"/>
<None Include="wwwroot\fonts\roboto-v20-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-500italic.woff2"/>
<None Include="wwwroot\fonts\roboto-v20-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700.woff2"/>
<None Include="wwwroot\fonts\roboto-v20-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-700italic.woff2"/>
<None Include="wwwroot\fonts\roboto-v20-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-italic.woff2"/>
<None Include="wwwroot\fonts\roboto-v20-vietnamese_latin-ext_latin_greek-ext_greek_cyrillic-ext_cyrillic-regular.woff2"/>
<None Include="wwwroot\vendor\bootstrap\bootstrap.bundle.min.js"/>
<None Include="wwwroot\vendor\bootstrap\bootstrap.bundle.min.js.map"/>
<None Include="wwwroot\vendor\font-awesome\fonts\fontawesome-webfont.svg"/>
<None Include="wwwroot\vendor\font-awesome\fonts\fontawesome-webfont.woff2"/>
<None Include="wwwroot\vendor\font-awesome\less\animated.less"/>
<None Include="wwwroot\vendor\font-awesome\less\bordered-pulled.less"/>
<None Include="wwwroot\vendor\font-awesome\less\core.less"/>
<None Include="wwwroot\vendor\font-awesome\less\fixed-width.less"/>
<None Include="wwwroot\vendor\font-awesome\less\font-awesome.less"/>
<None Include="wwwroot\vendor\font-awesome\less\icons.less"/>
<None Include="wwwroot\vendor\font-awesome\less\larger.less"/>
<None Include="wwwroot\vendor\font-awesome\less\list.less"/>
<None Include="wwwroot\vendor\font-awesome\less\mixins.less"/>
<None Include="wwwroot\vendor\font-awesome\less\path.less"/>
<None Include="wwwroot\vendor\font-awesome\less\rotated-flipped.less"/>
<None Include="wwwroot\vendor\font-awesome\less\screen-reader.less"/>
<None Include="wwwroot\vendor\font-awesome\less\stacked.less"/>
<None Include="wwwroot\vendor\font-awesome\less\variables.less"/>
<None Include="wwwroot\vendor\font-awesome\scss\font-awesome.scss"/>
<None Include="wwwroot\vendor\font-awesome\scss\_animated.scss"/>
<None Include="wwwroot\vendor\font-awesome\scss\_bordered-pulled.scss"/>
<None Include="wwwroot\vendor\font-awesome\scss\_core.scss"/>
<None Include="wwwroot\vendor\font-awesome\scss\_fixed-width.scss"/>
<None Include="wwwroot\vendor\font-awesome\scss\_icons.scss"/>
<None Include="wwwroot\vendor\font-awesome\scss\_larger.scss"/>
<None Include="wwwroot\vendor\font-awesome\scss\_list.scss"/>
<None Include="wwwroot\vendor\font-awesome\scss\_mixins.scss"/>
<None Include="wwwroot\vendor\font-awesome\scss\_path.scss"/>
<None Include="wwwroot\vendor\font-awesome\scss\_rotated-flipped.scss"/>
<None Include="wwwroot\vendor\font-awesome\scss\_screen-reader.scss"/>
<None Include="wwwroot\vendor\font-awesome\scss\_stacked.scss"/>
<None Include="wwwroot\vendor\font-awesome\scss\_variables.scss"/>
<None Include="wwwroot\vendor\jquery\jquery.js"/>
<None Include="wwwroot\vendor\jquery\jquery.min.js"/>
</ItemGroup>
<ItemGroup>
@ -122,7 +122,7 @@
</ItemGroup>
<ItemGroup>
<Folder Include="wwwroot\vendor\highlight.js\" />
<Folder Include="wwwroot\vendor\signalr\" />
<Folder Include="wwwroot\vendor\highlight.js\"/>
<Folder Include="wwwroot\vendor\signalr\"/>
</ItemGroup>
</Project>

View File

@ -174,7 +174,6 @@ public class Program
services.AddSingleton<AzureStorageClient>();
services.AddSingleton<ServerEnvironment>();
services.AddSingleton<EventAggregator>();
services.AddSingleton<GPGKeyService>();
services.AddHttpClient();
services.AddSingleton<ExternalAccountVerificationService>();
services.AddSingleton<EmailService>();

View File

@ -1,166 +0,0 @@
using System.Text;
using Newtonsoft.Json;
using Org.BouncyCastle.Bcpg;
using Org.BouncyCastle.Bcpg.OpenPgp;
using PluginBuilder.DataModels;
using PluginBuilder.Util;
using PluginBuilder.Util.Extensions;
using PluginBuilder.ViewModels;
namespace PluginBuilder.Services;
public class GPGKeyService(DBConnectionFactory connectionFactory)
{
public bool ValidateArmouredPublicKey(string publicKey, out string message, out PgpKeyViewModel? vm)
{
publicKey = publicKey.Trim();
vm = null;
if (publicKey.Contains("-----BEGIN PGP PRIVATE KEY BLOCK-----", StringComparison.OrdinalIgnoreCase))
{
message = "Private key block detected; upload only the public key";
return false;
}
try
{
using var publicKeyStream = new MemoryStream(Encoding.ASCII.GetBytes(publicKey));
using var decoderStream = PgpUtilities.GetDecoderStream(publicKeyStream);
var keyRingBundle = new PgpPublicKeyRingBundle(decoderStream);
PgpPublicKey? key = null;
foreach (PgpPublicKeyRing keyRing in keyRingBundle.GetKeyRings())
{
foreach (PgpPublicKey k in keyRing.GetPublicKeys())
{
if (k.GetSignatures().All(sig => ((sig.GetHashedSubPackets()?.GetKeyFlags()) & PgpKeyFlags.CanSign) == 0)) continue;
key = k;
break;
}
if (key != null)
break;
}
if (key == null)
{
message = "Signing key is required";
return false;
}
bool canSign = key.Algorithm switch
{
PublicKeyAlgorithmTag.RsaGeneral or
PublicKeyAlgorithmTag.RsaSign or
PublicKeyAlgorithmTag.Dsa or
PublicKeyAlgorithmTag.ECDsa or
PublicKeyAlgorithmTag.EdDsa => true,
_ => false
};
if (!canSign)
{
message = "Public key provided does not support signing";
return false;
}
if (key.GetValidSeconds() != 0 && key.CreationTime.AddSeconds(key.GetValidSeconds()) <= DateTimeOffset.UtcNow)
{
message = "Key has expired";
return false;
}
if (key.IsRevoked() || key.GetSignatures().Any(sig => sig.SignatureType == PgpSignature.KeyRevocation))
{
message = "Key is revoked";
return false;
}
vm = new PgpKeyViewModel
{
KeyId = key.KeyId.ToString("X"),
Fingerprint = BitConverter.ToString(key.GetFingerprint()).Replace("-", ""),
PublicKey = publicKey,
CreatedDate = key.CreationTime,
AddedDate = DateTimeOffset.UtcNow,
ValidDays = key.GetValidSeconds() == 0 ? -1 : (long)(key.CreationTime.AddSeconds(key.GetValidSeconds()) - key.CreationTime).TotalDays,
Version = key.Version
};
message = "Public Key validated successfully";
return true;
}
catch
{
message = "An error occurred while validating public key";
return false;
}
}
public async Task<SignatureProofResponse> VerifyDetachedSignature(string pluginslug, string userId, byte[] rawSignedBytes, IFormFile? signatureFile)
{
try
{
if (signatureFile is not { Length: > 0 })
return new SignatureProofResponse(false, "Please upload a valid GPG signature file (.asc)");
string signatureText;
using (var reader = new StreamReader(signatureFile.OpenReadStream()))
signatureText = await reader.ReadToEndAsync();
var publicKey = await GetPluginOwnerPublicKeys(pluginslug, userId);
if (string.IsNullOrEmpty(publicKey))
{
return new SignatureProofResponse(false, "No public keys found for this user. Kindly update your account profile with your GPG public key");
}
await using Stream pubIn = new MemoryStream(Encoding.ASCII.GetBytes(publicKey));
PgpPublicKeyRingBundle pubBundle = new PgpPublicKeyRingBundle(PgpUtilities.GetDecoderStream(pubIn));
await using Stream sigIn = new MemoryStream(Encoding.ASCII.GetBytes(signatureText));
PgpObjectFactory sigFact = new PgpObjectFactory(PgpUtilities.GetDecoderStream(sigIn));
PgpSignatureList sigList = (PgpSignatureList)sigFact.NextPgpObject();
if (sigList.Count <= 0)
{
return new SignatureProofResponse(false, "No signature found in armoured file uploaded");
}
PgpSignature signature = sigList[0];
PgpPublicKey signingKey = pubBundle.GetPublicKey(signature.KeyId);
if (signingKey == null)
{
return new SignatureProofResponse(false, "File was signed with a key not associated with the user's public key");
}
signature.InitVerify(signingKey);
signature.Update(rawSignedBytes);
bool ok = signature.Verify();
if (!ok)
{
return new SignatureProofResponse(false, "Unable to verify signature. Commit mismatch");
}
var signatureProof = new SignatureProof
{
Armour = signatureText,
KeyId = signature.KeyId.ToString("X"),
Fingerprint = BitConverter.ToString(signingKey.GetFingerprint()).Replace("-", ""),
SignedAt = signature.CreationTime,
VerifiedAt = DateTimeOffset.UtcNow
};
return new SignatureProofResponse(true, "Signature verified successfully", signatureProof);
}
catch (Exception ex)
{
return new SignatureProofResponse(false, $"Verification failed: {ex.Message}");
}
}
private async Task<string> GetPluginOwnerPublicKeys(string pluginSlug, string userId)
{
await using var conn = await connectionFactory.Open();
var pluginOwners = await conn.GetPluginOwners(pluginSlug);
if (pluginOwners == null || pluginOwners.Count != 0 == false) return string.Empty;
var owner = pluginOwners.FirstOrDefault(o => o.UserId == userId);
if (owner == null || string.IsNullOrEmpty(owner.AccountDetail)) return string.Empty;
var accountSettings = JsonConvert.DeserializeObject<AccountSettings>(owner.AccountDetail, CamelCaseSerializerSettings.Instance);
if (accountSettings?.GPGKey == null || string.IsNullOrEmpty(accountSettings.GPGKey.PublicKey)) return string.Empty;
return accountSettings.GPGKey.PublicKey;
}
}

View File

@ -4,7 +4,6 @@ using Newtonsoft.Json.Linq;
using Npgsql;
using PluginBuilder.DataModels;
using PluginBuilder.Services;
using PluginBuilder.ViewModels;
using PluginBuilder.ViewModels.Plugin;
namespace PluginBuilder.Util.Extensions;
@ -151,9 +150,8 @@ public static class NpgsqlConnectionExtensions
const string sql = """
SELECT
u."Id" AS "UserId",
up.is_primary_owner AS "IsPrimary",
u."Email",
u."AccountDetail"
u."Email" AS "Email",
up.is_primary_owner AS "IsPrimary"
FROM users_plugins up
JOIN "AspNetUsers" u ON u."Id" = up.user_id
WHERE up.plugin_slug = @slug
@ -199,22 +197,6 @@ public static class NpgsqlConnectionExtensions
});
}
public static async Task<bool> UpdateVersionReleaseStatus(this NpgsqlConnection connection, PluginSlug pluginSlug, string command, PluginVersion version, SignatureProof? signatureProof = null)
{
var updated = await connection.ExecuteAsync(
"UPDATE versions SET pre_release = @preRelease, signatureproof = CASE WHEN @hasSignature THEN @signatureproof::JSONB WHEN @preRelease THEN NULL ELSE signatureproof END " +
"WHERE plugin_slug = @pluginSlug AND ver = @version",
new
{
signatureproof = signatureProof != null ? JsonConvert.SerializeObject(signatureProof, CamelCaseSerializerSettings.Instance) : null,
hasSignature = signatureProof != null,
pluginSlug = pluginSlug.ToString(),
version = version.VersionParts,
preRelease = command == "unrelease"
});
return updated == 1;
}
public static async Task<PluginSlug[]> GetPluginsByUserId(this NpgsqlConnection connection, string userId)
{
return (await connection.QueryAsync<string>(
@ -381,12 +363,6 @@ public static class NpgsqlConnectionExtensions
"INSERT INTO settings (key, value) VALUES (@key, @value)",
new { key = SettingsKeys.VerifiedEmailForPluginPublish, value = "true" });
}
if (result.All(r => r.key != SettingsKeys.VerifiedGPGSignatureForPluginRelease))
{
await connection.ExecuteAsync(
"INSERT INTO settings (key, value) VALUES (@key, @value)",
new { key = SettingsKeys.VerifiedGPGSignatureForPluginRelease, value = "false" });
}
if (result.All(r => r.key != SettingsKeys.VerifiedEmailForLogin))
{
await connection.ExecuteAsync(
@ -408,17 +384,11 @@ public static class NpgsqlConnectionExtensions
return bool.TryParse(settingValue, out var result) && result;
}
public static async Task<bool> RequiresGPGSignatureForPluginRelease(this NpgsqlConnection connection)
{
var settingValue = await SettingsGetAsync(connection, SettingsKeys.VerifiedGPGSignatureForPluginRelease);
return bool.TryParse(settingValue, out var result) && result;
}
public static async Task UpdatePluginAdminSettings(this NpgsqlConnection connection, string settingKey, bool newValue)
public static async Task UpdateVerifiedEmailForPluginPublishSetting(this NpgsqlConnection connection, bool newValue)
{
var stringValue = newValue.ToString().ToLowerInvariant();
await connection.ExecuteAsync("UPDATE settings SET value = @Value WHERE key = @Key",
new { Value = stringValue, Key = settingKey });
new { Value = stringValue, Key = SettingsKeys.VerifiedEmailForPluginPublish });
}
public static async Task<bool> GetVerifiedEmailForLoginSetting(this NpgsqlConnection connection)

View File

@ -18,7 +18,6 @@ public class AdminPluginSettingViewModel : BasePagingViewModel
{
public IEnumerable<AdminPluginViewModel> Plugins { get; set; } = new List<AdminPluginViewModel>();
public bool VerifiedEmailForPluginPublish { get; set; }
public bool VerifiedGPGSignatureForPluginRelease { get; set; }
public string SearchText { get; set; } = null!;
public string? Status { get; set; }
public override int CurrentPageCount => Plugins.Count();

View File

@ -18,5 +18,4 @@ public class BuildViewModel
public string GitRef { get; internal set; }
public string RepositoryLink { get; internal set; }
public string Logs { get; set; }
public bool RequireGPGSignatureForRelease { get; set; }
}

View File

@ -1,23 +0,0 @@
namespace PluginBuilder.ViewModels;
public class PgpKeyViewModel
{
public string? KeyId { get; set; }
public string? Fingerprint { get; set; }
public string? PublicKey { get; set; }
public DateTimeOffset CreatedDate { get; set; }
public DateTimeOffset AddedDate { get; set; }
public long ValidDays { get; set; }
public int Version { get; set; }
}
public record SignatureProofResponse(bool valid, string message, SignatureProof? proof = null);
public class SignatureProof
{
public string Armour { get; init; }
public string KeyId { get; init; }
public string Fingerprint { get; init; }
public DateTime SignedAt { get; init; }
public DateTimeOffset VerifiedAt { get; init; }
}

View File

@ -1,5 +1,3 @@
using System.Text.Json;
namespace PluginBuilder.ViewModels.Plugin;
public class PluginOwnersPageViewModel
@ -13,8 +11,6 @@ public class PluginOwnersPageViewModel
public record OwnerVm(
string UserId,
bool IsPrimary,
string? Email,
string? AccountDetail
bool IsPrimary
);

View File

@ -15,15 +15,13 @@
{
<span class="badge bg-success ml-2">Confirmed</span>
}
<div class="d-flex align-items-center">
<input asp-for="AccountEmail" class="form-control" readonly />
<div class="input-group">
<input asp-for="AccountEmail" class="form-control" placeholder="" readonly="readonly" />
@if (Model.NeedToVerifyEmail)
{
<a asp-controller="Account"
asp-action="VerifyEmail"
class="btn btn-sm btn-outline-success ms-2">
Verify
</a>
<div class="input-group-append">
<a asp-controller="Account" asp-action="VerifyEmail" class="btn btn-secondary">Verify Email</a>
</div>
}
</div>
</div>
@ -33,15 +31,13 @@
{
<span class="badge bg-success ml-2">Confirmed</span>
}
<div class="d-flex align-items-center">
<div class="input-group">
<input asp-for="Settings.Github" class="form-control" placeholder="https://github.com/satoshi" readonly="readonly" />
@if (!Model.GithubAccountVerified)
{
<a asp-controller="Account"
asp-action="VerifyGithubAccount"
class="btn btn-sm btn-outline-success ms-2">
Verify
</a>
<div class="input-group-append">
<a asp-controller="Account" asp-action="VerifyGithubAccount" class="btn btn-secondary">Verify GitHub</a>
</div>
}
</div>
</div>
@ -60,77 +56,9 @@
<input asp-for="Settings.Email" class="form-control" type="email" placeholder="plugins@satoshi.com" />
<span asp-validation-for="Settings.Email" class="text-danger"></span>
</div>
<div class="form-group">
<label for="Settings.GpgPublicKey" class="form-label">GPG Public Key</label>
@{
DateTimeOffset? expiryDate = null;
if (Model.Settings.GPGKey?.ValidDays > 0)
{
expiryDate = Model.Settings.GPGKey.CreatedDate.AddDays(Model.Settings.GPGKey.ValidDays);
}
}
@if (!string.IsNullOrEmpty(Model.Settings.GPGKey?.Fingerprint) && (!expiryDate.HasValue || expiryDate > DateTimeOffset.UtcNow))
{
<span class="badge bg-success ms-2">Confirmed</span>
}
@if (expiryDate.HasValue)
{
if (expiryDate.Value < DateTimeOffset.UtcNow)
{
<div class="alert alert-danger mt-2">
Your GPG key expired on @expiryDate.Value.ToString("yyyy-MM-dd").
</div>
}
else if (expiryDate.Value <= DateTimeOffset.UtcNow.AddMonths(1))
{
<div class="alert alert-warning mt-2">
Your GPG key will expire on @expiryDate.Value.ToString("yyyy-MM-dd").
</div>
}
}
<div>
@if (!string.IsNullOrEmpty(Model.Settings.GPGKey?.Fingerprint))
{
<div id="existing-key-section" class="d-flex align-items-center">
<input class="form-control" readonly
value="Fingerprint: @Model.Settings.GPGKey?.Fingerprint" />
<button type="button"
class="btn btn-sm btn-outline-danger ms-2"
onclick="showResetKeyForm();">
Replace
</button>
</div>
<div id="reset-key-section" class="mt-3" style="display:none;">
<textarea asp-for="Settings.GPGKey.PublicKey" class="form-control" rows="8"
placeholder="Paste your armored GPG public key here..."></textarea>
<span asp-validation-for="Settings.GPGKey.PublicKey" class="text-danger"></span>
</div>
}
else
{
<textarea asp-for="Settings.GPGKey.PublicKey" class="form-control" rows="8"
placeholder="Paste your armored GPG public key here..."></textarea>
<span asp-validation-for="Settings.GPGKey.PublicKey" class="text-danger"></span>
}
</div>
</div>
<div class="form-group mt-4">
<input type="submit" class="btn btn-primary" id="Save" value="Save" />
</div>
</form>
</div>
</div>
@section FooterScripts {
<script>
function showResetKeyForm() {
document.getElementById("existing-key-section").style.display = "none";
document.getElementById("reset-key-section").style.display = "block";
}
</script>
}

View File

@ -9,17 +9,17 @@
<div class="d-flex flex-column flex-sm-row align-items-center gap-3">
<div class="d-flex align-items-center">
<form asp-controller="Admin" asp-action="UpdateVerifiedEmailForPublishRequirement" method="post" class="d-flex align-items-center">
<input type="checkbox" class="btcpay-toggle me-2" id="featureToggle" asp-for="@Model.VerifiedEmailForPluginPublish"
onchange="this.form.submit()" />
<label class="form-check-label text-wrap" for="featureToggle">
Require Verified Email for publishing
</label>
</form>
</div>
<div class="d-flex align-items-center">
<form asp-controller="Admin" asp-action="UpdateVerifiedEmailForPublishRequirement" method="post" class="d-flex align-items-center">
<input type="checkbox" class="btcpay-toggle me-2" id="featureToggle" asp-for="@Model.VerifiedEmailForPluginPublish"
onchange="this.form.submit()" />
<label class="form-check-label text-wrap" for="featureToggle">
Require Verified Email for publishing
</label>
</form>
</div>
@if (Model.Plugins.Any(m => !m.PreRelease && !string.IsNullOrEmpty(m.Version)))
@if (Model.Plugins.Where(m => !m.PreRelease && !string.IsNullOrEmpty(m.Version)).Any())
{
<a asp-controller="Admin" asp-action="EmailSender"
asp-route-to="@string.Join(",", Model.Plugins.Where(m => !m.PreRelease && !string.IsNullOrEmpty(m.Version)).Select(m => m.PrimaryOwnerEmail))"

View File

@ -26,18 +26,9 @@
case "uploaded":
if (Model.Version is null || Model.Version?.PreRelease is true)
{
if (Model.RequireGPGSignatureForRelease)
{
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#signReleaseModal">
Sign and Release
</button>
}
else
{
<form asp-action="Release" asp-route-pluginSlug="@Model.FullBuildId.PluginSlug" asp-route-version="@Model.Version.Version" method="post">
<button type="submit" name="command" value="release" class="btn btn-primary">Release</button>
</form>
}
<form asp-action="Release" asp-route-pluginSlug="@Model.FullBuildId.PluginSlug" asp-route-version="@Model.Version.Version" method="post">
<button type="submit" name="command" value="release" class="btn btn-primary">Release</button>
</form>
<a asp-action="CreateBuild" asp-route-pluginSlug="@Model.FullBuildId.PluginSlug" asp-route-copyBuild="@Model.FullBuildId.BuildId"
class="btn btn-secondary">Retry</a>
}
@ -61,39 +52,6 @@
</div>
</div>
<div class="modal fade" id="signReleaseModal" tabindex="-1" aria-labelledby="signReleaseModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="signReleaseModalLabel">Sign and Release Plugin Version</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="mb-2">
<p>Please sign the commit number shown below using your GPG keys, then upload a detached GPG signature (.asc)</p>
<div class="d-flex align-items-center gap-2">
<p class="mb-0">
Commit: <code id="commitHash">@Model.Commit</code>
</p>
<span role="button" style="cursor:pointer" onclick="downloadCommitFile()" title="Download commit txt file">
<vc:icon symbol="actions-download" />
</span>
</div>
</div>
<form asp-action="Release" asp-route-pluginSlug="@Model.FullBuildId.PluginSlug" asp-route-version="@(Model.Version?.Version ?? "")" method="post" enctype="multipart/form-data">
<input type="hidden" name="command" value="sign_release" />
<div class="mb-3">
<label for="signatureFile" class="form-label">Upload Signature (.asc)</label>
<input type="file" class="form-control" id="signatureFile" name="signatureFile" accept=".asc" required />
</div>
<button type="submit" class="btn btn-primary">Verify & Release</button>
</form>
</div>
</div>
</div>
</div>
<table class="table">
@if (Model.Version != null)
{
@ -116,7 +74,7 @@
else
{
@("@")
<a href="@Model.RepositoryLink" target="_blank" rel="noreferrer noopener">@Model.Commit?.Substring(0, 8)</a>
<a href="@Model.RepositoryLink" target="_blank" rel="noreferrer noopener">@Model.Commit</a>
}
</td>
</tr>
@ -183,20 +141,4 @@
<script src="~/vendor/highlight.js/highlight.min.js" asp-append-version="true"></script>
<script src="~/vendor/signalr/signalr.min.js" asp-append-version="true"></script>
<script src="~/scripts/Build.js" asp-append-version="true"></script>
<script>
function downloadCommitFile() {
const commit = document.getElementById('commitHash').textContent.trim();
const blob = new Blob([commit], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `commit-${commit}.txt`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
}