Improved ArticleView permission handling

This commit is contained in:
Mia Rose Winter 2024-01-18 21:57:53 +01:00
parent 6ab9855680
commit 468f8491de
Signed by: miawinter
GPG key ID: 4B6F6A83178F595E
4 changed files with 141 additions and 60 deletions

View file

@ -0,0 +1,72 @@
@using Wave.Data
@using Humanizer
@inject IStringLocalizer<ArticleComponent> Localizer
<h1 class="text-3xl lg:text-5xl font-light">
@Article.Title
</h1>
<p class="mb-3">
<small class="text-sm text-neutral-content">
<time datetime="@Article.PublishDate.ToString("u")"
title="@Article.PublishDate.ToString("g")">
@Article.PublishDate.Humanize()
</time>
@if (Article.LastModified is not null) {
<time datetime="@Article.LastModified.Value.ToString("u")"
title="@Article.LastModified.Value.ToString("g")">
&ensp;(@Localizer["ModifiedOn"] @Article.LastModified.Humanize())
</time>
}
@if (Article.Status <= ArticleStatus.Published) {
<span class="badge badge-sm badge-outline badge-warning">
@Article.Status.Humanize()
</span>
}
</small>
</p>
<AuthorizeView Policy="ArticleEditPermissions">
<Authorized>
<a class="btn btn-info mb-3" href="article/@Article.Id/edit">Edit</a>
</Authorized>
</AuthorizeView>
<hr class="my-3" />
<article class="prose prose-neutral max-w-none mb-6">
@Content
</article>
<hr class="my-3" />
@if (!string.IsNullOrWhiteSpace(Article.Author.AboutTheAuthor)) {
<section class="mb-2">
<h2 class="text-2xl lg:text-4xl mb-3">About The Author</h2>
<div class="card sm:card-side card-compact bg-neutral text-neutral-content rounded shadow">
<figure class="sm:max-w-32">
<img src="/api/user/pfp/@Article.Author.Id" alt="" width="800">
</figure>
<div class="card-body">
<h3 class="card-title">@Article.Author.Name</h3>
<p>
@Article.Author.AboutTheAuthor
</p>
</div>
</div>
</section>
}
<div class="flex gap-2">
@if (string.IsNullOrWhiteSpace(Article.Author.AboutTheAuthor)) {
<ProfilePill Profile="Article.Author" RoleTag="@Localizer["Author"]"/>
}
@if (Article.Reviewer is not null && Article.Reviewer.Id != Article.Author.Id) {
<ProfilePill Profile="Article.Reviewer" RoleTag="@Localizer["Reviewer"]"/>
}
</div>
@code {
[Parameter]
public required Article Article { get; set; }
private MarkupString Content => new(Article.BodyHtml);
}

View file

@ -1,69 +1,35 @@
@page "/article/{id:guid}" @page "/article/{id:guid}"
@using Microsoft.EntityFrameworkCore @using Microsoft.EntityFrameworkCore
@using Wave.Data @using Wave.Data
@using Humanizer @using System.Security.Claims
@using System.Globalization @using Microsoft.AspNetCore.Identity
@using System.Diagnostics.CodeAnalysis
@inject IDbContextFactory<ApplicationDbContext> ContextFactory; @inject IDbContextFactory<ApplicationDbContext> ContextFactory;
@inject RoleManager<IdentityRole> RoleManager
@inject IStringLocalizer<ArticleView> Localizer @inject IStringLocalizer<ArticleView> Localizer
<PageTitle>@(TitlePrefix + Article.Title)</PageTitle> <PageTitle>@(TitlePrefix + Article.Title)</PageTitle>
<h1 class="text-3xl lg:text-5xl font-light">@Article.Title</h1> <ErrorBoundary>
<p class="mb-3"> <ChildContent>
<small class="text-sm text-neutral-content"> <AuthorizeView Policy="ArticleEditOrReviewPermissions">
<time datetime="@Article.PublishDate.ToString("u")" <Authorized>
title="@Article.PublishDate.ToString("g")"> <ArticleComponent Article="@GetArticleProtected(context.User)" />
@Article.PublishDate.Humanize() </Authorized>
</time> <NotAuthorized>
@if (Article.LastModified is not null) { <ArticleComponent Article="@GetArticlePublic()" />
<time datetime="@Article.LastModified.Value.ToString("u")" </NotAuthorized>
title="@Article.LastModified.Value.ToString("g")"> </AuthorizeView>
&ensp;(@Localizer["ModifiedOn"] @Article.LastModified.Humanize()) </ChildContent>
</time> <ErrorContent>
<h1 class="text-3xl lg:text-5xl font-light mb-6">@Localizer["NotFound_Title"]</h1>
<p class="my-3">@Localizer["NotFound_Description"]</p>
@if (Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT")?.ToLower() == "development") {
<p>@context.Message</p>
} }
</small> </ErrorContent>
</p> </ErrorBoundary>
<AuthorizeView Policy="ArticleEditPermissions">
<Authorized>
<a class="btn btn-info mb-3" href="article/@Article.Id/edit">Edit</a>
</Authorized>
</AuthorizeView>
<hr class="my-3" />
<article class="prose prose-neutral max-w-none mb-6">
@Content
</article>
<hr class="my-3" />
@if (!string.IsNullOrWhiteSpace(Article.Author.AboutTheAuthor)) {
<section class="mb-2">
<h2 class="text-2xl lg:text-4xl mb-3">About The Author</h2>
<div class="card sm:card-side card-compact bg-neutral text-neutral-content rounded shadow">
<figure class="sm:max-w-32">
<img src="/api/user/pfp/@Article.Author.Id" alt="" width="800">
</figure>
<div class="card-body">
<h3 class="card-title">@Article.Author.Name</h3>
<p>
@Article.Author.AboutTheAuthor
</p>
</div>
</div>
</section>
}
<div class="flex gap-2">
@if (string.IsNullOrWhiteSpace(Article.Author.AboutTheAuthor)) {
<ProfilePill Profile="Article.Author" RoleTag="@Localizer["Author"]"/>
}
@if (Article.Reviewer is not null && Article.Reviewer.Id != Article.Author.Id) {
<ProfilePill Profile="Article.Reviewer" RoleTag="@Localizer["Reviewer"]"/>
}
</div>
@code { @code {
[CascadingParameter(Name = "TitlePrefix")] [CascadingParameter(Name = "TitlePrefix")]
@ -71,9 +37,39 @@
[Parameter] [Parameter]
public Guid Id { get; set; } public Guid Id { get; set; }
private Article Article { get; set; } = default!;
private Article Article { get; set; } = null!; private Article GetArticlePublic() {
private MarkupString Content => new(Article.BodyHtml); if (Article.Status >= ArticleStatus.Published && Article.PublishDate <= DateTimeOffset.UtcNow) {
return Article;
}
throw new ApplicationException("Article is not public.");
}
[SuppressMessage("ReSharper", "ConvertIfStatementToSwitchStatement")]
private Article GetArticleProtected(ClaimsPrincipal principal) {
// Admins always get access
if (principal.IsInRole("Admin")) {
return Article;
}
// You cannot access your own drafts
if (Article.Status is ArticleStatus.Draft) {
if (Article.Author.Id == principal.FindFirst("Id")!.Value) {
return Article;
}
throw new ApplicationException("Cannot access draft article without being author or admin.");
}
// InReview Articles can only be accessed by reviewers
if (Article.Status is ArticleStatus.InReview) {
if (principal.IsInRole("Reviewer")) {
return Article;
}
throw new ApplicationException("Cannot access in-review article without being a reviewer or admin.");
}
throw new ApplicationException("User does not have access to this article.");
}
protected override async Task OnInitializedAsync() { protected override async Task OnInitializedAsync() {
// We need blocking calls here, bc otherwise Blazor will execute Render in parallel, // We need blocking calls here, bc otherwise Blazor will execute Render in parallel,
@ -86,7 +82,7 @@
Article = context.Set<Article>() Article = context.Set<Article>()
.Include(a => a.Author) .Include(a => a.Author)
.Include(a => a.Reviewer) .Include(a => a.Reviewer)
.Where(a => a.Status >= ArticleStatus.Published && a.PublishDate <= now) // .Where(a => a.Status >= ArticleStatus.Published && a.PublishDate <= now)
.First(a => a.Id == Id); .First(a => a.Id == Id);
if (Article is null) throw new ApplicationException("Article not found."); if (Article is null) throw new ApplicationException("Article not found.");

View file

@ -36,7 +36,9 @@
.AddPolicy("ArticleEditPermissions", p => p.RequireRole("Author", "Admin")) .AddPolicy("ArticleEditPermissions", p => p.RequireRole("Author", "Admin"))
.AddPolicy("ArticleReviewPermissions", p => p.RequireRole("Reviewer", "Admin")) .AddPolicy("ArticleReviewPermissions", p => p.RequireRole("Reviewer", "Admin"))
.AddPolicy("ArticleDeletePermissions", p => p.RequireRole("Moderator", "Admin")) .AddPolicy("ArticleDeletePermissions", p => p.RequireRole("Moderator", "Admin"))
.AddPolicy("RoleAssignPermissions", p => p.RequireRole("Admin")); .AddPolicy("RoleAssignPermissions", p => p.RequireRole("Admin"))
.AddPolicy("ArticleEditOrReviewPermissions", p => p.RequireRole("Author", "Reviewer", "Admin"));
builder.Services.AddAuthentication(options => { builder.Services.AddAuthentication(options => {
options.DefaultScheme = IdentityConstants.ApplicationScheme; options.DefaultScheme = IdentityConstants.ApplicationScheme;
options.DefaultSignInScheme = IdentityConstants.ExternalScheme; options.DefaultSignInScheme = IdentityConstants.ExternalScheme;

View file

@ -0,0 +1,11 @@
using System.Security.Claims;
namespace Wave.Utilities;
public static class ClaimsPrincipalUtilities {
public static bool IsInRole(this ClaimsPrincipal principal, string roleName) {
return principal.Claims.Any(
c => c.Type == ClaimTypes.Role &&
string.Equals(c.Value, roleName,StringComparison.CurrentCultureIgnoreCase));
}
}