/// Common helper functions for views module JobsJobsJobs.Common.Handlers open Giraffe open Giraffe.Htmx open Microsoft.AspNetCore.Http open Microsoft.Extensions.Logging [] module HtmxHelpers = /// Is the request from htmx? let isHtmx (ctx : HttpContext) = ctx.Request.IsHtmx && not ctx.Request.IsHtmxRefresh /// Handlers for error conditions module Error = open System.Net /// Handler that will return a status code 404 and the text "Not Found" let notFound : HttpHandler = fun _ ctx -> let fac = ctx.GetService () let log = fac.CreateLogger "Handler" let path = string ctx.Request.Path log.LogInformation "Returning 404" RequestErrors.NOT_FOUND $"The URL {path} was not recognized as a valid URL" earlyReturn ctx /// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response let notAuthorized : HttpHandler = fun next ctx -> if ctx.Request.Method = "GET" then let redirectUrl = $"/citizen/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}" if isHtmx ctx then (withHxRedirect redirectUrl >=> redirectTo false redirectUrl) next ctx else redirectTo false redirectUrl next ctx else if isHtmx ctx then (setHttpHeader "X-Toast" $"error|||You are not authorized to access the URL {ctx.Request.Path.Value}" >=> setStatusCode 401) earlyReturn ctx else setStatusCode 401 earlyReturn ctx /// Handler to log 500s and return a message we can display in the application let unexpectedError (ex: exn) (log : ILogger) = log.LogError(ex, "An unexpected error occurred") clearResponse >=> ServerErrors.INTERNAL_ERROR ex.Message open System open System.Security.Claims open System.Text.Json open System.Text.RegularExpressions open JobsJobsJobs.Domain open Microsoft.AspNetCore.Antiforgery open Microsoft.Extensions.Configuration open Microsoft.Extensions.DependencyInjection open NodaTime /// Get the NodaTime clock from the request context let now (ctx : HttpContext) = ctx.GetService().GetCurrentInstant () /// Get the application configuration from the request context let config (ctx : HttpContext) = ctx.GetService () /// Get the logger factory from the request context let logger (ctx : HttpContext) = ctx.GetService () /// `None` if a `string option` is `None`, whitespace, or empty let noneIfBlank (s : string option) = s |> Option.map (fun x -> match x.Trim () with "" -> None | _ -> Some x) |> Option.flatten /// `None` if a `string` is null, empty, or whitespace; otherwise, `Some` and the trimmed string let noneIfEmpty = Option.ofObj >> noneIfBlank /// Try to get the current user let tryUser (ctx : HttpContext) = ctx.User.FindFirst ClaimTypes.NameIdentifier |> Option.ofObj |> Option.map (fun x -> x.Value) /// Get the ID of the currently logged in citizen // NOTE: if no one is logged in, this will raise an exception let currentCitizenId ctx = (tryUser >> Option.get >> CitizenId.ofString) ctx let antiForgerySvc (ctx : HttpContext) = ctx.RequestServices.GetRequiredService () /// Obtain an anti-forgery token set let csrf ctx = (antiForgerySvc ctx).GetAndStoreTokens ctx /// Get the time zone from the citizen's browser let timeZone (ctx : HttpContext) = let tz = string ctx.Request.Headers["X-Time-Zone"] defaultArg (noneIfEmpty tz) "Etc/UTC" /// The key to use to indicate if we have loaded the session let private sessionLoadedKey = "session-loaded" /// Load the session if we have not yet let private loadSession (ctx : HttpContext) = task { if not (ctx.Items.ContainsKey sessionLoadedKey) then do! ctx.Session.LoadAsync () ctx.Items.Add (sessionLoadedKey, "yes") } /// Save the session if we have loaded it let private saveSession (ctx : HttpContext) = task { if ctx.Items.ContainsKey sessionLoadedKey then do! ctx.Session.CommitAsync () } /// Get the messages from the session (destructively) let popMessages ctx = task { do! loadSession ctx let msgs = match ctx.Session.GetString "messages" with | null -> [] | m -> JsonSerializer.Deserialize m if not (List.isEmpty msgs) then ctx.Session.Remove "messages" return List.rev msgs } /// Add a message to the response let addMessage (level : string) (msg : string) ctx = task { do! loadSession ctx let! msgs = popMessages ctx ctx.Session.SetString ("messages", JsonSerializer.Serialize ($"{level}|||{msg}" :: msgs)) } /// Add a success message to the response let addSuccess msg ctx = task { do! addMessage "success" msg ctx } /// Add an error message to the response let addError msg ctx = task { do! addMessage "error" msg ctx } /// Add a list of errors to the response let addErrors (errors : string list) ctx = task { let errMsg = String.Join ("
  • ", errors) do! addError $"Please correct the following errors:
    • {errMsg}
    " ctx } open JobsJobsJobs.Common.Views /// Create the render context for an HTML response let private createContext (ctx : HttpContext) pageTitle content messages : Layout.PageRenderContext = { IsLoggedOn = Option.isSome (tryUser ctx) CurrentUrl = ctx.Request.Path.Value PageTitle = pageTitle Content = content Messages = messages } /// Render a page-level view let render pageTitle (_ : HttpFunc) (ctx : HttpContext) content = task { let! messages = popMessages ctx let renderCtx = createContext ctx pageTitle content messages let renderFunc = if isHtmx ctx then Layout.partial else Layout.full return! ctx.WriteHtmlViewAsync (renderFunc renderCtx) } /// Render a printable view (content with styles, but no layout) let renderPrint pageTitle (_ : HttpFunc) (ctx : HttpContext) content = createContext ctx pageTitle content [] |> Layout.print |> ctx.WriteHtmlViewAsync /// Render a bare (component) view let renderBare (_ : HttpFunc) (ctx : HttpContext) content = createContext ctx "" content [] |> Layout.bare |> ctx.WriteHtmlViewAsync /// Render as a composable HttpHandler let renderHandler pageTitle content : HttpHandler = fun next ctx -> render pageTitle next ctx content /// Validate the anti cross-site request forgery token in the current request let validateCsrf : HttpHandler = fun next ctx -> task { match! (antiForgerySvc ctx).IsRequestValidAsync ctx with | true -> return! next ctx | false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" earlyReturn ctx } /// Require a user to be logged on for a route let requireUser : HttpHandler = requiresAuthentication Error.notAuthorized /// Regular expression to validate that a URL is a local URL let isLocal = Regex """^/[^\/\\].*""" /// Redirect to another page, saving the session before redirecting let redirectToGet (url : string) next ctx = task { do! saveSession ctx let action = if Option.isSome (noneIfEmpty url) && (url = "/" || isLocal.IsMatch url) then if isHtmx ctx then withHxRedirect url else redirectTo false url else RequestErrors.BAD_REQUEST "Invalid redirect URL" return! action next ctx } /// Shorthand for Error.notFound for use in handler functions let notFound ctx = Error.notFound earlyReturn ctx