diff --git a/PluginBuilder/AccountSettings.cs b/PluginBuilder/AccountSettings.cs new file mode 100644 index 0000000..d8f02d7 --- /dev/null +++ b/PluginBuilder/AccountSettings.cs @@ -0,0 +1,37 @@ +using System.ComponentModel.DataAnnotations; + +namespace PluginBuilder +{ + public class AccountSettings + { + [Display(Name = "Github username")] + public string Github { get; set; } + + [Display(Name = "Nostr Npub key")] + public string? Nostr { get; set; } + + [Display(Name = "Twitter handle")] + public string? Twitter { get; set; } + + [Display(Name = "Email address")] + public string? Email { get; set; } + public List PgpKeys { get; set; } + } + + public class PgpKey + { + public string KeyBatchId { get; set; } + public string KeyUserId { get; set; } + public string Title { get; set; } + public string PublicKey { get; set; } + public string KeyId { get; set; } + public int BitStrength { get; set; } + public bool IsMasterKey { get; set; } + public bool IsEncryptionKey { get; set; } + public string Algorithm { get; set; } + public DateTime CreatedDate { get; set; } + public DateTime AddedDate { get; set; } + public long ValidDays { get; set; } + public int Version { get; set; } + } +} diff --git a/PluginBuilder/Components/MainNav/Default.cshtml b/PluginBuilder/Components/MainNav/Default.cshtml index 2978db2..875eda3 100644 --- a/PluginBuilder/Components/MainNav/Default.cshtml +++ b/PluginBuilder/Components/MainNav/Default.cshtml @@ -1,3 +1,4 @@ +@using PluginBuilder.Enums @inject Microsoft.AspNetCore.Identity.SignInManager SignInManager @model PluginBuilder.Components.MainNav.MainNavViewModel @@ -130,6 +131,16 @@ + @if (ViewData.IsActiveCategory(typeof(AccountNavPages))) + { + + + + } } diff --git a/PluginBuilder/Constants/WellKnownTempData.cs b/PluginBuilder/Constants/WellKnownTempData.cs new file mode 100644 index 0000000..2ca490a --- /dev/null +++ b/PluginBuilder/Constants/WellKnownTempData.cs @@ -0,0 +1,7 @@ +namespace PluginBuilder.Constants; + +public class WellKnownTempData +{ + public const string SuccessMessage = nameof(SuccessMessage); + public const string ErrorMessage = nameof(ErrorMessage); +} diff --git a/PluginBuilder/Controllers/AccountController.cs b/PluginBuilder/Controllers/AccountController.cs index 217d1b1..9fc4bd4 100644 --- a/PluginBuilder/Controllers/AccountController.cs +++ b/PluginBuilder/Controllers/AccountController.cs @@ -1,8 +1,7 @@ using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; -using PluginBuilder.DataModels; -using PluginBuilder.ModelBinders; +using PluginBuilder.Constants; using PluginBuilder.Services; using PluginBuilder.ViewModels; using PluginBuilder.ViewModels.Account; @@ -12,14 +11,17 @@ namespace PluginBuilder.Controllers [Authorize] public class AccountController : Controller { + private readonly PgpKeyService _pgpKeyService; private DBConnectionFactory ConnectionFactory { get; } private UserManager UserManager { get; } public AccountController( DBConnectionFactory connectionFactory, + PgpKeyService pgpKeyService, UserManager userManager) { ConnectionFactory = connectionFactory; + _pgpKeyService = pgpKeyService; UserManager = userManager; } @@ -60,5 +62,66 @@ public async Task AccountDetails(AccountDetailsViewModel model) TempData[TempDataConstant.SuccessMessage] = "Account details updated successfully"; return RedirectToAction(nameof(AccountDetails)); } + + [HttpPost("saveaccountkeys")] + public async Task SaveAccountPgpKeys(AccountKeySettingsViewModel model) + { + try + { + await _pgpKeyService.AddNewPGGKeyAsync(model.PublicKey, model.Title, UserManager.GetUserId(User)); + TempData[WellKnownTempData.SuccessMessage] = "Account key added successfully"; + return RedirectToAction("AccountKeySettings"); + } + catch (Exception ex) + { + ModelState.AddModelError("", ex.Message); + return RedirectToAction("AccountKeySettings"); + } + } + + + [HttpGet("accountkeysettings")] + public async Task AccountKeySettings() + { + await using var conn = await ConnectionFactory.Open(); + string userId = UserManager.GetUserId(User); + var accountSettings = await conn.GetAccountDetailSettings(userId) ?? new AccountSettings(); + + var pgpKeyViewModels = accountSettings?.PgpKeys? + .GroupBy(k => k.KeyBatchId) + .Select(g => new PgpKeyViewModel + { + BatchId = g.FirstOrDefault()?.KeyBatchId, + Title = g.FirstOrDefault()?.Title, + KeyUserId = g.FirstOrDefault()?.KeyUserId, + KeyId = g.FirstOrDefault(k => k.IsMasterKey)?.KeyId, + Subkeys = string.Join(", ", g.Where(k => !k.IsMasterKey).Select(k => k.KeyId)), + AddedDate = g.FirstOrDefault()?.AddedDate + }) + .ToList(); + return View(pgpKeyViewModels); + } + + [HttpPost("deleteaccountkey/{batchId}")] + public async Task DeleteAccountPgpKey(string batchId) + { + await using var conn = await ConnectionFactory.Open(); + string userId = UserManager.GetUserId(User); + var accountSettings = await conn.GetAccountDetailSettings(userId); + if (accountSettings == null) + { + TempData[WellKnownTempData.ErrorMessage] = "Account settings not found"; + return RedirectToAction("AccountKeySettings"); + } + int removedCount = accountSettings.PgpKeys?.RemoveAll(k => k.KeyBatchId == batchId) ?? 0; + if (removedCount == 0) + { + TempData[WellKnownTempData.ErrorMessage] = "Invalid batch key"; + return RedirectToAction("AccountKeySettings"); + } + await conn.SetAccountDetailSettings(accountSettings, userId); + TempData[WellKnownTempData.SuccessMessage] = "Account key deleted successfully"; + return RedirectToAction("AccountKeySettings"); + } } } diff --git a/PluginBuilder/Controllers/PluginController.cs b/PluginBuilder/Controllers/PluginController.cs index b9e60e5..c2c2986 100644 --- a/PluginBuilder/Controllers/PluginController.cs +++ b/PluginBuilder/Controllers/PluginController.cs @@ -8,6 +8,8 @@ using Newtonsoft.Json; using PluginBuilder.Components.PluginVersion; using PluginBuilder.Events; +using Microsoft.AspNetCore.Identity; +using PluginBuilder.Constants; namespace PluginBuilder.Controllers { @@ -17,17 +19,23 @@ public class PluginController : Controller { public PluginController( DBConnectionFactory connectionFactory, + UserManager userManager, BuildService buildService, - EventAggregator eventAggregator) + EventAggregator eventAggregator, + PgpKeyService pgpKeyService) { ConnectionFactory = connectionFactory; BuildService = buildService; EventAggregator = eventAggregator; + _pgpKeyService = pgpKeyService; + UserManager = userManager; } private DBConnectionFactory ConnectionFactory { get; } private BuildService BuildService { get; } private EventAggregator EventAggregator { get; } + private UserManager UserManager { get; } + private readonly PgpKeyService _pgpKeyService; [HttpGet("settings")] public async Task Settings( @@ -190,8 +198,8 @@ public async Task Build( long buildId) { 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)>( - "SELECT manifest_info, build_info, state, created_at, v.ver IS NOT NULL, v.pre_release FROM builds b " + + var row = await conn.QueryFirstOrDefaultAsync<(string manifest_info, string build_info, string state, DateTimeOffset created_at, bool published, bool pre_release, bool isbuildsigned)>( + "SELECT manifest_info, build_info, state, created_at, v.ver IS NOT NULL, v.pre_release, isbuildsigned 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", @@ -217,6 +225,7 @@ public async Task Build( vm.ManifestInfo = NiceJson(row.manifest_info); vm.BuildInfo = buildInfo?.ToString(Formatting.Indented); vm.DownloadLink = buildInfo?.Url; + vm.IsBuildSigned = row.isbuildsigned; vm.State = row.state; vm.CreatedDate = (DateTimeOffset.UtcNow - row.created_at).ToTimeAgo(); vm.Commit = buildInfo?.GitCommit?.Substring(0, 8); @@ -234,6 +243,45 @@ public async Task Build( return View(vm); } + + [HttpPost("builds/sign/{buildId}")] + public async Task SignPluginBuild(PluginApprovalStatusUpdateViewModel model, + [ModelBinder(typeof(PluginSlugModelBinder))] PluginSlug pluginSlug,long buildId) + { + await using var conn = await ConnectionFactory.Open(); + string userId = UserManager.GetUserId(User); + var accountSettings = await conn.GetAccountDetailSettings(userId); + if (accountSettings == null || accountSettings.PgpKeys == null || !accountSettings.PgpKeys.Any()) + { + TempData[WellKnownTempData.ErrorMessage] = "Kindly add new GPG Keys to proceed with plugin action"; + return RedirectToAction(nameof(Build), new { pluginSlug, buildId }); + } + var manifest_info = await conn.QueryFirstOrDefaultAsync( + "SELECT manifest_info FROM builds b WHERE b.plugin_slug=@pluginSlug AND id=@buildId LIMIT 1", + new + { + pluginSlug = pluginSlug.ToString(), + buildId + }); + List publicKeys = accountSettings.PgpKeys.Select(key => key.PublicKey).ToList(); + string manifestShasum = _pgpKeyService.ComputeSHA256(NiceJson(manifest_info)); + var validateSignature = _pgpKeyService.VerifyPgpMessage(model.ArmoredMessage, manifestShasum, publicKeys); + if (!validateSignature.success) + { + TempData[WellKnownTempData.ErrorMessage] = validateSignature.response; + return RedirectToAction(nameof(Build), new { pluginSlug, buildId }); + } + await conn.ExecuteAsync( + "UPDATE builds SET isbuildsigned = true WHERE plugin_slug = @pluginSlug AND id = @buildId", + new + { + pluginSlug = pluginSlug.ToString(), + buildId + }); + TempData[WellKnownTempData.SuccessMessage] = $"{model.PluginSlug} signed and verified successfully"; + return RedirectToAction(nameof(Build), new { pluginSlug, buildId }); + } + private string? NiceJson(string? json) { if (json is null) diff --git a/PluginBuilder/Data/Scripts/12.PgpBuildSigning.sql b/PluginBuilder/Data/Scripts/12.PgpBuildSigning.sql new file mode 100644 index 0000000..5a63471 --- /dev/null +++ b/PluginBuilder/Data/Scripts/12.PgpBuildSigning.sql @@ -0,0 +1,5 @@ +ALTER TABLE builds +ADD COLUMN isbuildsigned BOOLEAN DEFAULT false; + +UPDATE builds +SET isbuildsigned = true; diff --git a/PluginBuilder/Enums/AccountNavPages.cs b/PluginBuilder/Enums/AccountNavPages.cs new file mode 100644 index 0000000..eb0a212 --- /dev/null +++ b/PluginBuilder/Enums/AccountNavPages.cs @@ -0,0 +1,6 @@ +namespace PluginBuilder.Enums; + +public enum AccountNavPages +{ + Keys, Settings +} diff --git a/PluginBuilder/Program.cs b/PluginBuilder/Program.cs index 2e5a8a8..75c9fde 100644 --- a/PluginBuilder/Program.cs +++ b/PluginBuilder/Program.cs @@ -91,7 +91,7 @@ public void AddServices(IConfiguration configuration, IServiceCollection service services.AddHostedService(); services.AddHostedService(); services.AddHostedService(); - + services.AddTransient(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); diff --git a/PluginBuilder/Services/PgpKeyService.cs b/PluginBuilder/Services/PgpKeyService.cs new file mode 100644 index 0000000..1a06451 --- /dev/null +++ b/PluginBuilder/Services/PgpKeyService.cs @@ -0,0 +1,166 @@ +using Microsoft.AspNetCore.Identity; +using Org.BouncyCastle.Asn1.Cmp; +using Org.BouncyCastle.Bcpg.OpenPgp; +using PluginBuilder.ViewModels; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; + +namespace PluginBuilder.Services; + +public class PgpKeyService +{ + private DBConnectionFactory ConnectionFactory { get; } + private UserManager UserManager { get; } + public PgpKeyService(DBConnectionFactory connectionFactory, + UserManager userManager) + { + UserManager = userManager; + ConnectionFactory = connectionFactory; + } + + public async Task AddNewPGGKeyAsync(string publicKey, string title, string userId) + { + await using var conn = await ConnectionFactory.Open(); + var accountSettings = await conn.GetAccountDetailSettings(userId) ?? new AccountSettings(); + + // Batch ID to group master key and sub keys belonging to a public key + string batchId = Guid.NewGuid().ToString(); + List pgpKeys = new List(); + using var keyStream = new MemoryStream(Encoding.ASCII.GetBytes(publicKey)); + using var inputStream = PgpUtilities.GetDecoderStream(keyStream); + var pgpPubKeyBundle = new PgpPublicKeyRingBundle(inputStream); + + var emailRegex = new Regex(@"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"); + bool emailMatches = false; + var userRecord = await UserManager.FindByIdAsync(userId); + foreach (PgpPublicKeyRing keyRing in pgpPubKeyBundle.GetKeyRings()) + { + foreach (PgpPublicKey key in keyRing.GetPublicKeys()) + { + var userIds = key.GetUserIds(); + string extractedEmails = string.Empty; + if (userIds.Any(userIdString => + { + var match = emailRegex.Match(userIdString); + if (match.Success) + { + extractedEmails = match.Value; + return match.Value.Equals(userRecord?.Email, StringComparison.OrdinalIgnoreCase); + } + return false; + })) + { + emailMatches = true; + } + if (!emailMatches) + { + continue; + } + var pgpKey = new PgpKey + { + KeyBatchId = batchId, + Title = title, + PublicKey = publicKey, + KeyId = key.KeyId.ToString("X"), + BitStrength = key.BitStrength, + IsEncryptionKey = key.IsEncryptionKey, + IsMasterKey = key.IsMasterKey, + Algorithm = key.Algorithm.ToString(), + AddedDate = DateTime.Now, + CreatedDate = key.CreationTime, + ValidDays = key.GetValidSeconds(), + Version = key.Version, + KeyUserId = string.Join(", ", userIds) + }; + pgpKeys.Add(pgpKey); + } + } + accountSettings.PgpKeys = pgpKeys; + await conn.SetAccountDetailSettings(accountSettings, userId); + } + + public string ComputeSHA256(string input) + { + using SHA256 sha256 = SHA256.Create(); + byte[] inputBytes = Encoding.UTF8.GetBytes(input); + byte[] hashBytes = sha256.ComputeHash(inputBytes); + return BitConverter.ToString(hashBytes).Replace("-", "").ToLowerInvariant(); + } + + private static PgpPublicKey LoadPublicKey(string publicKeyString) + { + try + { + using var keyStream = new MemoryStream(Encoding.ASCII.GetBytes(publicKeyString)); + using var inputStream = PgpUtilities.GetDecoderStream(keyStream); + var pgpPubKeyBundle = new PgpPublicKeyRingBundle(inputStream); + return pgpPubKeyBundle.GetKeyRings() + .Cast() + .SelectMany(keyRing => keyRing.GetPublicKeys().Cast()) + .FirstOrDefault(key => key.IsMasterKey); + } + catch (Exception) + { + return null; + } + } + + public (bool success, string response) VerifyPgpMessage(string armoredMessage, string manifestShasum, List publicKeys) + { + try + { + foreach (var publicKey in publicKeys) + { + PgpPublicKey pgpPublicKey = LoadPublicKey(publicKey); + if (pgpPublicKey == null) + { + continue; + } + using var messageStream = new MemoryStream(Encoding.UTF8.GetBytes(armoredMessage)); + using var decoderStream = PgpUtilities.GetDecoderStream(messageStream); + var pgpFactory = new PgpObjectFactory(PgpUtilities.GetDecoderStream(messageStream)); + var pgpObject = pgpFactory.NextPgpObject(); + if (pgpObject is PgpCompressedData compressedData) + { + pgpFactory = new PgpObjectFactory(compressedData.GetDataStream()); + pgpObject = pgpFactory.NextPgpObject(); + } + if (pgpObject is PgpOnePassSignatureList onePassSignatureList) + { + var onePassSignature = onePassSignatureList[0]; + onePassSignature.InitVerify(pgpPublicKey); + pgpObject = pgpFactory.NextPgpObject(); + if (pgpObject is PgpLiteralData literalData) + { + using Stream literalDataStream = literalData.GetInputStream(); + using var actualMessageStream = new MemoryStream(); + int ch; + while ((ch = literalDataStream.ReadByte()) >= 0) + { + onePassSignature.Update((byte)ch); + actualMessageStream.WriteByte((byte)ch); + } + var message = Encoding.UTF8.GetString(actualMessageStream.ToArray()); + if (!message.Contains(manifestShasum)) + { + continue; + } + } + var signatureList = (PgpSignatureList)pgpFactory.NextPgpObject(); + var signature = signatureList[0]; + if (onePassSignature.Verify(signature)) + { + return (true, "Signature verified successfully"); + } + } + } + return (false, "Unable to validate signature message with account public keys"); + } + catch (Exception) + { + + throw; + } + } +} diff --git a/PluginBuilder/ViewDataExtensions.cs b/PluginBuilder/ViewDataExtensions.cs index 7e91d74..7f74955 100644 --- a/PluginBuilder/ViewDataExtensions.cs +++ b/PluginBuilder/ViewDataExtensions.cs @@ -8,7 +8,7 @@ public static class ViewDataExtensions private const string ACTIVE_CATEGORY_KEY = "ActiveCategory"; private const string ACTIVE_PAGE_KEY = "ActivePage"; private const string ACTIVE_ID_KEY = "ActiveId"; - private const string ActivePageClass = "active"; + private const string ACTIVE_CLASS = "active"; public static void SetActivePage(this ViewDataDictionary viewData, T activePage, string title = null, string activeId = null) where T : IConvertible @@ -35,17 +35,49 @@ public static void SetActiveCategory(this ViewDataDictionary viewData, string ac viewData[ACTIVE_CATEGORY_KEY] = activeCategory; } + public static string ActivePageClass(this ViewDataDictionary viewData, T page, object id = null) + where T : IConvertible + { + return ActivePageClass(viewData, page.ToString(), page.GetType().ToString(), id); + } + + public static string ActivePageClass(this ViewDataDictionary viewData, string page, string category, object id = null) + { + return IsActivePage(viewData, page, category, id); + } + + public static string ActivePageClass(this ViewDataDictionary viewData, IEnumerable pages, object id = null) where T : IConvertible + { + return IsActivePage(viewData, pages, id); + } + public static string IsActivePage(this ViewDataDictionary viewData, T page, object id = null) where T : IConvertible { return IsActivePage(viewData, page.ToString(), page.GetType().ToString(), id); } + public static bool IsActiveCategory(this ViewDataDictionary viewData, string category, object id = null) + { + if (!viewData.ContainsKey(ACTIVE_CATEGORY_KEY)) + return false; + var activeId = viewData[ACTIVE_ID_KEY]; + var activeCategory = viewData[ACTIVE_CATEGORY_KEY]?.ToString(); + var categoryMatch = category.Equals(activeCategory, StringComparison.InvariantCultureIgnoreCase); + var idMatch = id == null || activeId == null || id.Equals(activeId); + return categoryMatch && idMatch; + } + + public static bool IsActiveCategory(this ViewDataDictionary viewData, T category, object id = null) + { + return IsActiveCategory(viewData, category.ToString(), id); + } + public static string IsActivePage(this ViewDataDictionary viewData, IEnumerable pages, object id = null) where T : IConvertible { - return pages.Any(page => IsActivePage(viewData, page.ToString(), page.GetType().ToString(), id) == ActivePageClass) - ? ActivePageClass + return pages.Any(page => IsActivePage(viewData, page.ToString(), page.GetType().ToString(), id) == ACTIVE_CLASS) + ? ACTIVE_CLASS : null; } @@ -60,7 +92,7 @@ public static string IsActivePage(this ViewDataDictionary viewData, string page, var activeCategory = viewData[ACTIVE_CATEGORY_KEY]?.ToString(); var categoryAndPageMatch = (category == null || activeCategory.Equals(category, StringComparison.InvariantCultureIgnoreCase)) && page.Equals(activePage, StringComparison.InvariantCultureIgnoreCase); var idMatch = id == null || activeId == null || id.Equals(activeId); - return categoryAndPageMatch && idMatch ? ActivePageClass : null; + return categoryAndPageMatch && idMatch ? ACTIVE_CLASS : null; } } } diff --git a/PluginBuilder/ViewModels/AccountKeySettingsViewModel.cs b/PluginBuilder/ViewModels/AccountKeySettingsViewModel.cs new file mode 100644 index 0000000..92c1a73 --- /dev/null +++ b/PluginBuilder/ViewModels/AccountKeySettingsViewModel.cs @@ -0,0 +1,25 @@ +namespace PluginBuilder.ViewModels; + + +public class AccountKeySettingsViewModel +{ + public string PublicKey { get; set; } + public string Title { get; set; } +} + +public class PgpKeyViewModel +{ + public string BatchId { get; set; } + public string Title { get; set; } + public string KeyUserId { get; set; } + public string KeyId { get; set; } + public string Subkeys { get; set; } + public DateTime? AddedDate { get; set; } +} + +public class PluginApprovalStatusUpdateViewModel +{ + public string PluginSlug { get; set; } + public string ArmoredMessage { get; set; } + public string ManifestShasum { get; set; } +} diff --git a/PluginBuilder/ViewModels/BuildViewModel.cs b/PluginBuilder/ViewModels/BuildViewModel.cs index 73e6954..7a81b2e 100644 --- a/PluginBuilder/ViewModels/BuildViewModel.cs +++ b/PluginBuilder/ViewModels/BuildViewModel.cs @@ -18,5 +18,6 @@ public class BuildViewModel public string GitRef { get; internal set; } public string RepositoryLink { get; internal set; } public string Logs { get; set; } + public bool IsBuildSigned { get; set; } } } diff --git a/PluginBuilder/Views/Account/AccountDetails.cshtml b/PluginBuilder/Views/Account/AccountDetails.cshtml index 0fe1784..323bf3e 100644 --- a/PluginBuilder/Views/Account/AccountDetails.cshtml +++ b/PluginBuilder/Views/Account/AccountDetails.cshtml @@ -1,7 +1,8 @@ +@using PluginBuilder.Enums @model AccountDetailsViewModel @{ Layout = "_Layout"; - ViewData.SetActivePage("Account Settings"); + ViewData.SetActivePage(AccountNavPages.Settings, "Account Settings"); }

@ViewData["Title"]

diff --git a/PluginBuilder/Views/Account/AccountKeySettings.cshtml b/PluginBuilder/Views/Account/AccountKeySettings.cshtml new file mode 100644 index 0000000..aada091 --- /dev/null +++ b/PluginBuilder/Views/Account/AccountKeySettings.cshtml @@ -0,0 +1,110 @@ +@using PluginBuilder.Enums +@using PluginBuilder.ViewModels +@model List +@{ + Layout = "_Layout"; + ViewData.SetActivePage(AccountNavPages.Keys, "Account Key Settings"); +} + +
+

+ @ViewData["Title"] +

+ + +
+ +@if (Model != null && Model.Any()) +{ +
+ @foreach (var key in Model) + { +
+
+
+
+
+ + + + @* *@ +
+
+ @key.Title + User: @key.KeyUserId + Key ID: @key.KeyId + Sub Key(s): @key.Subkeys +
+ +
+
+ + + +
+
+ + +
+
+ } +
+} +else +{ +

No available keys. Click on the button to create a new PGP Key

+} + + diff --git a/PluginBuilder/Views/Plugin/Build.cshtml b/PluginBuilder/Views/Plugin/Build.cshtml index 62cd7be..39a41b9 100644 --- a/PluginBuilder/Views/Plugin/Build.cshtml +++ b/PluginBuilder/Views/Plugin/Build.cshtml @@ -4,6 +4,15 @@ ViewData.SetActivePage(PluginNavPages.Dashboard, $"Build {Model.FullBuildId.BuildId}", Model.FullBuildId.ToString()); } + +

@@ -25,9 +34,18 @@ case "uploaded": if (Model.Version is null || Model.Version?.PreRelease is true) { -
- -
+ if (Model.IsBuildSigned) + { +
+ +
+ } + else + { + + } Retry } else if (Model.Version?.Published is true) @@ -49,6 +67,32 @@

+ + + + @if (Model.Version != null) {