using System.Security.Claims; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using SQLInsight.Contracts; using SQLInsight.Hub.Data; using SQLInsight.Hub.Support; namespace SQLInsight.Hub.Portal; /// /// Form posts from the portal (login and the few actions). Pages are server-rendered Razor components. /// public static class PortalEndpoints { public const string LoginRateLimit = "portal-login"; public static void Map(IEndpointRouteBuilder app) { app.MapPost("/portal/login", LoginAsync).AllowAnonymous().RequireRateLimiting(LoginRateLimit); app.MapPost("/portal/logout", async (HttpContext http) => { await http.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme); return Results.Redirect("/portal/login"); }); var portal = app.MapGroup("/portal/actions").RequireAuthorization(); portal.MapPost("/findings/{id:long}/case", LinkCaseAsync); portal.MapPost("/customers/{id:int}/cases", CreateCaseAsync); portal.MapGet("/reports/support.csv", SupportReportCsvAsync); portal.MapPost("/findings/{id:long}/unlink", async (long id, HubDbContext db, CancellationToken ct) => { var finding = await db.Findings.FindAsync([id], ct); if (finding is null) { return Results.NotFound(); } finding.SupportCaseId = null; await db.SaveChangesAsync(ct); return Results.Redirect($"/portal/fund/{id}"); }); portal.MapPost("/cases/{id:int}/close", async (int id, HttpContext http, HubDbContext db, TimeProvider time, CancellationToken ct) => { var supportCase = await db.SupportCases.FindAsync([id], ct); if (supportCase is null) { return Results.NotFound(); } if (supportCase.IsOpen) { supportCase.IsOpen = false; supportCase.ClosedAt = time.GetUtcNow().UtcDateTime; supportCase.ClosedBy = "SEOSoft: " + (http.User.Identity?.Name ?? "ukendt"); } await db.SaveChangesAsync(ct); return Results.Redirect("/portal/sager"); }); portal.MapPost("/licenses/{id}/revoke", async (string id, [FromForm] string reason, HubDbContext db, TimeProvider time, CancellationToken ct) => { var license = await db.Licenses.FindAsync([id], ct); if (license is null) { return Results.NotFound(); } license.RevokedAt = time.GetUtcNow().UtcDateTime; license.RevokedReason = string.IsNullOrWhiteSpace(reason) ? "Spærret" : reason.Trim(); await db.SaveChangesAsync(ct); return Results.Redirect("/portal/licenser"); }); } private static async Task LoginAsync(HttpContext http, StaffAccounts accounts, [FromForm] string userName, [FromForm] string password, [FromForm] string? returnUrl) { var user = await accounts.VerifyAsync(userName, password, http.RequestAborted); if (user is null) { return Results.Redirect("/portal/login?fejl=1"); } var identity = new ClaimsIdentity( [new Claim(ClaimTypes.NameIdentifier, user.Id.ToString(System.Globalization.CultureInfo.InvariantCulture)), new Claim(ClaimTypes.Name, user.DisplayName), new Claim("username", user.UserName)], CookieAuthenticationDefaults.AuthenticationScheme); await http.SignInAsync(CookieAuthenticationDefaults.AuthenticationScheme, new ClaimsPrincipal(identity)); // Only local redirects: never an open redirect to another site. return Results.LocalRedirect(returnUrl is { Length: > 0 } && returnUrl.StartsWith("/portal", StringComparison.Ordinal) && !returnUrl.StartsWith("//", StringComparison.Ordinal) ? returnUrl : "/portal"); } private static async Task LinkCaseAsync(long id, [FromForm] int? caseId, [FromForm] string? reference, [FromForm] string? title, HubDbContext db, TimeProvider time, CancellationToken ct) { var finding = await db.Findings.Include(f => f.Instance).SingleOrDefaultAsync(f => f.Id == id, ct); if (finding is null) { return Results.NotFound(); } if (caseId is { } existing) { var supportCase = await db.SupportCases.SingleOrDefaultAsync(c => c.Id == existing && c.CustomerId == finding.Instance!.CustomerId, ct); if (supportCase is null) { return Results.BadRequest("Sagen tilhører en anden kunde."); } finding.SupportCaseId = supportCase.Id; } else { // An empty case number gets the customer's next SUP-number. var (supportCase, error) = await SupportCaseStore.AddAsync(db, new SupportCase { CustomerId = finding.Instance!.CustomerId, Reference = reference ?? string.Empty, Title = string.IsNullOrWhiteSpace(title) ? finding.Title : title.Trim(), CreatedAt = time.GetUtcNow().UtcDateTime, }, ct); if (supportCase is null) { return Results.BadRequest(error); } finding.SupportCaseId = supportCase.Id; } await db.SaveChangesAsync(ct); return Results.Redirect($"/portal/fund/{id}"); } private static async Task CreateCaseAsync(int id, [FromForm] string? reference, [FromForm] string title, [FromForm] string? description, HubDbContext db, TimeProvider time, CancellationToken ct) { if (!await db.Customers.AnyAsync(c => c.Id == id, ct)) { return Results.NotFound(); } if (string.IsNullOrWhiteSpace(title) || title.Length > SupportCaseRequest.MaxTitle) { return Results.BadRequest($"Titel skal udfyldes (højst {SupportCaseRequest.MaxTitle} tegn)."); } var (_, error) = await SupportCaseStore.AddAsync(db, new SupportCase { CustomerId = id, Reference = reference ?? string.Empty, Title = title.Trim(), Description = string.IsNullOrWhiteSpace(description) ? null : description.Trim()[..Math.Min(description.Trim().Length, SupportCaseRequest.MaxDescription)], CreatedAt = time.GetUtcNow().UtcDateTime, }, ct); return error is null ? Results.Redirect($"/portal/kunder/{id}") : Results.BadRequest(error); } private static async Task SupportReportCsvAsync(DateTime? fra, DateTime? til, int? kunde, PortalQueries queries, CancellationToken ct) { var (from, to) = SupportReportPeriod.Resolve(fra, til, queries.Now); var report = await queries.SupportReportAsync(from, to, kunde, ct); return Results.File(SupportReport.Csv(report), "text/csv; charset=utf-8", $"supportsager-{from:yyyy-MM-dd}-{to.AddDays(-1):yyyy-MM-dd}.csv"); } }