diff --git a/website/MyWebApp.Tests/LayoutServiceTests.cs b/website/MyWebApp.Tests/LayoutServiceTests.cs index 906c6cb..b759b0f 100644 --- a/website/MyWebApp.Tests/LayoutServiceTests.cs +++ b/website/MyWebApp.Tests/LayoutServiceTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.Caching.Memory; using MyWebApp.Services; using System.Collections.Generic; +using Microsoft.AspNetCore.Http; using Xunit; public class LayoutServiceTests @@ -12,8 +13,9 @@ public void CanReadZonesFromConfig() var config = new ConfigurationBuilder().Build(); var memory = new MemoryCache(new MemoryCacheOptions()); var cache = new CacheService(memory); - var tokens = new TokenRenderService(); - var service = new LayoutService(cache, tokens); + var accessor = new HttpContextAccessor(); + var tokens = new TokenRenderService(accessor); + var service = new LayoutService(cache, tokens, accessor); Assert.True(LayoutService.LayoutZones.ContainsKey("single-column")); Assert.Contains("sidebar", LayoutService.LayoutZones["two-column-sidebar"]); diff --git a/website/MyWebApp.Tests/NavigationTests.cs b/website/MyWebApp.Tests/NavigationTests.cs index 67b3227..7ead488 100644 --- a/website/MyWebApp.Tests/NavigationTests.cs +++ b/website/MyWebApp.Tests/NavigationTests.cs @@ -5,6 +5,7 @@ using MyWebApp.Data; using MyWebApp.Models; using MyWebApp.Services; +using Microsoft.AspNetCore.Http; using System.Text.RegularExpressions; using System.Threading.Tasks; using Xunit; @@ -23,7 +24,8 @@ public async Task PublishingPage_ShowsTitleOnceInHeader() context.Database.EnsureCreated(); var memory = new MemoryCache(new MemoryCacheOptions()); var cache = new CacheService(memory); - var tokens = new TokenRenderService(); + var accessor = new HttpContextAccessor(); + var tokens = new TokenRenderService(accessor); var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { @@ -32,7 +34,7 @@ public async Task PublishingPage_ShowsTitleOnceInHeader() {"Layouts:two-column-sidebar:1", "sidebar"} }) .Build(); - var layout = new LayoutService(cache, tokens); + var layout = new LayoutService(cache, tokens, accessor); context.Pages.Add(new Page { Slug = "about", Title = "About", Layout = "single-column", IsPublished = true }); context.SaveChanges(); diff --git a/website/MyWebApp.Tests/SanitizationTests.cs b/website/MyWebApp.Tests/SanitizationTests.cs index da3536a..188ce5d 100644 --- a/website/MyWebApp.Tests/SanitizationTests.cs +++ b/website/MyWebApp.Tests/SanitizationTests.cs @@ -23,7 +23,8 @@ private static (ApplicationDbContext ctx, LayoutService layout, ContentProcessin ctx.Database.EnsureCreated(); var memory = new MemoryCache(new MemoryCacheOptions()); var cache = new CacheService(memory); - var tokens = new TokenRenderService(); + var accessor = new HttpContextAccessor(); + var tokens = new TokenRenderService(accessor); var config = new ConfigurationBuilder() .AddInMemoryCollection(new Dictionary { @@ -32,7 +33,7 @@ private static (ApplicationDbContext ctx, LayoutService layout, ContentProcessin {"Layouts:two-column-sidebar:1", "sidebar"} }) .Build(); - var layout = new LayoutService(cache, tokens); + var layout = new LayoutService(cache, tokens, accessor); var sanitizer = new HtmlSanitizerService(); var content = new ContentProcessingService(sanitizer); return (ctx, layout, content, tokens, sanitizer); diff --git a/website/MyWebApp/Controllers/AdminRoleController.cs b/website/MyWebApp/Controllers/AdminRoleController.cs new file mode 100644 index 0000000..31da78d --- /dev/null +++ b/website/MyWebApp/Controllers/AdminRoleController.cs @@ -0,0 +1,96 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using MyWebApp.Data; +using MyWebApp.Filters; +using MyWebApp.Models; +using System.Linq; +using System.Threading.Tasks; + +namespace MyWebApp.Controllers; + +[RoleAuthorize("Admin")] +public class AdminRoleController : Controller +{ + private readonly ApplicationDbContext _db; + + public AdminRoleController(ApplicationDbContext db) + { + _db = db; + } + + public async Task Index() + { + var roles = await _db.Roles.AsNoTracking().OrderBy(r => r.Name).ToListAsync(); + return View(roles); + } + + public IActionResult Create() + { + return View(new Role()); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Create(Role model) + { + if (!ModelState.IsValid) return View(model); + _db.Roles.Add(model); + await _db.SaveChangesAsync(); + return RedirectToAction(nameof(Index)); + } + + public async Task Edit(int id) + { + var role = await _db.Roles + .Include(r => r.Permissions) + .FirstOrDefaultAsync(r => r.Id == id); + if (role == null) return NotFound(); + var permissions = await _db.Permissions.AsNoTracking().OrderBy(p => p.Name).ToListAsync(); + var vm = new RoleEditViewModel + { + Role = role, + SelectedPermissions = role.Permissions.Select(p => p.PermissionId).ToList() + }; + ViewBag.Permissions = permissions; + return View(vm); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task Edit(RoleEditViewModel model) + { + var role = await _db.Roles + .Include(r => r.Permissions) + .FirstOrDefaultAsync(r => r.Id == model.Role.Id); + if (role == null) return NotFound(); + role.Name = model.Role.Name; + _db.RolePermissions.RemoveRange(role.Permissions); + role.Permissions.Clear(); + foreach (var pid in model.SelectedPermissions.Distinct()) + { + role.Permissions.Add(new RolePermission { RoleId = role.Id, PermissionId = pid }); + } + await _db.SaveChangesAsync(); + return RedirectToAction(nameof(Index)); + } + + public async Task Delete(int id) + { + var role = await _db.Roles.FindAsync(id); + if (role == null) return NotFound(); + return View(role); + } + + [HttpPost, ActionName("Delete")] + [ValidateAntiForgeryToken] + public async Task DeleteConfirmed(int id) + { + var role = await _db.Roles.FindAsync(id); + if (role != null) + { + _db.Roles.Remove(role); + await _db.SaveChangesAsync(); + } + return RedirectToAction(nameof(Index)); + } +} diff --git a/website/MyWebApp/Models/AdminModels.cs b/website/MyWebApp/Models/AdminModels.cs index 7b98518..86273b7 100644 --- a/website/MyWebApp/Models/AdminModels.cs +++ b/website/MyWebApp/Models/AdminModels.cs @@ -40,4 +40,10 @@ public class FileStatsViewModel public DownloadFile File { get; set; } = new DownloadFile(); public int DownloadCount { get; set; } } + + public class RoleEditViewModel + { + public Role Role { get; set; } = new Role(); + public IList SelectedPermissions { get; set; } = new List(); + } } diff --git a/website/MyWebApp/Services/LayoutService.cs b/website/MyWebApp/Services/LayoutService.cs index 2e0ed82..09ffa7a 100644 --- a/website/MyWebApp/Services/LayoutService.cs +++ b/website/MyWebApp/Services/LayoutService.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Http; using System.Linq; using MyWebApp.Data; @@ -8,6 +9,7 @@ public class LayoutService { private readonly CacheService _cache; private readonly TokenRenderService _tokens; + private readonly IHttpContextAccessor _accessor; private const string HeaderKey = "layout_header"; private const string FooterKey = "layout_footer"; @@ -27,50 +29,94 @@ public static string[] GetZones(string layout) return LayoutZones.TryGetValue(layout, out var zones) ? zones : Array.Empty(); } - public LayoutService(CacheService cache, TokenRenderService tokens) + public LayoutService(CacheService cache, TokenRenderService tokens, IHttpContextAccessor accessor) { _cache = cache; _tokens = tokens; + _accessor = accessor; + } + + private string[] GetRoles() + { + var roles = _accessor.HttpContext?.Session.GetString("Roles"); + return string.IsNullOrWhiteSpace(roles) ? Array.Empty() : roles.Split(','); + } + + private async Task> GetAllowedPermissionsAsync(ApplicationDbContext db, string[] roles) + { + if (roles.Length == 0) return new List(); + return await db.RolePermissions.AsNoTracking() + .Where(rp => roles.Contains(rp.Role!.Name)) + .Select(rp => rp.PermissionId) + .Distinct() + .ToListAsync(); } public async Task GetHeaderAsync(ApplicationDbContext db) { - return await _cache.GetOrCreateAsync(HeaderKey, async e => + var roles = GetRoles(); + if (roles.Length == 0) { - e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); - var parts = await db.PageSections.AsNoTracking() - .Where(s => s.Page.Slug == "layout" && s.Zone == "header") - .OrderBy(s => s.SortOrder) - .Select(s => s.Html) - .ToListAsync(); - var html = string.Join(System.Environment.NewLine, parts); - return await _tokens.RenderAsync(db, html); - }); + return await _cache.GetOrCreateAsync(HeaderKey, async e => + { + e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); + var parts = await db.PageSections.AsNoTracking() + .Where(s => s.Page.Slug == "layout" && s.Zone == "header" && s.PermissionId == null) + .OrderBy(s => s.SortOrder) + .Select(s => s.Html) + .ToListAsync(); + var html = string.Join(System.Environment.NewLine, parts); + return await _tokens.RenderAsync(db, html); + }); + } + + var allowed = await GetAllowedPermissionsAsync(db, roles); + var query = db.PageSections.AsNoTracking() + .Where(s => s.Page.Slug == "layout" && s.Zone == "header"); + query = query.Where(s => s.PermissionId == null || allowed.Contains(s.PermissionId.Value)); + var parts2 = await query.OrderBy(s => s.SortOrder).Select(s => s.Html).ToListAsync(); + var html2 = string.Join(System.Environment.NewLine, parts2); + return await _tokens.RenderAsync(db, html2); } public async Task GetFooterAsync(ApplicationDbContext db) { - return await _cache.GetOrCreateAsync(FooterKey, async e => + var roles = GetRoles(); + if (roles.Length == 0) { - e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); - var parts = await db.PageSections.AsNoTracking() - .Where(s => s.Page.Slug == "layout" && s.Zone == "footer") - .OrderBy(s => s.SortOrder) - .Select(s => s.Html) - .ToListAsync(); - var html = string.Join(System.Environment.NewLine, parts); - return await _tokens.RenderAsync(db, html); - }); + return await _cache.GetOrCreateAsync(FooterKey, async e => + { + e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5); + var parts = await db.PageSections.AsNoTracking() + .Where(s => s.Page.Slug == "layout" && s.Zone == "footer" && s.PermissionId == null) + .OrderBy(s => s.SortOrder) + .Select(s => s.Html) + .ToListAsync(); + var html = string.Join(System.Environment.NewLine, parts); + return await _tokens.RenderAsync(db, html); + }); + } + + var allowed = await GetAllowedPermissionsAsync(db, roles); + var query = db.PageSections.AsNoTracking() + .Where(s => s.Page.Slug == "layout" && s.Zone == "footer"); + query = query.Where(s => s.PermissionId == null || allowed.Contains(s.PermissionId.Value)); + var parts2 = await query.OrderBy(s => s.SortOrder).Select(s => s.Html).ToListAsync(); + var html2 = string.Join(System.Environment.NewLine, parts2); + return await _tokens.RenderAsync(db, html2); } public async Task GetSectionAsync(ApplicationDbContext db, int pageId, string zone) { - - var parts = await db.PageSections.AsNoTracking() - .Where(s => s.PageId == pageId && s.Zone == zone) - .OrderBy(s => s.SortOrder) - .Select(s => s.Html) - .ToListAsync(); + var roles = GetRoles(); + var allowed = await GetAllowedPermissionsAsync(db, roles); + var query = db.PageSections.AsNoTracking() + .Where(s => s.PageId == pageId && s.Zone == zone); + if (allowed.Count == 0) + query = query.Where(s => s.PermissionId == null); + else + query = query.Where(s => s.PermissionId == null || allowed.Contains(s.PermissionId.Value)); + var parts = await query.OrderBy(s => s.SortOrder).Select(s => s.Html).ToListAsync(); var html = string.Join(System.Environment.NewLine, parts); return await _tokens.RenderAsync(db, html); diff --git a/website/MyWebApp/Services/TokenRenderService.cs b/website/MyWebApp/Services/TokenRenderService.cs index cc6f4fb..9d0396d 100644 --- a/website/MyWebApp/Services/TokenRenderService.cs +++ b/website/MyWebApp/Services/TokenRenderService.cs @@ -1,5 +1,6 @@ using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; +using Microsoft.AspNetCore.Http; using System.Linq; using MyWebApp.Data; @@ -8,6 +9,28 @@ namespace MyWebApp.Services; public class TokenRenderService { private static readonly Regex TokenRegex = new(@"\{\{(block|section):([^{}]+)\}\}|\{\{nav\}\}", RegexOptions.Compiled | RegexOptions.IgnoreCase); + private readonly IHttpContextAccessor _accessor; + + public TokenRenderService(IHttpContextAccessor accessor) + { + _accessor = accessor; + } + + private string[] GetRoles() + { + var roles = _accessor.HttpContext?.Session.GetString("Roles"); + return string.IsNullOrWhiteSpace(roles) ? Array.Empty() : roles.Split(','); + } + + private async Task> GetAllowedPermissionsAsync(ApplicationDbContext db, string[] roles) + { + if (roles.Length == 0) return new List(); + return await db.RolePermissions.AsNoTracking() + .Where(rp => roles.Contains(rp.Role!.Name)) + .Select(rp => rp.PermissionId) + .Distinct() + .ToListAsync(); + } public Task RenderAsync(ApplicationDbContext db, string html) { @@ -51,8 +74,15 @@ async Task Replace(Match match) if (parts.Length == 2 && int.TryParse(parts[0], out var pageId)) { var zone = parts[1]; - var htmlParts = await db.PageSections.AsNoTracking() - .Where(s => s.PageId == pageId && s.Zone == zone) + var roles = GetRoles(); + var allowed = await GetAllowedPermissionsAsync(db, roles); + var query = db.PageSections.AsNoTracking() + .Where(s => s.PageId == pageId && s.Zone == zone); + if (allowed.Count == 0) + query = query.Where(s => s.PermissionId == null); + else + query = query.Where(s => s.PermissionId == null || allowed.Contains(s.PermissionId.Value)); + var htmlParts = await query .OrderBy(s => s.SortOrder) .Select(s => s.Html) .ToListAsync(); diff --git a/website/MyWebApp/Views/Admin/_AdminLayout.cshtml b/website/MyWebApp/Views/Admin/_AdminLayout.cshtml index 1dbfc7e..538b4a3 100644 --- a/website/MyWebApp/Views/Admin/_AdminLayout.cshtml +++ b/website/MyWebApp/Views/Admin/_AdminLayout.cshtml @@ -28,6 +28,7 @@ Files Media Blocks + Roles Pages Logout diff --git a/website/MyWebApp/Views/AdminRole/Create.cshtml b/website/MyWebApp/Views/AdminRole/Create.cshtml new file mode 100644 index 0000000..e24893f --- /dev/null +++ b/website/MyWebApp/Views/AdminRole/Create.cshtml @@ -0,0 +1,10 @@ +@model MyWebApp.Models.Role +@{ + ViewData["Title"] = "Create Role"; + Layout = "../Admin/_AdminLayout"; +} +

Create Role

+
+
+ +
diff --git a/website/MyWebApp/Views/AdminRole/Delete.cshtml b/website/MyWebApp/Views/AdminRole/Delete.cshtml new file mode 100644 index 0000000..64bca4a --- /dev/null +++ b/website/MyWebApp/Views/AdminRole/Delete.cshtml @@ -0,0 +1,12 @@ +@model MyWebApp.Models.Role +@{ + ViewData["Title"] = "Delete Role"; + Layout = "../Admin/_AdminLayout"; +} +

Delete Role

+
+ +

Are you sure you want to delete "@Model.Name"?

+ | + Cancel +
diff --git a/website/MyWebApp/Views/AdminRole/Edit.cshtml b/website/MyWebApp/Views/AdminRole/Edit.cshtml new file mode 100644 index 0000000..0e2b95d --- /dev/null +++ b/website/MyWebApp/Views/AdminRole/Edit.cshtml @@ -0,0 +1,21 @@ +@model MyWebApp.Models.RoleEditViewModel +@{ + ViewData["Title"] = "Edit Role"; + Layout = "../Admin/_AdminLayout"; + var permissions = ViewBag.Permissions as List; +} +

Edit Role

+
+ +
+
+ + @foreach (var p in permissions) + { +
+ @p.Name +
+ } +
+ +
diff --git a/website/MyWebApp/Views/AdminRole/Index.cshtml b/website/MyWebApp/Views/AdminRole/Index.cshtml new file mode 100644 index 0000000..5b94e09 --- /dev/null +++ b/website/MyWebApp/Views/AdminRole/Index.cshtml @@ -0,0 +1,20 @@ +@model IEnumerable +@{ + ViewData["Title"] = "Roles"; + Layout = "../Admin/_AdminLayout"; +} +

Roles

+

Create New

+ + + +@foreach (var r in Model) +{ + + + + + +} + +
Name
@r.NameEditDelete