diff --git a/Wave/Components/Pages/ArticleEditor.razor b/Wave/Components/Pages/ArticleEditor.razor index 7d3f508..eeb1178 100644 --- a/Wave/Components/Pages/ArticleEditor.razor +++ b/Wave/Components/Pages/ArticleEditor.razor @@ -3,9 +3,9 @@ @using Wave.Data @using Microsoft.AspNetCore.Identity +@using System.Security.Claims -@rendermode InteractiveServer -@attribute [Authorize(Policy = "ArticleEditOrReviewPermissions")] +@rendermode @(new InteractiveServerRenderMode(false)) @inject UserManager UserManager @inject IStringLocalizer Localizer @@ -23,7 +23,7 @@

@Localizer["EditorTitle"]

- +

Not found

@@ -41,10 +41,12 @@ [Parameter] public Guid? Id { get; set; } private ApplicationUser? User { get; set; } + private ClaimsPrincipal? ClaimsUser { get; set; } protected override async Task OnInitializedAsync() { if (AuthenticationState is null) throw new ApplicationException("???"); var state = await AuthenticationState; + ClaimsUser = state.User; var user = await UserManager.GetUserAsync(state.User); User = user ?? throw new ApplicationException("???2"); } diff --git a/Wave/Components/Pages/ArticleView.razor b/Wave/Components/Pages/ArticleView.razor index 9cc011b..29fc275 100644 --- a/Wave/Components/Pages/ArticleView.razor +++ b/Wave/Components/Pages/ArticleView.razor @@ -3,9 +3,7 @@ @using Microsoft.EntityFrameworkCore @using Wave.Data @using System.Security.Claims -@using System.Diagnostics.CodeAnalysis @using System.Globalization -@using System.Net @using Microsoft.AspNetCore.Identity @using Microsoft.Extensions.Options @using Wave.Services @@ -54,45 +52,41 @@ - - - -
- @if (CanEdit) { - @Localizer["Edit"] - } - @if (Article.Status is ArticleStatus.Draft) { -
- - - - } - @if (Article.Status is ArticleStatus.InReview || (Article.Status is ArticleStatus.Draft && context.User.IsInRole("Admin"))) { -
- - - @if (Features.Value.EmailSubscriptions) { -
- -
- } - - } - - - @Localizer["Delete_Submit"] - - -
-
- - - -
+ @if (GetArticle(HttpContext.User) is {} article) { + + +
+ @if (article.AllowedToEdit(HttpContext.User)) { + @Localizer["Edit"] + } + @if (article.AllowedToSubmitForReview(HttpContext.User)) { +
+ + + + } + @if (article.AllowedToPublish(HttpContext.User)) { +
+ + + @if (Features.Value.EmailSubscriptions && HttpContext.User.IsInRole("Admin")) { +
+ +
+ } + + } + @if (article.AllowedToDelete(HttpContext.User)) { + + @Localizer["Delete_Submit"] + + } +
+ }

@Localizer["NotFound_Title"]

@@ -135,52 +129,11 @@ [CascadingParameter] public HttpContext HttpContext { get; set; } = default!; - - private bool CanEdit { get; set; } - - private Article GetArticlePublic() { - if (Article is null) throw new ApplicationException("Article not found."); - 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) { - if (Article is null) throw new ApplicationException("Article not found."); - - // The Article is publicly available - if (Article.Status >= ArticleStatus.Published && Article.PublishDate <= DateTimeOffset.UtcNow) { - if (principal.IsInRole("Admin")) - CanEdit = true; - return Article; - } - - // Admins always get access - if (principal.IsInRole("Admin")) { - CanEdit = true; - return Article; - } - - // You can only access your own drafts - if (Article.Status is ArticleStatus.Draft) { - if (Article.Author.Id == principal.FindFirst("Id")!.Value) { - CanEdit = true; - 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")) { - CanEdit = true; - 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."); + + private Article GetArticle(ClaimsPrincipal principal) { + if (Article.AllowedToRead(principal)) return Article!; + + throw new ApplicationException("Article not found or missing permissions."); } protected override void OnInitialized() { @@ -207,10 +160,10 @@ } private async Task SubmitForReview() { - if (Article is null) return; + if (Article.AllowedToSubmitForReview(HttpContext.User) is false) return; await using var context = await ContextFactory.CreateDbContextAsync(); - Article.Status = ArticleStatus.InReview; + Article!.Status = ArticleStatus.InReview; context.Update(Article); await context.SaveChangesAsync(); Message.ShowSuccess(Localizer["Submit_Review_Success"]); @@ -224,6 +177,8 @@ await EmailService.ConnectAsync(CancellationToken.None); foreach (var reviewer in reviewers) { + if (reviewer.Id == HttpContext.User.FindFirst("Id")!.Value) continue; + var email = await Email.CreateDefaultEmail( reviewer.Email!, reviewer.Name, @@ -245,6 +200,8 @@ } private async Task SubmitForPublish() { + if (Article.AllowedToPublish(HttpContext.User) is false) return; + await using var context = await ContextFactory.CreateDbContextAsync(); Article!.Status = ArticleStatus.Published; string userId = HttpContext.User.FindFirst("Id")!.Value; @@ -269,27 +226,30 @@ } try { - await EmailService.ConnectAsync(CancellationToken.None); var author = Article.Author; - string publishMessage = - (Article.PublishDate < DateTimeOffset.Now) ? - "Is is now publicly available." : - $"It is currently scheduled for {Article.PublishDate.ToString("f", CultureInfo.GetCultureInfo("en-US"))}."; + if (author.Id != HttpContext.User.FindFirst("Id")!.Value) { + await EmailService.ConnectAsync(CancellationToken.None); - var email = await Email.CreateDefaultEmail( - author.Email!, - author.Name, - "Article has been approved", - "Your Article is going to be published", - $"

The Article '{Article.Title}' has been checked and approved by a reviewer.

" + - $"

{publishMessage}

", - $"The Article '{Article.Title}' has been checked and approved by a reviewer. " + - publishMessage); - // TODO check if they enabled email notifications (property currently not implemented) - await EmailService.SendEmailAsync(email); + string publishMessage = + (Article.PublishDate < DateTimeOffset.Now) ? + "Is is now publicly available." : + $"It is currently scheduled for {Article.PublishDate.ToString("f", CultureInfo.GetCultureInfo("en-US"))}."; - await EmailService.DisconnectAsync(CancellationToken.None); + var email = await Email.CreateDefaultEmail( + author.Email!, + author.Name, + "Article has been approved", + "Your Article is going to be published", + $"

The Article '{Article.Title}' has been checked and approved by a reviewer.

" + + $"

{publishMessage}

", + $"The Article '{Article.Title}' has been checked and approved by a reviewer. " + + publishMessage); + // TODO check if they enabled email notifications (property currently not implemented) + await EmailService.SendEmailAsync(email); + + await EmailService.DisconnectAsync(CancellationToken.None); + } } catch (Exception ex) { Logger.LogError(ex, "Failed to send mail to author about article '{title}' being published.", Article.Title); } diff --git a/Wave/Components/Pages/Partials/ArticleEditorPartial.razor b/Wave/Components/Pages/Partials/ArticleEditorPartial.razor index 213da1a..88bf175 100644 --- a/Wave/Components/Pages/Partials/ArticleEditorPartial.razor +++ b/Wave/Components/Pages/Partials/ArticleEditorPartial.razor @@ -2,6 +2,7 @@ @using System.ComponentModel.DataAnnotations @using System.Diagnostics.CodeAnalysis @using System.Net +@using System.Security.Claims @using Microsoft.AspNetCore.Identity @using Microsoft.EntityFrameworkCore @using Wave.Services @@ -231,6 +232,9 @@ public Guid? Id { get; set; } [Parameter] public required ApplicationUser User { get; set; } + [Parameter] + public required ClaimsPrincipal ClaimsUser { get; set; } + [SupplyParameterFromForm] private InputModel Model { get; set; } = new(); @@ -261,11 +265,12 @@ .Include(a => a.Images) .FirstAsync(a => a.Id == Id); if (article is null) throw new ApplicationException("Article not found."); - - await HandleRoles(article, User); } if (article is not null) { + if (!article.AllowedToEdit(ClaimsUser)) + throw new ApplicationException("You are not allowed to edit this article"); + Model.Id ??= article.Id; Model.Title ??= article.Title; Model.Slug ??= article.Slug; @@ -278,6 +283,10 @@ } private async Task OnValidSubmit() { + if (Article.AllowedToEdit(ClaimsUser) is false) { + Message.ShowError("Permission denied."); + return; + } try { Saving = true; diff --git a/Wave/Components/Routes.razor b/Wave/Components/Routes.razor index 6859d17..ac4cb80 100644 --- a/Wave/Components/Routes.razor +++ b/Wave/Components/Routes.razor @@ -1,11 +1,16 @@ @using Wave.Components.Account.Shared - - - - - - - - + + + + + + +
+ +
+
+
+ +
diff --git a/Wave/Utilities/Permissions.cs b/Wave/Utilities/Permissions.cs new file mode 100644 index 0000000..3f5aed4 --- /dev/null +++ b/Wave/Utilities/Permissions.cs @@ -0,0 +1,120 @@ +using System.Security.Claims; +using Wave.Data; + +namespace Wave.Utilities; + +/// +/// Central location for assessing if a user has access to an article, or may modify them in specific ways +/// +public static class Permissions { + public static bool AllowedToRead(this Article? article, ClaimsPrincipal principal) { + if (article is null || article.IsDeleted) return false; + + // The Article is publicly available + if (article.Status >= ArticleStatus.Published && article.PublishDate <= DateTimeOffset.UtcNow) { + return true; + } + + // Admins always get access + if (principal.IsInRole("Admin")) { + return true; + } + + // You can only access your own drafts + if (article.Status is ArticleStatus.Draft && article.Author.Id == principal.FindFirst("Id")!.Value) { + return true; + } + + // Reviewers can see in-review articles + if (article.Status is ArticleStatus.InReview && principal.IsInRole("Reviewer")) { + return true; + } + + return false; + } + + public static bool AllowedToEdit(this Article? article, ClaimsPrincipal principal) { + if (article is null || article.IsDeleted) return false; + + // Admins always can edit articles + if (principal.IsInRole("Admin")) { + return true; + } + + // You can edit your own draft articles + if (article.Status is ArticleStatus.Draft && article.Author.Id == principal.FindFirst("Id")!.Value) { + return true; + } + + // Reviewers can edit in-review articles + if (article.Status is ArticleStatus.InReview && principal.IsInRole("Reviewer")) { + return true; + } + + // Moderators can edit published/-ing articles + if (article.Status is ArticleStatus.Published && principal.IsInRole("Moderator")) { + return true; + } + + return false; + } + + public static bool AllowedToSubmitForReview(this Article? article, ClaimsPrincipal principal) { + if (article is null || article.IsDeleted) return false; + + // Draft articles can be submitted by their authors (admins can publish them anyway, no need to submit) + if (article.Status is ArticleStatus.Draft && article.Author.Id == principal.FindFirst("Id")!.Value) { + return true; + } + + return false; + } + + public static bool AllowedToPublish(this Article? article, ClaimsPrincipal principal) { + if (article is null || article.IsDeleted) return false; + + // Admins can skip review and directly publish draft articles + if (article.Status is ArticleStatus.Draft && principal.IsInRole("Admin")) { + return true; + } + + // Admins may always review articles + if (article.Status is ArticleStatus.InReview && principal.IsInRole("Admin")) { + return true; + } + + // Reviewers can review in-review articles, as long as they are not their own + if (article.Status is ArticleStatus.InReview && principal.IsInRole("Reviewer") && + article.Author.Id != principal.FindFirst("Id")!.Value) { + return true; + } + + return false; + } + + public static bool AllowedToDelete(this Article? article, ClaimsPrincipal principal) { + if (article is null || article.IsDeleted) return false; + + // Admins can delete articles whenever + if (principal.IsInRole("Admin")) { + return true; + } + + // You can delete your drafts + if (article.Status is ArticleStatus.Draft && article.Author.Id == principal.FindFirst("Id")!.Value) { + return true; + } + + // Reviewers can reject/delete in-review articles + if (article.Status is ArticleStatus.InReview && principal.IsInRole("Reviewer")) { + return true; + } + + // Moderators can take down articles + if (article.Status is ArticleStatus.Published && principal.IsInRole("Moderator")) { + return true; + } + + return false; + } +} \ No newline at end of file