884 lines
36 KiB
C#
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);
|
|
}
|
|
}
|