diff --git a/JustBigO(Fun).Tests/Controllers/HomeControllerTests.cs b/JustBigO(Fun).Tests/Controllers/HomeControllerTests.cs index 18e6273..0a42306 100644 --- a/JustBigO(Fun).Tests/Controllers/HomeControllerTests.cs +++ b/JustBigO(Fun).Tests/Controllers/HomeControllerTests.cs @@ -1,6 +1,7 @@ using JustBigO_Fun_.Controllers; using JustBigO_Fun_.Data; using JustBigO_Fun_.Models; +using JustBigO_Fun_.Services; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; @@ -33,7 +34,7 @@ public async Task Index_ReturnsViewWithProblems() db.SaveChanges(); var mockLogger = new Mock>(); - var controller = new HomeController(mockLogger.Object, db); + var controller = new HomeController(mockLogger.Object, db, new MarkdownRenderer(new HtmlSanitizerService())); // Mocking User identity var user = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity()); @@ -61,7 +62,7 @@ public async Task Index_FiltersByDifficulty() db.SaveChanges(); var mockLogger = new Mock>(); - var controller = new HomeController(mockLogger.Object, db); + var controller = new HomeController(mockLogger.Object, db, new MarkdownRenderer(new HtmlSanitizerService())); // Mocking User identity var user = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity()); @@ -86,7 +87,7 @@ public async Task Solve_ReturnsNotFound_WhenProblemDoesNotExist() // Arrange using var db = new ApplicationDbContext(_options); var mockLogger = new Mock>(); - var controller = new HomeController(mockLogger.Object, db); + var controller = new HomeController(mockLogger.Object, db, new MarkdownRenderer(new HtmlSanitizerService())); // Act var result = await controller.Solve(999); diff --git a/JustBigO(Fun)/Areas/Admin/Views/Dashboard/Index.cshtml b/JustBigO(Fun)/Areas/Admin/Views/Dashboard/Index.cshtml new file mode 100644 index 0000000..0672945 --- /dev/null +++ b/JustBigO(Fun)/Areas/Admin/Views/Dashboard/Index.cshtml @@ -0,0 +1,58 @@ +@model AdminDashboardVm +@{ + ViewData["Title"] = "Admin dashboard"; +} + +
+
+
+

Admin dashboard

+

Manage problems, test cases and user access.

+
+ ← Back to site +
+ +
+
+
+
@Model.ProblemCount
+
Problems
+
+
+
+
+
@Model.TestCount
+
Test cases
+
+
+
+
+
@Model.SubmissionCount
+
Submissions
+
+
+
+
+
@Model.UserCount
+
Users (@Model.AdminCount admin@(Model.AdminCount == 1 ? "" : "s"))
+
+
+
+ +
+
+
+
Problems
+

Create, edit and delete problems, manage code templates and test cases.

+ Manage problems → +
+
+
+
+
Users
+

View registered users and promote or demote their roles.

+ Manage users → +
+
+
+
diff --git a/JustBigO(Fun)/Areas/Admin/Views/Problems/Create.cshtml b/JustBigO(Fun)/Areas/Admin/Views/Problems/Create.cshtml index 6ae93d0..47c824d 100644 --- a/JustBigO(Fun)/Areas/Admin/Views/Problems/Create.cshtml +++ b/JustBigO(Fun)/Areas/Admin/Views/Problems/Create.cshtml @@ -4,7 +4,10 @@ }
-

New problem

+
+

New problem

+ ← Back to problems +
@@ -18,7 +21,7 @@
-
+
-
- - -
-
+
+
- - -
-
- - - Ex: {"python":"def solve():...","java":"class Solution{...}","cpp":"..."} + +
+ +
-
Test files (.in / .out) – hidden from users
-

File content is stored in the database (not the files themselves). For multiple tests: select ALL .in files in the first field and ALL .out files in the second (Ctrl+Click). Pairing: 1st .in ↔ 1st .out, 2nd ↔ 2nd, etc.

+
Test files (.in / .out)
+

Select ALL .in files in the first field and ALL .out files in the second (Ctrl+Click). Pairing: 1st .in ↔ 1st .out, 2nd ↔ 2nd, etc.

@@ -59,6 +56,20 @@
+
+
Add a test case
+
+
+ + +
+
+ + +
+
+
+
Cancel @@ -66,3 +77,8 @@
+ +@section Scripts { + + +} diff --git a/JustBigO(Fun)/Areas/Admin/Views/Problems/Edit.cshtml b/JustBigO(Fun)/Areas/Admin/Views/Problems/Edit.cshtml index ea0419a..676b554 100644 --- a/JustBigO(Fun)/Areas/Admin/Views/Problems/Edit.cshtml +++ b/JustBigO(Fun)/Areas/Admin/Views/Problems/Edit.cshtml @@ -4,7 +4,19 @@ }
-

Edit: @Model.Title

+
+

Edit: @Model.Title

+ ← Back to problems +
+ + @if (TempData["AdminMessage"] is string okMsg) + { +
@okMsg
+ } + @if (TempData["AdminError"] is string errMsg) + { +
@errMsg
+ }
@@ -28,25 +40,23 @@
- - + +
- - -
-
- - + +
+ +
-
Add new tests (.in / .out)
-

@Model.ExistingTestCount existing tests. To add more: select ALL new .in files and ALL new .out files (Ctrl+Click). Pairing: 1st .in ↔ 1st .out, 2nd ↔ 2nd, etc.

+
Bulk add tests from files (.in / .out)
+

Select ALL new .in files and ALL new .out files (Ctrl+Click). Pairing: 1st .in ↔ 1st .out, 2nd ↔ 2nd, etc. Uploaded tests are appended to the list below after saving.

@@ -65,4 +75,68 @@
+ + @* Test case management lives outside the main form because HTML forms cannot be nested. *@ +
+
Test cases (@Model.Tests.Count)
+ + @if (Model.Tests.Count == 0) + { +

No test cases yet. Add one below.

+ } + else + { +
+ @foreach (var t in Model.Tests) + { +
+
+ Test #@t.OrderIndex +
+ @Html.AntiForgeryToken() + +
+
+
+ @Html.AntiForgeryToken() +
+
+ + +
+
+ + +
+
+ +
+
+ } +
+ } + +
+
Add a test case
+
+ @Html.AntiForgeryToken() +
+
+ + +
+
+ + +
+
+ +
+
+
+ +@section Scripts { + + +} diff --git a/JustBigO(Fun)/Areas/Admin/Views/Problems/Index.cshtml b/JustBigO(Fun)/Areas/Admin/Views/Problems/Index.cshtml index a6f106f..681d323 100644 --- a/JustBigO(Fun)/Areas/Admin/Views/Problems/Index.cshtml +++ b/JustBigO(Fun)/Areas/Admin/Views/Problems/Index.cshtml @@ -22,11 +22,10 @@ - @{ var i = 1; } @foreach (var p in Model ?? Enumerable.Empty()) { - @(i++) + @p.OrderIndex @p.Title @p.Slug @p.Difficulty @@ -45,6 +44,6 @@

- ← Back to Dashboard + ← Back to admin dashboard

diff --git a/JustBigO(Fun)/Areas/Admin/Views/Problems/_CodeTemplatesEditor.cshtml b/JustBigO(Fun)/Areas/Admin/Views/Problems/_CodeTemplatesEditor.cshtml new file mode 100644 index 0000000..bbc5855 --- /dev/null +++ b/JustBigO(Fun)/Areas/Admin/Views/Problems/_CodeTemplatesEditor.cshtml @@ -0,0 +1,36 @@ +@model ProblemEditVm +@{ + // Unique suffix so multiple instances / future reuse don't collide on element ids. + var tabId = "tmpl-" + (Model.Id == 0 ? "new" : Model.Id.ToString()); +} + +
+
Code templates
+ + + +
+
+ +
+
+
+ +
+
+
+ +
+
+
+
diff --git a/JustBigO(Fun)/Areas/Admin/Views/Problems/_CodeTemplatesScripts.cshtml b/JustBigO(Fun)/Areas/Admin/Views/Problems/_CodeTemplatesScripts.cshtml new file mode 100644 index 0000000..49d321b --- /dev/null +++ b/JustBigO(Fun)/Areas/Admin/Views/Problems/_CodeTemplatesScripts.cshtml @@ -0,0 +1,70 @@ +@* Syntax highlighting for the per-language code templates using Monaco (same editor as /solve). *@ + + + + diff --git a/JustBigO(Fun)/Areas/Admin/Views/Problems/_MarkdownEditorAssets.cshtml b/JustBigO(Fun)/Areas/Admin/Views/Problems/_MarkdownEditorAssets.cshtml new file mode 100644 index 0000000..3f1b776 --- /dev/null +++ b/JustBigO(Fun)/Areas/Admin/Views/Problems/_MarkdownEditorAssets.cshtml @@ -0,0 +1,87 @@ +@* Loads the EasyMDE Markdown editor (dark-themed) and binds it to the Description textarea (id="Description"). *@ + + + + + + + diff --git a/JustBigO(Fun)/Areas/Admin/Views/Users/Index.cshtml b/JustBigO(Fun)/Areas/Admin/Views/Users/Index.cshtml new file mode 100644 index 0000000..80e2680 --- /dev/null +++ b/JustBigO(Fun)/Areas/Admin/Views/Users/Index.cshtml @@ -0,0 +1,82 @@ +@model UsersIndexVm +@{ + ViewData["Title"] = "Manage users"; +} + +
+
+

Manage users

+ ← Admin dashboard +
+ + @if (TempData["AdminMessage"] is string okMsg) + { +
@okMsg
+ } + @if (TempData["AdminError"] is string errMsg) + { +
@errMsg
+ } + +
+ + + + + + + + + + + @foreach (var u in Model.Users) + { + + + + + + + } + +
EmailUsernameCurrent roleRole management
+ @u.Email + @if (u.IsCurrentUser) + { + you + } + @u.UserName + @u.Role + + @if (u.IsCurrentUser) + { + You cannot change your own role. + } + else + { +
+ @Html.AntiForgeryToken() + + + +
+ } +
+
+ +

+ ← Back to admin dashboard +

+
diff --git a/JustBigO(Fun)/Controllers/Admin/DashboardController.cs b/JustBigO(Fun)/Controllers/Admin/DashboardController.cs new file mode 100644 index 0000000..b8e2e82 --- /dev/null +++ b/JustBigO(Fun)/Controllers/Admin/DashboardController.cs @@ -0,0 +1,45 @@ +using JustBigO_Fun_.Data; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace JustBigO_Fun_.Controllers.Admin; + +[Authorize(Roles = AdminSeeder.AdminRole)] +[Area("Admin")] +[Route("Admin")] +public class DashboardController : Controller +{ + private readonly ApplicationDbContext _db; + private readonly UserManager _userManager; + + public DashboardController(ApplicationDbContext db, UserManager userManager) + { + _db = db; + _userManager = userManager; + } + + [HttpGet] + public async Task Index(CancellationToken ct) + { + var vm = new AdminDashboardVm + { + ProblemCount = await _db.Problems.CountAsync(ct), + TestCount = await _db.ProblemTests.CountAsync(ct), + SubmissionCount = await _db.Submissions.CountAsync(ct), + UserCount = await _userManager.Users.CountAsync(ct), + AdminCount = (await _userManager.GetUsersInRoleAsync(AdminSeeder.AdminRole)).Count + }; + return View(vm); + } +} + +public class AdminDashboardVm +{ + public int ProblemCount { get; set; } + public int TestCount { get; set; } + public int SubmissionCount { get; set; } + public int UserCount { get; set; } + public int AdminCount { get; set; } +} diff --git a/JustBigO(Fun)/Controllers/Admin/ProblemsController.cs b/JustBigO(Fun)/Controllers/Admin/ProblemsController.cs index 56439b6..ba518ce 100644 --- a/JustBigO(Fun)/Controllers/Admin/ProblemsController.cs +++ b/JustBigO(Fun)/Controllers/Admin/ProblemsController.cs @@ -1,9 +1,9 @@ using System.ComponentModel.DataAnnotations; +using System.Text.Json; using JustBigO_Fun_.Data; using JustBigO_Fun_.Models; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.EntityFrameworkCore; namespace JustBigO_Fun_.Controllers.Admin; @@ -31,7 +31,7 @@ public async Task Index() } [HttpGet("Create")] - public IActionResult Create() => View(new ProblemEditVm()); + public IActionResult Create() => View(ProblemEditVm.WithDefaultTemplates()); [HttpPost("Create")] [ValidateAntiForgeryToken] @@ -46,19 +46,34 @@ public async Task Create(ProblemEditVm vm, CancellationToken ct) { Title = vm.Title, Slug = vm.Slug, + // Stored as raw Markdown; rendered to sanitized HTML at display time. Description = vm.Description ?? "", Difficulty = vm.Difficulty ?? "Easy", Tags = vm.Tags ?? "", - CodeTemplatesJson = vm.CodeTemplatesJson ?? "{}", - OrderIndex = vm.OrderIndex + CodeTemplatesJson = vm.BuildCodeTemplatesJson(), + // OrderIndex is managed by the backend, not entered by the admin. + OrderIndex = await NextOrderIndexAsync(ct) }; _db.Problems.Add(problem); await _db.SaveChangesAsync(ct); + if (!string.IsNullOrWhiteSpace(vm.FirstTestInput) && !string.IsNullOrWhiteSpace(vm.FirstTestExpectedOutput)) + { + _db.ProblemTests.Add(new ProblemTest + { + ProblemId = problem.Id, + InputJson = vm.FirstTestInput, + ExpectedOutputJson = vm.FirstTestExpectedOutput.TrimEnd(), + OrderIndex = 1 + }); + await _db.SaveChangesAsync(ct); + } + if (vm.InFiles?.Any() == true && vm.OutFiles?.Any() == true) await SaveTestFilesAsync(problem.Id, vm.InFiles, vm.OutFiles, ct); - return RedirectToAction(nameof(Index)); + await _db.SaveChangesAsync(ct); + return RedirectToAction(nameof(Edit), new { id = problem.Id }); } return View(vm); } @@ -71,18 +86,7 @@ public async Task Edit(int id, CancellationToken ct) .FirstOrDefaultAsync(x => x.Id == id, ct); if (p == null) return NotFound(); - var vm = new ProblemEditVm - { - Id = p.Id, - Title = p.Title, - Slug = p.Slug, - Description = p.Description, - Difficulty = p.Difficulty, - Tags = p.Tags, - CodeTemplatesJson = p.CodeTemplatesJson, - OrderIndex = p.OrderIndex, - ExistingTestCount = p.Tests.Count - }; + var vm = BuildEditVm(p); return View(vm); } @@ -92,7 +96,7 @@ public async Task Edit(int id, ProblemEditVm vm, CancellationToke { if (id != vm.Id) return BadRequest(); var existing = await _db.Problems - .Include(x => x.Tests) + .Include(x => x.Tests.OrderBy(t => t.OrderIndex)) .FirstOrDefaultAsync(x => x.Id == id, ct); if (existing == null) return NotFound(); @@ -103,19 +107,24 @@ public async Task Edit(int id, ProblemEditVm vm, CancellationToke { existing.Title = vm.Title; existing.Slug = vm.Slug; + // Stored as raw Markdown; rendered to sanitized HTML at display time. existing.Description = vm.Description ?? ""; existing.Difficulty = vm.Difficulty ?? "Easy"; existing.Tags = vm.Tags ?? ""; - existing.CodeTemplatesJson = vm.CodeTemplatesJson ?? "{}"; - existing.OrderIndex = vm.OrderIndex; + existing.CodeTemplatesJson = vm.BuildCodeTemplatesJson(); + // OrderIndex is intentionally not updated here; it is backend-managed. if (vm.InFiles?.Any() == true && vm.OutFiles?.Any() == true) await SaveTestFilesAsync(existing.Id, vm.InFiles, vm.OutFiles, ct); await _db.SaveChangesAsync(ct); - return RedirectToAction(nameof(Index)); + TempData["AdminMessage"] = "Problem saved."; + return RedirectToAction(nameof(Edit), new { id = existing.Id }); } - vm.ExistingTestCount = existing.Tests.Count; + + // Re-populate the read-only/list portions of the VM before redisplaying the form. + vm.OrderIndex = existing.OrderIndex; + vm.Tests = existing.Tests.Select(ToTestVm).ToList(); return View(vm); } @@ -130,10 +139,107 @@ public async Task Delete(int id, CancellationToken ct) return RedirectToAction(nameof(Index)); } + // ---------- Test case CRUD ---------- + + [HttpPost("{problemId:int}/Tests/Add")] + [ValidateAntiForgeryToken] + public async Task AddTest(int problemId, string inputJson, string expectedOutputJson, CancellationToken ct) + { + var problem = await _db.Problems.FindAsync([problemId], ct); + if (problem == null) return NotFound(); + + if (string.IsNullOrWhiteSpace(inputJson) || string.IsNullOrWhiteSpace(expectedOutputJson)) + { + TempData["AdminError"] = "Both input and expected output are required to add a test."; + return RedirectToAction(nameof(Edit), new { id = problemId }); + } + + var maxOrder = await _db.ProblemTests + .Where(t => t.ProblemId == problemId) + .Select(t => (int?)t.OrderIndex) + .MaxAsync(ct) ?? 0; + + _db.ProblemTests.Add(new ProblemTest + { + ProblemId = problemId, + InputJson = inputJson, + ExpectedOutputJson = expectedOutputJson.TrimEnd(), + OrderIndex = maxOrder + 1 + }); + await _db.SaveChangesAsync(ct); + TempData["AdminMessage"] = "Test added."; + return RedirectToAction(nameof(Edit), new { id = problemId }); + } + + [HttpPost("{problemId:int}/Tests/{testId:int}/Edit")] + [ValidateAntiForgeryToken] + public async Task EditTest(int problemId, int testId, string inputJson, string expectedOutputJson, CancellationToken ct) + { + var test = await _db.ProblemTests.FirstOrDefaultAsync(t => t.Id == testId && t.ProblemId == problemId, ct); + if (test == null) return NotFound(); + + if (string.IsNullOrWhiteSpace(inputJson) || string.IsNullOrWhiteSpace(expectedOutputJson)) + { + TempData["AdminError"] = "Both input and expected output are required."; + return RedirectToAction(nameof(Edit), new { id = problemId }); + } + + test.InputJson = inputJson; + test.ExpectedOutputJson = expectedOutputJson.TrimEnd(); + await _db.SaveChangesAsync(ct); + TempData["AdminMessage"] = "Test updated."; + return RedirectToAction(nameof(Edit), new { id = problemId }); + } + + [HttpPost("{problemId:int}/Tests/{testId:int}/Delete")] + [ValidateAntiForgeryToken] + public async Task DeleteTest(int problemId, int testId, CancellationToken ct) + { + var test = await _db.ProblemTests.FirstOrDefaultAsync(t => t.Id == testId && t.ProblemId == problemId, ct); + if (test == null) return NotFound(); + + _db.ProblemTests.Remove(test); + await _db.SaveChangesAsync(ct); + TempData["AdminMessage"] = "Test deleted."; + return RedirectToAction(nameof(Edit), new { id = problemId }); + } + + // ---------- Helpers ---------- + + private async Task NextOrderIndexAsync(CancellationToken ct) + { + var max = await _db.Problems.Select(p => (int?)p.OrderIndex).MaxAsync(ct) ?? 0; + return max + 1; + } + + private ProblemEditVm BuildEditVm(Problem p) + { + var templates = p.GetCodeTemplates(); + return new ProblemEditVm + { + Id = p.Id, + Title = p.Title, + Slug = p.Slug, + Description = p.Description, + Difficulty = p.Difficulty, + Tags = p.Tags, + OrderIndex = p.OrderIndex, + PythonTemplate = templates.GetValueOrDefault("python", ""), + JavaTemplate = templates.GetValueOrDefault("java", ""), + CppTemplate = templates.GetValueOrDefault("cpp", ""), + Tests = p.Tests.OrderBy(t => t.OrderIndex).Select(ToTestVm).ToList() + }; + } + + private static ProblemTestVm ToTestVm(ProblemTest t) => + new() { Id = t.Id, OrderIndex = t.OrderIndex, InputJson = t.InputJson, ExpectedOutputJson = t.ExpectedOutputJson }; + private async Task SaveTestFilesAsync(int problemId, List inFiles, List outFiles, CancellationToken ct) { - var tests = _db.ProblemTests.Where(t => t.ProblemId == problemId).ToList(); - var maxOrder = tests.Count > 0 ? tests.Max(t => t.OrderIndex) : 0; + var maxOrder = await _db.ProblemTests + .Where(t => t.ProblemId == problemId) + .Select(t => (int?)t.OrderIndex) + .MaxAsync(ct) ?? 0; var inList = inFiles.OrderBy(f => f.FileName).ToList(); var outList = outFiles.OrderBy(f => f.FileName).ToList(); @@ -161,6 +267,14 @@ private async Task SaveTestFilesAsync(int problemId, List inFiles, Li public record ProblemListVm(int Id, string Title, string Slug, string Difficulty, int OrderIndex, int TestCount); +public class ProblemTestVm +{ + public int Id { get; set; } + public int OrderIndex { get; set; } + public string InputJson { get; set; } = ""; + public string ExpectedOutputJson { get; set; } = ""; +} + public class ProblemEditVm { public int Id { get; set; } @@ -182,10 +296,55 @@ public class ProblemEditVm [MaxLength(500)] public string Tags { get; set; } = ""; - public string CodeTemplatesJson { get; set; } = "{}"; + // Per-language code templates. The backend bundles these into CodeTemplatesJson on save, + // so admins never have to hand-write JSON. + [Display(Name = "Python template")] + public string? PythonTemplate { get; set; } + + [Display(Name = "Java template")] + public string? JavaTemplate { get; set; } + + [Display(Name = "C++ template")] + public string? CppTemplate { get; set; } + + /// Read-only; assigned automatically by the backend. public int OrderIndex { get; set; } public List? InFiles { get; set; } public List? OutFiles { get; set; } - public int ExistingTestCount { get; set; } + + // Optional single test case captured when creating a new problem. + public string? FirstTestInput { get; set; } + public string? FirstTestExpectedOutput { get; set; } + + /// Existing test cases for the problem (Edit view only). + public List Tests { get; set; } = new(); + + /// Bundles the per-language templates into the JSON shape the rest of the app expects. + public string BuildCodeTemplatesJson() + { + var map = new Dictionary(); + if (!string.IsNullOrWhiteSpace(PythonTemplate)) map["python"] = PythonTemplate; + if (!string.IsNullOrWhiteSpace(JavaTemplate)) map["java"] = JavaTemplate; + if (!string.IsNullOrWhiteSpace(CppTemplate)) map["cpp"] = CppTemplate; + return JsonSerializer.Serialize(map); + } + + /// Boilerplate starter code pre-filled when creating a new problem. + public const string DefaultPythonTemplate = + "import sys\n\ndef main():\n # read from stdin, write the answer to stdout\n pass\n\nif __name__ == \"__main__\":\n main()\n"; + + public const string DefaultJavaTemplate = + "import java.util.*;\n\npublic class Main {\n public static void main(String[] args) {\n Scanner sc = new Scanner(System.in);\n // read from stdin, print the answer to stdout\n }\n}\n"; + + public const string DefaultCppTemplate = + "#include \n#include \n\nusing namespace std;\n\nint main() {\n // read from stdin, print the answer to stdout\n return 0;\n}\n"; + + /// Creates a VM pre-populated with default per-language code templates. + public static ProblemEditVm WithDefaultTemplates() => new() + { + PythonTemplate = DefaultPythonTemplate, + JavaTemplate = DefaultJavaTemplate, + CppTemplate = DefaultCppTemplate + }; } diff --git a/JustBigO(Fun)/Controllers/Admin/UsersController.cs b/JustBigO(Fun)/Controllers/Admin/UsersController.cs new file mode 100644 index 0000000..7cb9f00 --- /dev/null +++ b/JustBigO(Fun)/Controllers/Admin/UsersController.cs @@ -0,0 +1,112 @@ +using JustBigO_Fun_.Data; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace JustBigO_Fun_.Controllers.Admin; + +[Authorize(Roles = AdminSeeder.AdminRole)] +[Area("Admin")] +[Route("Admin/[controller]")] +public class UsersController : Controller +{ + /// + /// Pseudo-role shown in the UI for a user that has no Identity role assigned. + /// This app uses a single-role-per-user model, so "no role" maps to a plain "User". + /// + public const string NoRole = "User"; + + private readonly UserManager _userManager; + private readonly RoleManager _roleManager; + + public UsersController(UserManager userManager, RoleManager roleManager) + { + _userManager = userManager; + _roleManager = roleManager; + } + + [HttpGet] + public async Task Index() + { + var currentUserId = _userManager.GetUserId(User); + + // "User" (no role) first, then every role defined in the system. + var available = new List { NoRole }; + available.AddRange(await _roleManager.Roles + .Where(r => r.Name != null) + .Select(r => r.Name!) + .OrderBy(name => name) + .ToListAsync()); + + var users = await _userManager.Users + .OrderBy(u => u.Email) + .ToListAsync(); + + var items = new List(); + foreach (var user in users) + { + var roles = await _userManager.GetRolesAsync(user); + items.Add(new UserListItemVm( + user.Id, + user.Email ?? "", + user.UserName ?? "", + roles.FirstOrDefault() ?? NoRole, + user.Id == currentUserId)); + } + + return View(new UsersIndexVm { Users = items, AvailableRoles = available }); + } + + [HttpPost("UpdateRole")] + [ValidateAntiForgeryToken] + public async Task UpdateRole(string userId, string role) + { + var user = await _userManager.FindByIdAsync(userId); + if (user == null) return NotFound(); + + // Admins cannot change their own role: avoids accidentally locking yourself out. + if (userId == _userManager.GetUserId(User)) + { + TempData["AdminError"] = "You cannot change your own role."; + return RedirectToAction(nameof(Index)); + } + + if (role != NoRole && !await _roleManager.RoleExistsAsync(role)) + { + TempData["AdminError"] = $"Role '{role}' does not exist."; + return RedirectToAction(nameof(Index)); + } + + var currentRoles = await _userManager.GetRolesAsync(user); + + // Never allow demoting the last remaining administrator. + if (currentRoles.Contains(AdminSeeder.AdminRole) && role != AdminSeeder.AdminRole) + { + var adminCount = (await _userManager.GetUsersInRoleAsync(AdminSeeder.AdminRole)).Count; + if (adminCount <= 1) + { + TempData["AdminError"] = "Cannot remove the last administrator."; + return RedirectToAction(nameof(Index)); + } + } + + // Single-role model: clear any existing roles, then assign the chosen one + // ("User" means no Identity role at all). + if (currentRoles.Count > 0) + await _userManager.RemoveFromRolesAsync(user, currentRoles); + if (role != NoRole) + await _userManager.AddToRoleAsync(user, role); + + TempData["AdminMessage"] = $"Updated {user.Email} to role \"{role}\"."; + return RedirectToAction(nameof(Index)); + } +} + +public record UserListItemVm(string Id, string Email, string UserName, string Role, bool IsCurrentUser); + +public class UsersIndexVm +{ + public List Users { get; set; } = new(); + public List AvailableRoles { get; set; } = new(); +} diff --git a/JustBigO(Fun)/Controllers/HomeController.cs b/JustBigO(Fun)/Controllers/HomeController.cs index adef60e..2fa0712 100644 --- a/JustBigO(Fun)/Controllers/HomeController.cs +++ b/JustBigO(Fun)/Controllers/HomeController.cs @@ -15,11 +15,13 @@ public class HomeController : Controller { private readonly ILogger _logger; private readonly ApplicationDbContext _db; + private readonly IMarkdownRenderer _markdown; - public HomeController(ILogger logger, ApplicationDbContext db) + public HomeController(ILogger logger, ApplicationDbContext db, IMarkdownRenderer markdown) { _logger = logger; _db = db; + _markdown = markdown; } // --- INCEPUT MODIFICARE --- @@ -109,6 +111,13 @@ public async Task Solve(int? id) if (problem == null) return RedirectToAction(nameof(Index)); } + + // Descriptions are authored in Markdown. Render to sanitized HTML before display + // (the view emits this via @Html.Raw). This is a read-only projection; the entity + // is not persisted from this action. Legacy HTML-authored descriptions still render + // correctly because Markdig passes raw HTML through, and the output is sanitized. + problem.Description = _markdown.RenderToSafeHtml(problem.Description); + return View(problem); } diff --git a/JustBigO(Fun)/Data/ProblemSeeder.cs b/JustBigO(Fun)/Data/ProblemSeeder.cs index 9ef86f1..741d994 100644 --- a/JustBigO(Fun)/Data/ProblemSeeder.cs +++ b/JustBigO(Fun)/Data/ProblemSeeder.cs @@ -1,3 +1,4 @@ +using System.Text.RegularExpressions; using JustBigO_Fun_.Models; using Microsoft.EntityFrameworkCore; @@ -5,6 +6,91 @@ namespace JustBigO_Fun_.Data; public static class ProblemSeeder { + // Problem statements are authored in Markdown. These constants are also used to migrate + // the original HTML-authored descriptions to Markdown on startup (see LooksLikeHtml usage below). + private const string TwoSumDescription = """ + Given an array of integers `nums` and an integer `target`, return indices of the two numbers such that they add up to `target`. + + You may assume that each input would have **exactly one solution**, and you may not use the same element twice. + + **Input Format:** Line 1: N (number of elements). Line 2: N space-separated integers. Line 3: target integer. + + **Output Format:** Two space-separated indices. + + **Example** + + Input: + ```text + 4 + 2 7 11 15 + 9 + ``` + Output: + ```text + 0 1 + ``` + """; + + private const string LevelOrderDescription = """ + Given the `root` of a binary tree, return the level order traversal of its nodes' values. + + **Input Format:** Line 1: N (number of nodes). Line 2: N space-separated values representing the level-order traversal (use `null` for empty nodes). + + **Output Format:** Print each level on a new line, space-separated. + + **Example** + + Input: + ```text + 7 + 3 9 20 null null 15 7 + ``` + Output: + ```text + 3 + 9 20 + 15 7 + ``` + """; + + private const string MinWindowDescription = """ + Given two strings `s` and `t`, return the minimum window substring of `s` such that every character in `t` (including duplicates) is included in the window. + + If there is no such substring, return the empty string. + + **Input Format:** Line 1: string s. Line 2: string t. + + **Output Format:** The substring (or empty line). + + **Example** + + Input: + ```text + ADOBECODEBANC + ABC + ``` + Output: + ```text + BANC + ``` + """; + + /// Heuristic: does the description still contain raw HTML markup (legacy format)? + private static bool LooksLikeHtml(string? text) => + !string.IsNullOrWhiteSpace(text) && Regex.IsMatch(text, "<[a-zA-Z][^>]*>"); + + /// + /// True if a seeded description should be refreshed to the canonical Markdown: either it is still + /// raw HTML, or it is an earlier auto-generated form that placed Input:/Output: inside a single + /// code fence. Admin-authored Markdown in the current format is left untouched. + /// + private static bool NeedsDescriptionMigration(string? text) + { + if (string.IsNullOrWhiteSpace(text)) return false; + if (LooksLikeHtml(text)) return true; + return Regex.IsMatch(text, "```text\\s*\\r?\\nInput:"); + } + public static async Task SeedAsync(ApplicationDbContext db) { // If problems exist, we still want to ensure MethodNames are set for this feature @@ -15,6 +101,7 @@ public static async Task SeedAsync(ApplicationDbContext db) { if (string.IsNullOrEmpty(p1.MethodName)) p1.MethodName = "two_sum"; p1.SignatureJson = "{\"parameters\":[{\"name\":\"nums\",\"type\":\"int[]\"},{\"name\":\"target\",\"type\":\"int\"}],\"returnType\":\"int[]\"}"; + if (NeedsDescriptionMigration(p1.Description)) p1.Description = TwoSumDescription; } var p2 = await db.Problems.FirstOrDefaultAsync(p => p.Slug == "binary-tree-level-order"); @@ -22,6 +109,7 @@ public static async Task SeedAsync(ApplicationDbContext db) { if (string.IsNullOrEmpty(p2.MethodName)) p2.MethodName = "level_order"; p2.SignatureJson = "{\"parameters\":[{\"name\":\"root\",\"type\":\"TreeNode\"}],\"returnType\":\"int[][]\"}"; + if (NeedsDescriptionMigration(p2.Description)) p2.Description = LevelOrderDescription; } var p3 = await db.Problems.FirstOrDefaultAsync(p => p.Slug == "minimum-window-substring"); @@ -29,8 +117,9 @@ public static async Task SeedAsync(ApplicationDbContext db) { if (string.IsNullOrEmpty(p3.MethodName)) p3.MethodName = "min_window"; p3.SignatureJson = "{\"parameters\":[{\"name\":\"s\",\"type\":\"string\"},{\"name\":\"t\",\"type\":\"string\"}],\"returnType\":\"string\"}"; + if (NeedsDescriptionMigration(p3.Description)) p3.Description = MinWindowDescription; } - + await db.SaveChangesAsync(); return; } @@ -51,16 +140,7 @@ public static async Task SeedAsync(ApplicationDbContext db) OrderIndex = 1, MethodName = "two_sum", SignatureJson = "{\"parameters\":[{\"name\":\"nums\",\"type\":\"int[]\"},{\"name\":\"target\",\"type\":\"int\"}],\"returnType\":\"int[]\"}", - Description = """ -

Given an array of integers nums and an integer target, return indices of the two numbers such that they add up to target.

-

You may assume that each input would have exactly one solution, and you may not use the same element twice.

-

Input Format: Line 1: N (number of elements). Line 2: N space-separated integers. Line 3: target integer.

-

Output Format: Two space-separated indices.

-
- Input:
4
2 7 11 15
9
- Output: 0 1 -
- """, + Description = TwoSumDescription, CodeTemplatesJson = twoSumTemplates }; @@ -80,15 +160,7 @@ public static async Task SeedAsync(ApplicationDbContext db) OrderIndex = 2, MethodName = "level_order", SignatureJson = "{\"parameters\":[{\"name\":\"root\",\"type\":\"TreeNode\"}],\"returnType\":\"int[][]\"}", - Description = """ -

Given the root of a binary tree, return the level order traversal of its nodes' values.

-

Input Format: Line 1: N (number of nodes). Line 2: N space-separated values representing the level-order traversal (use 'null' for empty nodes).

-

Output Format: Print each level on a new line, space-separated.

-
- Input:
7
3 9 20 null null 15 7
- Output:
3
9 20
15 7 -
- """, + Description = LevelOrderDescription, CodeTemplatesJson = levelOrderTemplates }; @@ -108,16 +180,7 @@ public static async Task SeedAsync(ApplicationDbContext db) OrderIndex = 3, MethodName = "min_window", SignatureJson = "{\"parameters\":[{\"name\":\"s\",\"type\":\"string\"},{\"name\":\"t\",\"type\":\"string\"}],\"returnType\":\"string\"}", - Description = """ -

Given two strings s and t, return the minimum window substring of s such that every character in t (including duplicates) is included in the window.

-

If there is no such substring, return the empty string.

-

Input Format: Line 1: string s. Line 2: string t.

-

Output Format: The substring (or empty line).

-
- Input:
ADOBECODEBANC
ABC
- Output: BANC -
- """, + Description = MinWindowDescription, CodeTemplatesJson = minWindowTemplates }; diff --git a/JustBigO(Fun)/JustBigO(Fun).csproj b/JustBigO(Fun)/JustBigO(Fun).csproj index e0c0bd4..fbfdd4c 100644 --- a/JustBigO(Fun)/JustBigO(Fun).csproj +++ b/JustBigO(Fun)/JustBigO(Fun).csproj @@ -24,6 +24,8 @@ + + diff --git a/JustBigO(Fun)/Program.cs b/JustBigO(Fun)/Program.cs index ec933e0..cc7a074 100644 --- a/JustBigO(Fun)/Program.cs +++ b/JustBigO(Fun)/Program.cs @@ -26,6 +26,10 @@ builder.Services.AddHttpClient(); builder.Services.AddScoped(); builder.Services.AddHttpClient(); +// HtmlSanitizer is thread-safe and stateless, so a singleton is appropriate. +builder.Services.AddSingleton(); +// Renders Markdown problem statements to sanitized HTML. +builder.Services.AddSingleton(); // ----------------------------- // --- CONFIGURARE IDENTITY --- diff --git a/JustBigO(Fun)/Services/HtmlSanitizerService.cs b/JustBigO(Fun)/Services/HtmlSanitizerService.cs new file mode 100644 index 0000000..fdfbfb6 --- /dev/null +++ b/JustBigO(Fun)/Services/HtmlSanitizerService.cs @@ -0,0 +1,46 @@ +using Ganss.Xss; + +namespace JustBigO_Fun_.Services; + +/// +/// Wraps with an allow-list tailored to problem statements. +/// Permits common formatting tags plus the jbo-example-box styling used by seeded problems, +/// while removing scripts, inline event handlers, iframes, etc. +/// +public class HtmlSanitizerService : IHtmlSanitizer +{ + private readonly HtmlSanitizer _sanitizer; + + public HtmlSanitizerService() + { + _sanitizer = new HtmlSanitizer(); + + // Start from a conservative formatting-only allow-list. + _sanitizer.AllowedTags.Clear(); + foreach (var tag in new[] + { + "p", "br", "strong", "b", "em", "i", "u", "s", "code", "pre", + "ul", "ol", "li", "blockquote", "h1", "h2", "h3", "h4", "h5", "h6", + "table", "thead", "tbody", "tr", "th", "td", "div", "span", "a", "hr", "sup", "sub" + }) + { + _sanitizer.AllowedTags.Add(tag); + } + + _sanitizer.AllowedAttributes.Clear(); + _sanitizer.AllowedAttributes.Add("class"); + _sanitizer.AllowedAttributes.Add("href"); + _sanitizer.AllowedAttributes.Add("title"); + _sanitizer.AllowedAttributes.Add("colspan"); + _sanitizer.AllowedAttributes.Add("rowspan"); + + // Only allow safe link schemes; no javascript: URIs. + _sanitizer.AllowedSchemes.Clear(); + _sanitizer.AllowedSchemes.Add("http"); + _sanitizer.AllowedSchemes.Add("https"); + _sanitizer.AllowedSchemes.Add("mailto"); + } + + public string Sanitize(string? html) => + string.IsNullOrWhiteSpace(html) ? string.Empty : _sanitizer.Sanitize(html); +} diff --git a/JustBigO(Fun)/Services/IHtmlSanitizer.cs b/JustBigO(Fun)/Services/IHtmlSanitizer.cs new file mode 100644 index 0000000..73f2381 --- /dev/null +++ b/JustBigO(Fun)/Services/IHtmlSanitizer.cs @@ -0,0 +1,11 @@ +namespace JustBigO_Fun_.Services; + +/// +/// Sanitizes untrusted HTML (e.g. admin-authored problem descriptions) so that it can +/// be safely rendered with @Html.Raw(...) without exposing the app to XSS. +/// +public interface IHtmlSanitizer +{ + /// Strips scripts, event handlers and other dangerous markup, keeping only safe formatting tags. + string Sanitize(string? html); +} diff --git a/JustBigO(Fun)/Services/IMarkdownRenderer.cs b/JustBigO(Fun)/Services/IMarkdownRenderer.cs new file mode 100644 index 0000000..1a6af07 --- /dev/null +++ b/JustBigO(Fun)/Services/IMarkdownRenderer.cs @@ -0,0 +1,13 @@ +namespace JustBigO_Fun_.Services; + +/// +/// Renders Markdown problem statements into HTML that is safe to emit with @Html.Raw(...). +/// +public interface IMarkdownRenderer +{ + /// + /// Converts Markdown to HTML and then sanitizes the result. Any raw HTML embedded in the + /// source (e.g. legacy HTML-authored descriptions) is preserved but still sanitized. + /// + string RenderToSafeHtml(string? markdown); +} diff --git a/JustBigO(Fun)/Services/MarkdownRenderer.cs b/JustBigO(Fun)/Services/MarkdownRenderer.cs new file mode 100644 index 0000000..3045ca9 --- /dev/null +++ b/JustBigO(Fun)/Services/MarkdownRenderer.cs @@ -0,0 +1,30 @@ +using Markdig; + +namespace JustBigO_Fun_.Services; + +/// +/// Renders Markdown with Markdig (advanced extensions: tables, lists, etc.) and runs the +/// resulting HTML through so it is safe to render unescaped. +/// +public class MarkdownRenderer : IMarkdownRenderer +{ + private static readonly MarkdownPipeline Pipeline = new MarkdownPipelineBuilder() + .UseAdvancedExtensions() + .Build(); + + private readonly IHtmlSanitizer _sanitizer; + + public MarkdownRenderer(IHtmlSanitizer sanitizer) + { + _sanitizer = sanitizer; + } + + public string RenderToSafeHtml(string? markdown) + { + if (string.IsNullOrWhiteSpace(markdown)) + return string.Empty; + + var html = Markdown.ToHtml(markdown, Pipeline); + return _sanitizer.Sanitize(html); + } +} diff --git a/JustBigO(Fun)/Views/Shared/_Layout.cshtml b/JustBigO(Fun)/Views/Shared/_Layout.cshtml index 3476465..8a2940f 100644 --- a/JustBigO(Fun)/Views/Shared/_Layout.cshtml +++ b/JustBigO(Fun)/Views/Shared/_Layout.cshtml @@ -38,7 +38,7 @@ @if (User.IsInRole("Admin")) { } diff --git a/JustBigO(Fun)/wwwroot/css/site.css b/JustBigO(Fun)/wwwroot/css/site.css index 7e6215b..6aeed94 100644 --- a/JustBigO(Fun)/wwwroot/css/site.css +++ b/JustBigO(Fun)/wwwroot/css/site.css @@ -573,6 +573,33 @@ body { line-height: 1.6; } +/* Fenced code blocks (e.g. ```text examples) authored in Markdown. */ +.jbo-problem-content pre { + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.08); + border-left: 3px solid #38bdf8; + padding: 0.85rem 1rem; + margin-block: 1rem; + border-radius: 0 0.5rem 0.5rem 0; + overflow-x: auto; +} + +.jbo-problem-content pre code { + background: transparent; + padding: 0; + color: #e2e8f0; + font-family: monospace; + font-size: 0.85rem; +} + +/* Inline code spans. */ +.jbo-problem-content code { + background: rgba(255, 255, 255, 0.08); + padding: 0.1rem 0.35rem; + border-radius: 0.25rem; + font-size: 0.85em; +} + .jbo-example-box { background: rgba(255,255,255,0.05); border-left: 3px solid #38bdf8; @@ -697,6 +724,7 @@ body { .bg-black .text-muted, .jbo-workspace-right .text-muted, .jbo-workspace-panel .text-muted, -.modal-content.bg-dark .text-muted { +.modal-content.bg-dark .text-muted, +.text-muted { color: #adb5bd !important; /* A much lighter, readable grey */ } \ No newline at end of file