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

884 lines
36 KiB
C#

using Dapper;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Newtonsoft.Json.Linq;
using PluginBuilder.APIModels;
using PluginBuilder.Components.PluginVersion;
using PluginBuilder.Configuration;
using PluginBuilder.Controllers.Logic;
using PluginBuilder.DataModels;
using PluginBuilder.JsonConverters;
using PluginBuilder.ModelBinders;
using PluginBuilder.Services;
using PluginBuilder.Util;
using PluginBuilder.Util.Extensions;
using PluginBuilder.ViewModels;
using PluginBuilder.ViewModels.Admin;
using PluginBuilder.ViewModels.Home;
using PluginBuilder.ViewModels.Plugin;
namespace PluginBuilder.Controllers;
[Authorize]
public class HomeController(
DBConnectionFactory connectionFactory,
UserManager<IdentityUser> userManager,
SignInManager<IdentityUser> signInManager,
EmailService emailService,
UserVerifiedLogic userVerifiedLogic,
PluginBuilderOptions options,
ServerEnvironment env,
NostrService nostrService,
GitHostingProviderFactory gitHostingProviderFactory,
ILogger<HomeController> logger,
HealthCheckService healthCheckService)
: Controller
{
[AllowAnonymous]
[HttpGet("/")]
public IActionResult HomePage(
[ModelBinder(typeof(PluginVersionModelBinder))]
PluginVersion? btcpayVersion = null,
string? searchPluginName = null)
{
return RedirectToAction(
User.Identity?.IsAuthenticated == true
? nameof(Dashboard)
: nameof(AllPlugins)
);
}
// auth methods
[HttpGet("/dashboard")]
public async Task<IActionResult> Dashboard()
{
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,
string slug, string? identifier)>
(@"SELECT id, state, manifest_info, build_info, created_at, v.ver IS NOT NULL, v.pre_release, p.slug, p.identifier
FROM builds b
LEFT JOIN versions v ON b.plugin_slug=v.plugin_slug AND b.id=v.build_id
JOIN plugins p ON p.slug = b.plugin_slug
JOIN users_plugins up ON up.plugin_slug = b.plugin_slug
WHERE up.user_id = @userId
ORDER BY created_at DESC
LIMIT 50", new { userId = userManager.GetUserId(User) });
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, row.slug);
b.Date = (DateTimeOffset.UtcNow - row.created_at).ToTimeAgo();
b.RepositoryLink = PluginController.GetUrl(buildInfo, gitHostingProviderFactory);
b.DownloadLink = buildInfo?.Url;
b.Error = buildInfo?.Error;
b.PluginSlug = row.slug;
b.PluginIdentifier = row.identifier ?? row.slug;
}
return View("Views/Plugin/Dashboard", vm);
}
[HttpGet("/logout")]
public async Task<IActionResult> Logout()
{
await signInManager.SignOutAsync();
return RedirectToAction(nameof(Login));
}
[AllowAnonymous]
[HttpGet("/login")]
public IActionResult Login()
{
return View(new LoginViewModel());
}
[AllowAnonymous]
[HttpPost("/login")]
[EnableRateLimiting(Policies.PublicApiRateLimit)]
public async Task<IActionResult> Login(LoginViewModel model, string? returnUrl = null)
{
ViewData["ReturnUrl"] = returnUrl;
if (!ModelState.IsValid)
return View(model);
var user = await userManager.FindByEmailAsync(model.Email);
if (user is null)
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
}
var result = await signInManager.CheckPasswordSignInAsync(user, model.Password, true);
if (!result.Succeeded)
{
ModelState.AddModelError(string.Empty, "Invalid login attempt.");
return View(model);
}
if (userVerifiedLogic.IsEmailVerificationRequiredForLogin)
{
var principal = await signInManager.CreateUserPrincipalAsync(user);
var isVerified = await userVerifiedLogic.IsUserEmailVerifiedForLogin(principal);
if (isVerified)
{
await signInManager.SignInAsync(user, model.RememberMe);
return RedirectToLocal(returnUrl);
}
var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
var link = Url.Action(nameof(ConfirmEmail), "Home",
new { uid = user.Id, token }, Request.Scheme, Request.Host.ToString())!;
var email = user.Email!;
await emailService.SendVerifyEmail(email, link);
ViewData["VerifyEmailTitle"] = "Email confirmation required to sign in";
ViewData["VerifyEmailDescription"] =
"After you confirm your email, please sign in again to continue.";
return View(nameof(VerifyEmail), email);
}
await signInManager.SignInAsync(user, model.RememberMe);
return RedirectToLocal(returnUrl);
}
[AllowAnonymous]
[HttpGet("/register")]
public IActionResult Register(string? returnUrl = null)
{
ViewData["ReturnUrl"] = returnUrl;
return View(new RegisterViewModel());
}
[AllowAnonymous]
[HttpPost("/register")]
[EnableRateLimiting(Policies.PublicApiRateLimit)]
public async Task<IActionResult> Register(RegisterViewModel model, string? returnUrl = null)
{
ViewData["ReturnUrl"] = returnUrl;
if (!ModelState.IsValid)
return View(model);
IdentityUser user = new() { UserName = model.Email, Email = model.Email };
var result = await userManager.CreateAsync(user, model.Password);
if (!result.Succeeded)
{
foreach (var error in result.Errors)
ModelState.AddModelError("", error.Description);
return View(model);
}
await using var conn = await connectionFactory.Open();
var admins = await userManager.GetUsersInRoleAsync(Roles.ServerAdmin);
var isAdminReg = admins.Count == 0 || (model.IsAdmin && env.CheatMode);
if (isAdminReg)
await userManager.AddToRoleAsync(user, Roles.ServerAdmin);
// check if it's not admin and we are requiring email verifications
var emailSettings = await emailService.GetEmailSettingsFromDb();
if (!isAdminReg && emailSettings?.PasswordSet == true)
{
var token = await userManager.GenerateEmailConfirmationTokenAsync(user);
var link = Url.Action(nameof(ConfirmEmail), "Home", new { uid = user.Id, token },
Request.Scheme, Request.Host.ToString());
await emailService.SendVerifyEmail(model.Email, link!);
return RedirectToAction(nameof(VerifyEmail), new { email = user.Email });
}
await signInManager.SignInAsync(user, false);
return RedirectToLocal(returnUrl);
}
[AllowAnonymous]
[HttpGet("public/plugins")]
[EnableRateLimiting(Policies.PublicApiRateLimit)]
public async Task<IActionResult> AllPlugins(
[ModelBinder(typeof(BtcPayHostVersionModelBinder))]
PluginVersion? btcpayVersion = null, bool includePreRelease = false, string? searchPluginName = null, string sort = "smart")
{
searchPluginName = searchPluginName.StripControlCharacters();
await using var conn = await connectionFactory.Open();
var query = $"""
WITH review_stats AS (
SELECT
plugin_slug,
AVG(rating) AS avg_rating,
COUNT(*) AS total_reviews
FROM plugin_reviews
GROUP BY plugin_slug
)
SELECT
lv.plugin_slug,
lv.ver,
p.settings,
p.visibility,
b.id,
b.manifest_info,
b.build_info,
COALESCE(rs.avg_rating, 0.0) AS avg_rating,
COALESCE(rs.total_reviews, 0) AS total_reviews
FROM get_latest_versions(@btcpayVersion, @includePreRelease) lv
JOIN builds b ON b.plugin_slug = lv.plugin_slug AND b.id = lv.build_id
JOIN plugins p ON b.plugin_slug = p.slug
LEFT JOIN review_stats rs ON rs.plugin_slug = lv.plugin_slug
WHERE b.manifest_info IS NOT NULL
AND b.build_info IS NOT NULL
AND (
p.visibility = 'listed'
OR (p.visibility = 'unlisted' AND @hasSearchTerm = true)
)
AND (
@hasSearchTerm = false
OR (
p.slug ILIKE @searchPattern
OR b.manifest_info->>'Name' ILIKE @searchPattern
OR p.settings->>'pluginTitle' ILIKE @searchPattern
OR b.build_info->>'gitRepository' ILIKE @searchPattern
))
ORDER BY {OrderByClause()}
""";
// be careful not to introduce sql injection here
string OrderByClause()
{
const string pluginNameExpr = "COALESCE(p.settings->>'pluginTitle', b.manifest_info->>'Name')";
var orderByClause = sort.ToLowerInvariant() switch
{
"rating" => $"rs.avg_rating DESC NULLS LAST, rs.total_reviews DESC, b.created_at DESC, {pluginNameExpr}",
"recent" => $"b.created_at DESC, {pluginNameExpr}",
"alpha" => pluginNameExpr,
_ => $"""
(
(COALESCE(rs.avg_rating, 0.0) * 10)
+ GREATEST(0, 30 - DATE_PART('day', NOW() - b.created_at))
+ LEAST(LN(1 + COALESCE(rs.total_reviews, 0)) * 5, 40)
) DESC,
{pluginNameExpr}
"""
};
return orderByClause;
}
var rows = await conn
.QueryAsync<(string plugin_slug, int[] ver, string settings, PluginVisibilityEnum visibility, long id, string manifest_info, string build_info, decimal avg_rating, int total_reviews
)>(
query,
new
{
btcpayVersion = btcpayVersion?.VersionParts,
includePreRelease,
searchPattern = $"%{searchPluginName}%",
hasSearchTerm = !string.IsNullOrWhiteSpace(searchPluginName)
});
rows.TryGetNonEnumeratedCount(out var count);
List<PublishedPlugin> versions = new(count);
versions.AddRange(rows.Select(r =>
{
var manifestInfo = JObject.Parse(r.manifest_info);
var settings = SafeJson.Deserialize<PluginSettings>(r.settings);
return new PublishedPlugin
{
PluginTitle = settings?.PluginTitle ?? manifestInfo["Name"]?.ToString(),
Description = settings?.Description ?? manifestInfo["Description"]?.ToString(),
ProjectSlug = r.plugin_slug,
Version = string.Join('.', r.ver),
BuildInfo = JObject.Parse(r.build_info),
ManifestInfo = manifestInfo,
PluginLogo = settings?.Logo,
IsUnlisted = r.visibility == PluginVisibilityEnum.Unlisted,
RatingSummary = new PluginRatingSummary
{
Average = r.avg_rating,
TotalReviews = r.total_reviews
}
};
}));
return View(versions);
}
[AllowAnonymous]
[HttpGet("public/plugins/{pluginSlug}")]
[EnableRateLimiting(Policies.PublicApiRateLimit)]
public async Task<IActionResult> GetPluginDetails(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
[ModelBinder(typeof(PluginVersionModelBinder))]
PluginVersion? version = null,
[ModelBinder(typeof(BtcPayHostVersionModelBinder))]
PluginVersion? btcpayVersion = null,
bool includePreRelease = false,
[FromQuery] PluginDetailsViewModel? model = null)
{
if (pluginSlug is null)
return NotFound();
model ??= new PluginDetailsViewModel();
var sort = string.Equals(model.Sort, "helpful", StringComparison.OrdinalIgnoreCase) ? "helpful" : "newest";
if (model.RatingFilter is < 1 or > 5)
model.RatingFilter = null;
var userId = User.Identity?.IsAuthenticated == true ? userManager.GetUserId(User) : null;
var isAdmin = User.Identity?.IsAuthenticated == true && User.IsInRole(Roles.ServerAdmin);
var orderBy = sort == "helpful"
? " (hv.up_count - hv.down_count) DESC, r.created_at DESC "
: " r.created_at DESC ";
var versionFilter = version is null ? string.Empty : "AND v.ver = @version";
var versionSource = btcpayVersion is null
? "versions v"
: "get_all_versions(@btcpayVersion, @includePreRelease) gv JOIN versions v ON v.plugin_slug = gv.plugin_slug AND v.ver = gv.ver";
var versionsQuery = btcpayVersion is null
? """
SELECT array_agg(array_to_string(ver, '.') ORDER BY ver DESC)
FROM versions
WHERE plugin_slug = v.plugin_slug
"""
: """
SELECT array_agg(array_to_string(gv.ver, '.') ORDER BY gv.ver DESC)
FROM get_all_versions(@btcpayVersion, @includePreRelease) gv
WHERE gv.plugin_slug = v.plugin_slug
""";
var versionPreReleasesQuery = btcpayVersion is null
? """
SELECT array_agg(pre_release ORDER BY ver DESC)
FROM versions
WHERE plugin_slug = v.plugin_slug
"""
: """
SELECT array_agg(vv.pre_release ORDER BY gv.ver DESC)
FROM get_all_versions(@btcpayVersion, @includePreRelease) gv
JOIN versions vv ON vv.plugin_slug = gv.plugin_slug AND vv.ver = gv.ver
WHERE gv.plugin_slug = v.plugin_slug
""";
var prms = new
{
pluginSlug = pluginSlug.ToString(),
version = version?.VersionParts,
btcpayVersion = btcpayVersion?.VersionParts,
includePreRelease,
currentUserId = userId,
isAdmin,
skip = model.Skip,
take = model.Count,
sort,
rating = model.RatingFilter
};
var sql =
@"
-- FIRST QUERY
SELECT
v.plugin_slug,
array_to_string(v.ver, '.') AS ver_str,
array_to_string(v.btcpay_min_ver, '.') AS btcpay_min_ver,
array_to_string(v.btcpay_max_ver, '.') AS btcpay_max_ver,
p.settings,
b.manifest_info,
b.build_info,
p.visibility,
(SELECT b2.created_at
FROM builds b2
WHERE b2.plugin_slug = v.plugin_slug
ORDER BY b2.id ASC
LIMIT 1) AS created_at,
(" + versionsQuery + @") AS versions,
(" + versionPreReleasesQuery + @") AS version_pre_releases
FROM " + versionSource + @"
JOIN builds b ON b.plugin_slug = v.plugin_slug AND b.id = v.build_id
JOIN plugins p ON b.plugin_slug = p.slug
WHERE v.plugin_slug = @pluginSlug
" + versionFilter + @"
AND b.manifest_info IS NOT NULL
AND b.build_info IS NOT NULL
AND (
p.visibility <> 'hidden'
OR @isAdmin
OR (
@currentUserId IS NOT NULL AND EXISTS (
SELECT 1 FROM users_plugins up
WHERE up.plugin_slug = v.plugin_slug AND up.user_id = @currentUserId
)
)
)
ORDER BY v.ver DESC
LIMIT 1;
-- SECOND QUERY
SELECT
COALESCE(AVG(rating), 0) AS ""Average"",
COUNT(*) AS ""TotalReviews"",
COUNT(*) FILTER (WHERE rating = 1) AS ""C1"",
COUNT(*) FILTER (WHERE rating = 2) AS ""C2"",
COUNT(*) FILTER (WHERE rating = 3) AS ""C3"",
COUNT(*) FILTER (WHERE rating = 4) AS ""C4"",
COUNT(*) FILTER (WHERE rating = 5) AS ""C5""
FROM plugin_reviews
WHERE plugin_slug = @pluginSlug;
-- THIRD QUERY
SELECT
r.id AS Id,
u.username AS ""AuthorDisplay"",
u.profile_url AS ""AuthorUrl"",
u.avatar_url AS ""AuthorAvatarUrl"",
r.rating AS Rating,
r.body AS Body,
array_to_string(r.plugin_version, '.')::text AS ""PluginVersion"",
r.created_at AS ""CreatedAt"",
COALESCE(hv.up_count, 0) AS ""UpCount"",
COALESCE(hv.down_count, 0) AS ""DownCount"",
( @currentUserId IS NOT NULL AND u.user_id = @currentUserId ) AS ""IsReviewOwner"",
CASE
WHEN @currentUserId IS NULL THEN NULL
WHEN r.helpful_voters ? @currentUserId
THEN (r.helpful_voters ->> @currentUserId)::boolean
ELSE NULL
END AS ""UserVoteHelpful""
FROM plugin_reviews r
LEFT JOIN plugin_reviewers u ON u.id = r.reviewer_id
LEFT JOIN LATERAL (
SELECT
COUNT(*) FILTER (WHERE kv.value::boolean) AS up_count,
COUNT(*) FILTER (WHERE NOT kv.value::boolean) AS down_count
FROM jsonb_each_text(COALESCE(r.helpful_voters, '{}'::jsonb)) kv
) hv ON TRUE
WHERE r.plugin_slug = @pluginSlug AND (@rating IS NULL OR r.rating = @rating)
ORDER BY " + orderBy + @"
OFFSET @skip LIMIT @take;"
;
await using var conn = await connectionFactory.Open();
await using var multi = await conn.QueryMultipleAsync(sql, prms);
//first
var pluginDetails = await multi.ReadFirstOrDefaultAsync<dynamic>();
if (pluginDetails is null)
return NotFound();
var versionLabels = (pluginDetails.versions as IEnumerable<string> ?? Enumerable.Empty<string>()).ToList();
var versionPreReleases = (pluginDetails.version_pre_releases as IEnumerable<bool> ?? Enumerable.Empty<bool>()).ToList();
var versions = versionLabels
.Select((v, i) => new PluginDetailsVersionViewModel
{
Version = v,
PreRelease = i < versionPreReleases.Count && versionPreReleases[i]
})
.ToList();
//second
var summary = await multi.ReadFirstOrDefaultAsync<PluginRatingSummary>() ?? new PluginRatingSummary();
// third
var items = (await multi.ReadAsync<Review>()).ToList();
var settings = SafeJson.Deserialize<PluginSettings>((string)pluginDetails.settings);
var manifestInfo = JObject.Parse((string)pluginDetails.manifest_info);
var plugin = new PublishedPlugin
{
PluginTitle = settings?.PluginTitle ?? manifestInfo["Name"]?.ToString(),
Description = settings?.Description ?? manifestInfo["Description"]?.ToString(),
ProjectSlug = pluginSlug.ToString(),
ManifestInfo = manifestInfo,
PluginLogo = settings?.Logo,
Documentation = settings?.Documentation,
VideoUrl = settings?.VideoUrl,
Images = settings?.Images,
Version = (string)pluginDetails.ver_str,
BTCPayMinVersion = (string?)pluginDetails.btcpay_min_ver is { Length: > 0 } min ? min.Trim() : null,
BTCPayMaxVersion = (string?)pluginDetails.btcpay_max_ver is { Length: > 0 } max ? max.Trim() : null,
BuildInfo = JObject.Parse((string)pluginDetails.build_info),
CreatedDate = (DateTimeOffset)pluginDetails.created_at,
RatingSummary = summary
};
var primaryOwnerId = await conn.RetrievePluginPrimaryOwner(pluginSlug);
var ownerSettings = await conn.GetAccountDetailSettings(primaryOwnerId!) ?? new AccountSettings();
var ownerGithubHandle = ExternalAccountVerificationService.GetGithubHandle(ownerSettings.Github);
string? ownerGithubUrl = null;
if (!string.IsNullOrWhiteSpace(ownerGithubHandle))
ownerGithubUrl = $"{ExternalProfileUrls.GithubBaseUrl}{Uri.EscapeDataString(ownerGithubHandle)}";
string? ownerNostrUrl = null;
var ownerNpub = ownerSettings.Nostr?.Npub?.Trim();
if (!string.IsNullOrWhiteSpace(ownerNpub))
ownerNostrUrl = string.Format(ExternalProfileUrls.PrimalProfileFormat, Uri.EscapeDataString(ownerNpub));
var pluginContributors = GithubService.LoadSnapshot(options.PluginDataDir, pluginSlug);
if (pluginContributors.Count == 0)
{
var provider = gitHostingProviderFactory.GetProvider(plugin.gitRepository);
if (provider != null)
{
pluginContributors = await provider.GetContributorsAsync(plugin.gitRepository, plugin.pluginDir);
if (pluginContributors.Count > 0)
await GithubService.SaveSnapshot(options.PluginDataDir, pluginSlug, pluginContributors);
}
}
var vm = new PluginDetailsViewModel
{
Plugin = plugin,
Sort = sort,
Skip = model.Skip,
Reviews = items,
IsAdmin = isAdmin,
IsOwner = userId != null && userId == primaryOwnerId,
PluginVersions = versions,
ShowHiddenNotice = Enum.Parse<PluginVisibilityEnum>((string)pluginDetails.visibility, true) == PluginVisibilityEnum.Hidden,
Contributors = pluginContributors,
RatingFilter = model.RatingFilter,
OwnerGithubUrl = ownerGithubUrl,
OwnerNostrUrl = ownerNostrUrl,
EmbedMode = string.Equals(Request.Query["embed"], "1", StringComparison.Ordinal)
};
return View(vm);
}
[HttpPost("public/plugins/{pluginSlug}/reviews/upsert")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> UpsertReview(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug, int rating, string? body, string? pluginVersion)
{
if (rating is < 1 or > 5)
return BadRequest("Invalid rating");
var userId = userManager.GetUserId(User);
if (string.IsNullOrEmpty(userId))
return Forbid();
await using var conn = await connectionFactory.Open();
var isOwner = await conn.UserOwnsPlugin(userId, pluginSlug);
if (isOwner)
{
TempData[TempDataConstant.WarningMessage] = "You cannot review your own plugin.";
return RedirectToAction(nameof(GetPluginDetails), "Home", new { pluginSlug = pluginSlug.ToString() });
}
var reviewerAccountDetails = await conn.GetAccountDetailSettings(userId) ?? new AccountSettings();
if (string.IsNullOrEmpty(reviewerAccountDetails.Github) &&
(reviewerAccountDetails.Nostr == null || string.IsNullOrEmpty(reviewerAccountDetails.Nostr?.Npub)))
{
TempData[TempDataConstant.WarningMessage] = "You need to verify your GitHub or Nostr account in order to review plugins";
return RedirectToAction(nameof(AccountController.AccountDetails), "Account");
}
int[]? pluginVersionParts = null;
if (!string.IsNullOrWhiteSpace(pluginVersion) && PluginVersion.TryParse(pluginVersion, out var v))
pluginVersionParts = v.VersionParts;
PluginReviewViewModel reviewViewModel = new()
{
PluginSlug = pluginSlug.ToString(),
Rating = rating,
Body = body,
PluginVersion = pluginVersionParts
};
reviewViewModel.ReviewerId = await conn.CreateOrUpdatePluginReviewer(await UpdatePluginReviewerData(reviewerAccountDetails, userId));
await conn.UpsertPluginReview(reviewViewModel);
var sort = Request.Query["sort"].ToString();
var url = Url.Action(nameof(GetPluginDetails), "Home", new
{
pluginSlug = pluginSlug.ToString(),
sort = string.IsNullOrEmpty(sort) ? null : sort
});
return Redirect((url ?? "/") + "#reviews");
}
private async Task<ImportReviewViewModel> UpdatePluginReviewerData(AccountSettings settings, string userId)
{
ImportReviewViewModel importReviewModel = new()
{
SelectedUserId = userId,
LinkExistingUser = true
};
if (!string.IsNullOrEmpty(settings.Github))
{
var githubProfile = ExternalAccountVerificationService.GetGithubIdentity(settings.Github);
importReviewModel.ReviewerName = githubProfile?.Login;
importReviewModel.ReviewerProfileUrl = githubProfile?.HtmlUrl;
importReviewModel.ReviewerAvatarUrl = githubProfile?.AvatarUrl;
}
else if (settings.Nostr != null && !string.IsNullOrEmpty(settings.Nostr.Npub))
{
var nostr = settings.Nostr;
importReviewModel.ReviewerProfileUrl = string.Format(ExternalProfileUrls.PrimalProfileFormat, Uri.EscapeDataString(nostr.Npub));
importReviewModel.ReviewerName = string.IsNullOrWhiteSpace(nostr.Profile?.Name)
? nostr.Npub.Length >= 8
? $"{nostr.Npub[..8]}…"
: nostr.Npub
: nostr.Profile.Name;
importReviewModel.ReviewerAvatarUrl = !string.IsNullOrWhiteSpace(nostr.Profile?.PictureUrl) &&
Uri.TryCreate(nostr.Profile.PictureUrl, UriKind.Absolute, out var avatarUri) &&
(avatarUri.Scheme == Uri.UriSchemeHttp || avatarUri.Scheme == Uri.UriSchemeHttps)
? nostr.Profile.PictureUrl
: null;
try
{
var pubKey = nostrService.NpubToHexPub(nostr.Npub);
var nostrProfile = await nostrService.GetNostrProfileByAuthorHexAsync(pubKey);
if (nostrProfile is not null)
{
importReviewModel.ReviewerName = nostrProfile.Name;
importReviewModel.ReviewerAvatarUrl = nostrProfile.PictureUrl;
return importReviewModel;
}
}
catch (Exception ex)
{
logger.LogError(ex, "Error while retrieving nostr profile for {Npub}", nostr.Npub);
}
}
return importReviewModel;
}
[HttpPost("public/plugins/{pluginSlug}/reviews/{id:long}/vote")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> VoteReview(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
long id,
bool isHelpful)
{
var userId = userManager.GetUserId(User);
if (string.IsNullOrEmpty(userId))
return Forbid();
await using var conn = await connectionFactory.Open();
var current = await conn.GetReviewHelpfulVoteAsync(pluginSlug, id, userId);
var ok = current == isHelpful
? await conn.RemoveReviewHelpfulVoteAsync(pluginSlug, id, userId)
: await conn.UpsertReviewHelpfulVoteAsync(pluginSlug, id, userId, isHelpful);
if (!ok)
TempData[TempDataConstant.WarningMessage] = "Error while updating review helpful vote";
var url = Url.Action(nameof(GetPluginDetails), new
{
pluginSlug = pluginSlug.ToString()
});
return Redirect((url ?? "/") + "#reviews");
}
[HttpPost("public/plugins/{pluginSlug}/reviews/{id:long}/delete")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> DeleteReview(
[ModelBinder(typeof(PluginSlugModelBinder))]
PluginSlug pluginSlug,
long id)
{
var userId = userManager.GetUserId(User);
if (string.IsNullOrEmpty(userId))
return Forbid();
var isAdmin = User.Identity?.IsAuthenticated == true && User.IsInRole(Roles.ServerAdmin);
await using var conn = await connectionFactory.Open();
var ok = await conn.DeleteReviewAsync(pluginSlug, id, userId, isAdmin);
if (!ok)
TempData[TempDataConstant.WarningMessage] = "Error while deleting review";
var url = Url.Action(nameof(GetPluginDetails), new
{
pluginSlug = pluginSlug.ToString()
});
return Redirect((url ?? "/") + "#reviews");
}
[AllowAnonymous]
[HttpGet("/VerifyEmail")]
public IActionResult VerifyEmail(string email)
{
return View(model: email);
}
[AllowAnonymous]
[HttpGet("/ConfirmEmail")]
public async Task<IActionResult> ConfirmEmail(string uid, string token)
{
ConfirmEmailViewModel model = new();
var user = await userManager.FindByIdAsync(uid);
if (user is not null)
{
var result = await userManager.ConfirmEmailAsync(user, token);
model.Email = user.Email!;
model.EmailConfirmed = result.Succeeded;
}
return View(model);
}
[AllowAnonymous]
[HttpGet("/UpdateEmail")]
public async Task<IActionResult> VerifyEmailUpdate(string uid, string token)
{
ConfirmEmailViewModel model = new();
await using var conn = await connectionFactory.Open();
var user = await userManager.FindByIdAsync(uid);
if (user is null)
return View("ConfirmEmail", model);
var settings = await conn.GetAccountDetailSettings(user.Id);
if (string.IsNullOrEmpty(settings?.PendingNewEmail))
return View("ConfirmEmail", model);
var result = await userManager.ChangeEmailAsync(user, settings.PendingNewEmail, token);
var setUsernameResult = await userManager.SetUserNameAsync(user, settings.PendingNewEmail);
model.Email = settings.PendingNewEmail;
model.EmailConfirmed = result.Succeeded && setUsernameResult.Succeeded;
if (model.EmailConfirmed)
{
settings.PendingNewEmail = string.Empty;
await conn.SetAccountDetailSettings(settings, user.Id);
}
return View("ConfirmEmail", model);
}
// password reset flow
[AllowAnonymous]
[HttpGet("/passwordreset")]
public IActionResult PasswordReset(string email, string code)
{
if (string.IsNullOrEmpty(email) || string.IsNullOrEmpty(code))
{
TempData[TempDataConstant.WarningMessage] = "Invalid password reset link.";
return RedirectToAction(nameof(Login));
}
var model = new PasswordResetViewModel { Email = email, PasswordResetToken = code };
return View(model);
}
[AllowAnonymous]
[HttpPost("/passwordreset")]
[EnableRateLimiting(Policies.PublicApiRateLimit)]
public async Task<IActionResult> PasswordReset(PasswordResetViewModel model)
{
if (!ModelState.IsValid)
return View(model);
var user = await userManager.FindByEmailAsync(model.Email);
if (user is null)
{
ModelState.AddModelError(string.Empty, "User with suggested email doesn't exist");
return View(model);
}
var result = await userManager.ResetPasswordAsync(user, model.PasswordResetToken, model.Password);
if (!result.Succeeded)
{
foreach (var err in result.Errors)
ModelState.AddModelError(string.Empty, err.Description);
return View(model);
}
return RedirectToAction(nameof(Login));
}
[AllowAnonymous]
[HttpGet("/forgotpassword")]
public IActionResult ForgotPassword()
{
return View(new ForgotPasswordViewModel());
}
[AllowAnonymous]
[HttpPost("/forgotpassword")]
[EnableRateLimiting(Policies.PublicApiRateLimit)]
[ValidateAntiForgeryToken]
public async Task<IActionResult> ForgotPassword(ForgotPasswordViewModel model)
{
if (!ModelState.IsValid)
return View(model);
var user = await userManager.FindByEmailAsync(model.Email);
// Check if user exists and if their email is confirmed before sending a reset link.
if (user != null)
{
var code = await userManager.GeneratePasswordResetTokenAsync(user);
var callbackUrl = Url.Action(nameof(PasswordReset), "Home",
new { email = user.Email, code }, Request.Scheme);
await emailService.ResetPasswordEmail(model.Email, callbackUrl!);
}
model.FormSubmitted = true;
return View(model);
}
private IActionResult RedirectToLocal(string? returnUrl = null)
{
if (!string.IsNullOrEmpty(returnUrl) && Url.IsLocalUrl(returnUrl))
return Redirect(returnUrl);
return RedirectToAction(nameof(HomePage), "Home");
}
[AllowAnonymous]
[HttpGet("/health")]
[EnableRateLimiting(Policies.PublicApiRateLimit)]
public async Task<IActionResult> CheckHealth(CancellationToken cancellationToken)
{
var report = await healthCheckService.CheckHealthAsync(cancellationToken);
var result = new
{
status = report.Status == HealthStatus.Healthy ? "UP" : "DOWN",
timestamp = DateTime.UtcNow,
description = report.Entries.Values.FirstOrDefault(e => e.Description is not null).Description
};
// display page
var acceptHeader = Request.Headers.Accept.ToString();
if (acceptHeader.Contains("text/html"))
{
var hcvm = new HealthCheckViewModel { Healthy = result.status, Description = result.description ?? "" };
var view = View("HealthPage", hcvm);
view.StatusCode = report.Status == HealthStatus.Unhealthy
? StatusCodes.Status503ServiceUnavailable
: StatusCodes.Status200OK;
return view;
}
// send JSON result
return report.Status == HealthStatus.Healthy ? Ok(result) : StatusCode(StatusCodes.Status503ServiceUnavailable, result);
}
}