Version 3 #67
@ -6,18 +6,44 @@ module MyPrayerJournal.Handlers
|
|||||||
|
|
||||||
open Giraffe
|
open Giraffe
|
||||||
open Giraffe.Htmx
|
open Giraffe.Htmx
|
||||||
|
open Microsoft.AspNetCore.Authentication
|
||||||
|
open Microsoft.AspNetCore.Http
|
||||||
open System
|
open System
|
||||||
|
open System.Security.Claims
|
||||||
|
|
||||||
|
/// Helper function to be able to split out log on
|
||||||
|
[<AutoOpen>]
|
||||||
|
module private LogOnHelpers =
|
||||||
|
|
||||||
|
/// Log on, optionally specifying a redirected URL once authentication is complete
|
||||||
|
let logOn url : HttpHandler =
|
||||||
|
fun next ctx -> task {
|
||||||
|
match url with
|
||||||
|
| Some it ->
|
||||||
|
do! ctx.ChallengeAsync ("Auth0", AuthenticationProperties (RedirectUri = it))
|
||||||
|
return! next ctx
|
||||||
|
| None -> return! challenge "Auth0" next ctx
|
||||||
|
}
|
||||||
|
|
||||||
/// Handlers for error conditions
|
/// Handlers for error conditions
|
||||||
module Error =
|
module Error =
|
||||||
|
|
||||||
open Microsoft.Extensions.Logging
|
open Microsoft.Extensions.Logging
|
||||||
|
open System.Threading.Tasks
|
||||||
|
|
||||||
/// Handle errors
|
/// Handle errors
|
||||||
let error (ex : Exception) (log : ILogger) =
|
let error (ex : Exception) (log : ILogger) =
|
||||||
log.LogError (EventId(), ex, "An unhandled exception has occurred while executing the request.")
|
log.LogError (EventId(), ex, "An unhandled exception has occurred while executing the request.")
|
||||||
clearResponse >=> setStatusCode 500 >=> json ex.Message
|
clearResponse >=> setStatusCode 500 >=> json ex.Message
|
||||||
|
|
||||||
|
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized reponse
|
||||||
|
let notAuthorized : HttpHandler =
|
||||||
|
fun next ctx ->
|
||||||
|
(next, ctx)
|
||||||
|
||> match ctx.Request.Method with
|
||||||
|
| "GET" -> logOn None
|
||||||
|
| _ -> setStatusCode 401 >=> fun _ _ -> Task.FromResult<HttpContext option> None
|
||||||
|
|
||||||
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
||||||
let notFound : HttpHandler =
|
let notFound : HttpHandler =
|
||||||
setStatusCode 404 >=> text "Not found"
|
setStatusCode 404 >=> text "Not found"
|
||||||
@ -28,36 +54,14 @@ module Error =
|
|||||||
module private Helpers =
|
module private Helpers =
|
||||||
|
|
||||||
open LiteDB
|
open LiteDB
|
||||||
open Microsoft.AspNetCore.Authentication
|
|
||||||
open Microsoft.AspNetCore.Http
|
|
||||||
open Microsoft.AspNetCore.Http.Features.Authentication
|
|
||||||
open Microsoft.Extensions.Logging
|
open Microsoft.Extensions.Logging
|
||||||
open Microsoft.Net.Http.Headers
|
open Microsoft.Net.Http.Headers
|
||||||
open System.Security.Claims
|
|
||||||
open System.Threading.Tasks
|
|
||||||
|
|
||||||
let debug (ctx : HttpContext) message =
|
let debug (ctx : HttpContext) message =
|
||||||
let fac = ctx.GetService<ILoggerFactory>()
|
let fac = ctx.GetService<ILoggerFactory>()
|
||||||
let log = fac.CreateLogger "Debug"
|
let log = fac.CreateLogger "Debug"
|
||||||
log.LogInformation message
|
log.LogInformation message
|
||||||
|
|
||||||
/// This type is internal in ASP.NET Core. :(
|
|
||||||
type AuthFeatures (result : AuthenticateResult) =
|
|
||||||
let mutable _user : ClaimsPrincipal = match result with null -> null | r -> r.Principal
|
|
||||||
let mutable _result : AuthenticateResult = result
|
|
||||||
interface IAuthenticateResultFeature with
|
|
||||||
member __.AuthenticateResult
|
|
||||||
with get () = _result
|
|
||||||
and set v =
|
|
||||||
_result <- v
|
|
||||||
_user <- match _result with null -> null | rslt -> rslt.Principal
|
|
||||||
interface IHttpAuthenticationFeature with
|
|
||||||
member __.User
|
|
||||||
with get () = _user
|
|
||||||
and set v =
|
|
||||||
_user <- v
|
|
||||||
_result <- null
|
|
||||||
|
|
||||||
/// Get the LiteDB database
|
/// Get the LiteDB database
|
||||||
let db (ctx : HttpContext) = ctx.GetService<LiteDatabase>()
|
let db (ctx : HttpContext) = ctx.GetService<LiteDatabase>()
|
||||||
|
|
||||||
@ -67,11 +71,12 @@ module private Helpers =
|
|||||||
|> Option.ofObj
|
|> Option.ofObj
|
||||||
|> Option.map (fun user -> user.Claims |> Seq.tryFind (fun u -> u.Type = ClaimTypes.NameIdentifier))
|
|> Option.map (fun user -> user.Claims |> Seq.tryFind (fun u -> u.Type = ClaimTypes.NameIdentifier))
|
||||||
|> Option.flatten
|
|> Option.flatten
|
||||||
|
|> Option.map (fun claim -> claim.Value)
|
||||||
|
|
||||||
/// Get the current user's ID
|
/// Get the current user's ID
|
||||||
// NOTE: this may raise if you don't run the request through the authorize handler first
|
// NOTE: this may raise if you don't run the request through the requiresAuthentication handler first
|
||||||
let userId ctx =
|
let userId ctx =
|
||||||
((user >> Option.get) ctx).Value |> UserId
|
(user >> Option.get) ctx |> UserId
|
||||||
|
|
||||||
/// Return a 201 CREATED response
|
/// Return a 201 CREATED response
|
||||||
let created =
|
let created =
|
||||||
@ -87,54 +92,6 @@ module private Helpers =
|
|||||||
let jsNow () =
|
let jsNow () =
|
||||||
DateTime.UtcNow.Subtract(DateTime (1970, 1, 1, 0, 0, 0)).TotalSeconds |> (int64 >> ( * ) 1_000L >> Ticks)
|
DateTime.UtcNow.Subtract(DateTime (1970, 1, 1, 0, 0, 0)).TotalSeconds |> (int64 >> ( * ) 1_000L >> Ticks)
|
||||||
|
|
||||||
/// Handler to return a 401 Not Authorized reponse
|
|
||||||
let notAuthorized : HttpHandler =
|
|
||||||
setStatusCode 401 >=> fun _ _ -> Task.FromResult<HttpContext option> None
|
|
||||||
|
|
||||||
/// Handler to require authorization
|
|
||||||
// NOTE: This is cribbed from ASP.NET Core's `AuthenticationMiddleware#Invoke`
|
|
||||||
// https://github.com/dotnet/aspnetcore/blob/main/src/Security/Authentication/Core/src/AuthenticationMiddleware.cs
|
|
||||||
let authorize : HttpHandler =
|
|
||||||
fun next ctx -> task {
|
|
||||||
let schemes = ctx.GetService<IAuthenticationSchemeProvider> ()
|
|
||||||
ctx.Features.Set<IAuthenticationFeature>
|
|
||||||
(AuthenticationFeature (OriginalPath = ctx.Request.Path, OriginalPathBase = ctx.Request.PathBase))
|
|
||||||
|
|
||||||
// Give any IAuthenticationRequestHandler schemes a chance to handle the request
|
|
||||||
let handlers = ctx.GetService<IAuthenticationHandlerProvider> ()
|
|
||||||
let! schms = schemes.GetRequestHandlerSchemesAsync ()
|
|
||||||
let mutable handled = false
|
|
||||||
|
|
||||||
for schm in schms do
|
|
||||||
match handled with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
match! handlers.GetHandlerAsync (ctx, schm.Name) with
|
|
||||||
| null -> ()
|
|
||||||
| :? IAuthenticationRequestHandler as handler ->
|
|
||||||
match! handler.HandleRequestAsync () with true -> handled <- true | _ -> ()
|
|
||||||
| _ -> ()
|
|
||||||
|
|
||||||
match handled with
|
|
||||||
| true -> return None
|
|
||||||
| false ->
|
|
||||||
match! schemes.GetDefaultAuthenticateSchemeAsync () with
|
|
||||||
| null -> ()
|
|
||||||
| auth ->
|
|
||||||
match! ctx.AuthenticateAsync auth.Name with
|
|
||||||
| null -> ()
|
|
||||||
| result ->
|
|
||||||
match result.Principal with null -> () | _ -> ctx.User <- result.Principal
|
|
||||||
match result.Succeeded with
|
|
||||||
| true ->
|
|
||||||
let authFeatures = AuthFeatures result
|
|
||||||
ctx.Features.Set<IHttpAuthenticationFeature> authFeatures
|
|
||||||
ctx.Features.Set<IAuthenticateResultFeature> authFeatures
|
|
||||||
| false -> ()
|
|
||||||
|
|
||||||
return! match user ctx with Some _ -> next ctx | None -> notAuthorized next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Render a component result
|
/// Render a component result
|
||||||
let renderComponent nodes : HttpHandler =
|
let renderComponent nodes : HttpHandler =
|
||||||
fun next ctx -> task {
|
fun next ctx -> task {
|
||||||
@ -212,7 +169,7 @@ module Components =
|
|||||||
|
|
||||||
// GET /components/journal-items
|
// GET /components/journal-items
|
||||||
let journalItems : HttpHandler =
|
let journalItems : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let shouldShow now r = now > Ticks.toLong r.snoozedUntil && now > Ticks.toLong r.showAfter
|
let shouldShow now r = now > Ticks.toLong r.snoozedUntil && now > Ticks.toLong r.showAfter
|
||||||
let! jrnl = Data.journalByUserId (userId ctx) (db ctx)
|
let! jrnl = Data.journalByUserId (userId ctx) (db ctx)
|
||||||
@ -222,7 +179,7 @@ module Components =
|
|||||||
|
|
||||||
// GET /components/request/[req-id]/edit
|
// GET /components/request/[req-id]/edit
|
||||||
let requestEdit requestId : HttpHandler =
|
let requestEdit requestId : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
match requestId with
|
match requestId with
|
||||||
| "new" ->
|
| "new" ->
|
||||||
@ -230,22 +187,17 @@ module Components =
|
|||||||
(Views.Request.edit (JournalRequest.ofRequestLite Request.empty) false) next ctx
|
(Views.Request.edit (JournalRequest.ofRequestLite Request.empty) false) next ctx
|
||||||
| _ ->
|
| _ ->
|
||||||
match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with
|
match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with
|
||||||
| Some req ->
|
| Some req -> return! partialIfNotRefresh "Edit Prayer Request" (Views.Request.edit req false) next ctx
|
||||||
return! partialIfNotRefresh "Edit Prayer Request" (Views.Request.edit req false) next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// GET /components/request-item/[req-id]
|
// GET /components/request-item/[req-id]
|
||||||
let requestItem reqId : HttpHandler =
|
let requestItem reqId : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
match! Data.tryJournalById (RequestId.ofString reqId) (userId ctx) (db ctx) with
|
match! Data.tryJournalById (RequestId.ofString reqId) (userId ctx) (db ctx) with
|
||||||
| Some req ->
|
| Some req -> return! renderComponent [ Views.Request.reqListItem req ] next ctx
|
||||||
debug ctx "Found the item"
|
| None -> return! Error.notFound next ctx
|
||||||
return! renderComponent [ Views.Request.reqListItem req ] next ctx
|
|
||||||
| None ->
|
|
||||||
debug ctx "Did not find the item"
|
|
||||||
return! Error.notFound next ctx
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -256,20 +208,20 @@ module Home =
|
|||||||
let home : HttpHandler =
|
let home : HttpHandler =
|
||||||
partialIfNotRefresh "Welcome!" Views.Home.home
|
partialIfNotRefresh "Welcome!" Views.Home.home
|
||||||
|
|
||||||
// GET /user/log-on
|
|
||||||
let logOn : HttpHandler =
|
|
||||||
partialIfNotRefresh "Logging on..." Views.Home.logOn
|
|
||||||
|
|
||||||
|
|
||||||
/// /journal URL
|
/// /journal URL
|
||||||
module Journal =
|
module Journal =
|
||||||
|
|
||||||
// GET /journal
|
// GET /journal
|
||||||
let journal : HttpHandler =
|
let journal : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let usr = ctx.Request.Headers.["X-Given-Name"].[0]
|
let usr =
|
||||||
return! partialIfNotRefresh "Your Prayer Journal" (Views.Journal.journal usr) next ctx
|
ctx.User.Claims
|
||||||
|
|> Seq.tryFind (fun c -> c.Type = ClaimTypes.GivenName)
|
||||||
|
|> Option.map (fun c -> c.Value)
|
||||||
|
|> Option.defaultValue "Your"
|
||||||
|
return! partialIfNotRefresh (sprintf "%s Prayer Journal" usr) (Views.Journal.journal usr) next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -294,7 +246,7 @@ module Request =
|
|||||||
|
|
||||||
// PATCH /request/[req-id]/prayed
|
// PATCH /request/[req-id]/prayed
|
||||||
let prayed requestId : HttpHandler =
|
let prayed requestId : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let db = db ctx
|
let db = db ctx
|
||||||
let usrId = userId ctx
|
let usrId = userId ctx
|
||||||
@ -315,7 +267,7 @@ module Request =
|
|||||||
|
|
||||||
/// POST /api/request/[req-id]/note
|
/// POST /api/request/[req-id]/note
|
||||||
let addNote requestId : HttpHandler =
|
let addNote requestId : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let db = db ctx
|
let db = db ctx
|
||||||
let usrId = userId ctx
|
let usrId = userId ctx
|
||||||
@ -329,17 +281,17 @@ module Request =
|
|||||||
| None -> return! Error.notFound next ctx
|
| None -> return! Error.notFound next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /requests/active
|
// GET /requests/active
|
||||||
let active : HttpHandler =
|
let active : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let! reqs = Data.journalByUserId (userId ctx) (db ctx)
|
let! reqs = Data.journalByUserId (userId ctx) (db ctx)
|
||||||
return! partialIfNotRefresh "Active Requests" (Views.Request.active reqs) next ctx
|
return! partialIfNotRefresh "Active Requests" (Views.Request.active reqs) next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /requests/snoozed
|
// GET /requests/snoozed
|
||||||
let snoozed : HttpHandler =
|
let snoozed : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let! reqs = Data.journalByUserId (userId ctx) (db ctx)
|
let! reqs = Data.journalByUserId (userId ctx) (db ctx)
|
||||||
let now = (jsNow >> Ticks.toLong) ()
|
let now = (jsNow >> Ticks.toLong) ()
|
||||||
@ -347,9 +299,9 @@ module Request =
|
|||||||
return! partialIfNotRefresh "Active Requests" (Views.Request.snoozed snoozed) next ctx
|
return! partialIfNotRefresh "Active Requests" (Views.Request.snoozed snoozed) next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
/// GET /requests/answered
|
// GET /requests/answered
|
||||||
let answered : HttpHandler =
|
let answered : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let! reqs = Data.answeredRequests (userId ctx) (db ctx)
|
let! reqs = Data.answeredRequests (userId ctx) (db ctx)
|
||||||
return! partialIfNotRefresh "Answered Requests" (Views.Request.answered reqs) next ctx
|
return! partialIfNotRefresh "Answered Requests" (Views.Request.answered reqs) next ctx
|
||||||
@ -357,7 +309,7 @@ module Request =
|
|||||||
|
|
||||||
/// GET /api/request/[req-id]
|
/// GET /api/request/[req-id]
|
||||||
let get requestId : HttpHandler =
|
let get requestId : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with
|
match! Data.tryJournalById (RequestId.ofString requestId) (userId ctx) (db ctx) with
|
||||||
| Some req -> return! json req next ctx
|
| Some req -> return! json req next ctx
|
||||||
@ -366,7 +318,7 @@ module Request =
|
|||||||
|
|
||||||
// GET /request/[req-id]/full
|
// GET /request/[req-id]/full
|
||||||
let getFull requestId : HttpHandler =
|
let getFull requestId : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
match! Data.tryFullRequestById (RequestId.ofString requestId) (userId ctx) (db ctx) with
|
match! Data.tryFullRequestById (RequestId.ofString requestId) (userId ctx) (db ctx) with
|
||||||
| Some req -> return! partialIfNotRefresh "Prayer Request" (Views.Request.full req) next ctx
|
| Some req -> return! partialIfNotRefresh "Prayer Request" (Views.Request.full req) next ctx
|
||||||
@ -375,7 +327,7 @@ module Request =
|
|||||||
|
|
||||||
/// GET /api/request/[req-id]/notes
|
/// GET /api/request/[req-id]/notes
|
||||||
let getNotes requestId : HttpHandler =
|
let getNotes requestId : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let! notes = Data.notesById (RequestId.ofString requestId) (userId ctx) (db ctx)
|
let! notes = Data.notesById (RequestId.ofString requestId) (userId ctx) (db ctx)
|
||||||
return! json notes next ctx
|
return! json notes next ctx
|
||||||
@ -383,7 +335,7 @@ module Request =
|
|||||||
|
|
||||||
// PATCH /request/[req-id]/show
|
// PATCH /request/[req-id]/show
|
||||||
let show requestId : HttpHandler =
|
let show requestId : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let db = db ctx
|
let db = db ctx
|
||||||
let usrId = userId ctx
|
let usrId = userId ctx
|
||||||
@ -398,7 +350,7 @@ module Request =
|
|||||||
|
|
||||||
/// PATCH /api/request/[req-id]/snooze
|
/// PATCH /api/request/[req-id]/snooze
|
||||||
let snooze requestId : HttpHandler =
|
let snooze requestId : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let db = db ctx
|
let db = db ctx
|
||||||
let usrId = userId ctx
|
let usrId = userId ctx
|
||||||
@ -414,7 +366,7 @@ module Request =
|
|||||||
|
|
||||||
// PATCH /request/[req-id]/cancel-snooze
|
// PATCH /request/[req-id]/cancel-snooze
|
||||||
let cancelSnooze requestId : HttpHandler =
|
let cancelSnooze requestId : HttpHandler =
|
||||||
authorize
|
requiresAuthentication Error.notAuthorized
|
||||||
>=> fun next ctx -> task {
|
>=> fun next ctx -> task {
|
||||||
let db = db ctx
|
let db = db ctx
|
||||||
let usrId = userId ctx
|
let usrId = userId ctx
|
||||||
@ -434,7 +386,8 @@ module Request =
|
|||||||
|
|
||||||
// POST /request
|
// POST /request
|
||||||
let add : HttpHandler =
|
let add : HttpHandler =
|
||||||
fun next ctx -> task {
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> task {
|
||||||
let! form = ctx.BindModelAsync<Models.Request> ()
|
let! form = ctx.BindModelAsync<Models.Request> ()
|
||||||
let db = db ctx
|
let db = db ctx
|
||||||
let usrId = userId ctx
|
let usrId = userId ctx
|
||||||
@ -462,7 +415,8 @@ module Request =
|
|||||||
|
|
||||||
// PATCH /request
|
// PATCH /request
|
||||||
let update : HttpHandler =
|
let update : HttpHandler =
|
||||||
fun next ctx -> Ply.task {
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> Ply.task {
|
||||||
let! form = ctx.BindModelAsync<Models.Request> ()
|
let! form = ctx.BindModelAsync<Models.Request> ()
|
||||||
let db = db ctx
|
let db = db ctx
|
||||||
let usrId = userId ctx
|
let usrId = userId ctx
|
||||||
@ -489,6 +443,25 @@ module Request =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers for /user URLs
|
||||||
|
module User =
|
||||||
|
|
||||||
|
open Microsoft.AspNetCore.Authentication.Cookies
|
||||||
|
|
||||||
|
// GET /user/log-on
|
||||||
|
let logOn : HttpHandler =
|
||||||
|
logOn (Some "/journal")
|
||||||
|
|
||||||
|
// GET /user/log-off
|
||||||
|
let logOff : HttpHandler =
|
||||||
|
requiresAuthentication Error.notAuthorized
|
||||||
|
>=> fun next ctx -> task {
|
||||||
|
do! ctx.SignOutAsync ("Auth0", AuthenticationProperties (RedirectUri = "/"))
|
||||||
|
do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
|
return! next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
open Giraffe.EndpointRouting
|
open Giraffe.EndpointRouting
|
||||||
|
|
||||||
/// The routes for myPrayerJournal
|
/// The routes for myPrayerJournal
|
||||||
@ -526,7 +499,12 @@ let routes =
|
|||||||
route "" Request.add
|
route "" Request.add
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
GET_HEAD [ route "/user/log-on" Home.logOn ]
|
subRoute "/user/" [
|
||||||
|
GET_HEAD [
|
||||||
|
route "log-off" User.logOff
|
||||||
|
route "log-on" User.logOn
|
||||||
|
]
|
||||||
|
]
|
||||||
subRoute "/api/" [
|
subRoute "/api/" [
|
||||||
GET [
|
GET [
|
||||||
subRoute "request" [
|
subRoute "request" [
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
<PackageReference Include="FunctionalCuid" Version="1.0.0" />
|
<PackageReference Include="FunctionalCuid" Version="1.0.0" />
|
||||||
<PackageReference Include="Giraffe" Version="5.0.0" />
|
<PackageReference Include="Giraffe" Version="5.0.0" />
|
||||||
<PackageReference Include="LiteDB" Version="5.0.11" />
|
<PackageReference Include="LiteDB" Version="5.0.11" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="5.0.10" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="5.0.10" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../../../Giraffe.Htmx/src/Htmx/Giraffe.Htmx.fsproj" />
|
<ProjectReference Include="../../../../Giraffe.Htmx/src/Htmx/Giraffe.Htmx.fsproj" />
|
||||||
|
@ -56,27 +56,75 @@ module Configure =
|
|||||||
|
|
||||||
open Giraffe
|
open Giraffe
|
||||||
open LiteDB
|
open LiteDB
|
||||||
open Microsoft.AspNetCore.Authentication.JwtBearer
|
open Microsoft.AspNetCore.Authentication.Cookies
|
||||||
|
open Microsoft.AspNetCore.Authentication.OpenIdConnect
|
||||||
|
open Microsoft.AspNetCore.Http
|
||||||
open Microsoft.Extensions.DependencyInjection
|
open Microsoft.Extensions.DependencyInjection
|
||||||
|
open Microsoft.IdentityModel.Protocols.OpenIdConnect
|
||||||
|
open System
|
||||||
open System.Text.Json
|
open System.Text.Json
|
||||||
open System.Text.Json.Serialization
|
open System.Text.Json.Serialization
|
||||||
|
open System.Threading.Tasks
|
||||||
|
|
||||||
/// Configure dependency injection
|
/// Configure dependency injection
|
||||||
let services (bldr : WebApplicationBuilder) =
|
let services (bldr : WebApplicationBuilder) =
|
||||||
|
let sameSite (opts : CookieOptions) =
|
||||||
|
match opts.SameSite, opts.Secure with
|
||||||
|
| SameSiteMode.None, false -> opts.SameSite <- SameSiteMode.Unspecified
|
||||||
|
| _, _ -> ()
|
||||||
|
|
||||||
bldr.Services
|
bldr.Services
|
||||||
.AddRouting()
|
.AddRouting()
|
||||||
.AddGiraffe()
|
.AddGiraffe()
|
||||||
|
.Configure<CookiePolicyOptions>(
|
||||||
|
fun (opts : CookiePolicyOptions) ->
|
||||||
|
opts.MinimumSameSitePolicy <- SameSiteMode.Unspecified
|
||||||
|
opts.OnAppendCookie <- fun ctx -> sameSite ctx.CookieOptions
|
||||||
|
opts.OnDeleteCookie <- fun ctx -> sameSite ctx.CookieOptions)
|
||||||
.AddAuthentication(
|
.AddAuthentication(
|
||||||
/// Use HTTP "Bearer" authentication with JWTs
|
/// Use HTTP "Bearer" authentication with JWTs
|
||||||
fun opts ->
|
fun opts ->
|
||||||
opts.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme
|
opts.DefaultAuthenticateScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
opts.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme)
|
opts.DefaultSignInScheme <- CookieAuthenticationDefaults.AuthenticationScheme
|
||||||
.AddJwtBearer(
|
opts.DefaultChallengeScheme <- CookieAuthenticationDefaults.AuthenticationScheme)
|
||||||
/// Configure JWT options with Auth0 options from configuration
|
.AddCookie()
|
||||||
|
.AddOpenIdConnect("Auth0",
|
||||||
|
/// Configure OIDC with Auth0 options from configuration
|
||||||
fun opts ->
|
fun opts ->
|
||||||
let jwtCfg = bldr.Configuration.GetSection "Auth0"
|
let cfg = bldr.Configuration.GetSection "Auth0"
|
||||||
opts.Authority <- sprintf "https://%s/" jwtCfg.["Domain"]
|
opts.Authority <- sprintf "https://%s/" cfg.["Domain"]
|
||||||
opts.Audience <- jwtCfg.["Audience"])
|
opts.ClientId <- cfg.["Id"]
|
||||||
|
opts.ClientSecret <- cfg.["Secret"]
|
||||||
|
opts.ResponseType <- OpenIdConnectResponseType.Code
|
||||||
|
|
||||||
|
opts.Scope.Clear ()
|
||||||
|
opts.Scope.Add "openid"
|
||||||
|
opts.Scope.Add "profile"
|
||||||
|
|
||||||
|
opts.CallbackPath <- PathString "/user/log-on/success"
|
||||||
|
opts.ClaimsIssuer <- "Auth0"
|
||||||
|
opts.SaveTokens <- true
|
||||||
|
|
||||||
|
opts.Events <- OpenIdConnectEvents ()
|
||||||
|
opts.Events.OnRedirectToIdentityProviderForSignOut <- fun ctx ->
|
||||||
|
let returnTo =
|
||||||
|
match ctx.Properties.RedirectUri with
|
||||||
|
| it when isNull it || it = "" -> ""
|
||||||
|
| redirUri ->
|
||||||
|
let finalRedirUri =
|
||||||
|
match redirUri.StartsWith "/" with
|
||||||
|
| true ->
|
||||||
|
// transform to absolute
|
||||||
|
let request = ctx.Request
|
||||||
|
sprintf "%s://%s%s%s" request.Scheme request.Host.Value request.PathBase.Value redirUri
|
||||||
|
| false -> redirUri
|
||||||
|
Uri.EscapeDataString finalRedirUri |> sprintf "&returnTo=%s"
|
||||||
|
sprintf "https://%s/v2/logout?client_id=%s%s" cfg.["Domain"] cfg.["Id"] returnTo
|
||||||
|
|> ctx.Response.Redirect
|
||||||
|
ctx.HandleResponse ()
|
||||||
|
|
||||||
|
Task.CompletedTask
|
||||||
|
)
|
||||||
|> ignore
|
|> ignore
|
||||||
let jsonOptions = JsonSerializerOptions ()
|
let jsonOptions = JsonSerializerOptions ()
|
||||||
jsonOptions.Converters.Add (JsonFSharpConverter ())
|
jsonOptions.Converters.Add (JsonFSharpConverter ())
|
||||||
@ -97,11 +145,14 @@ module Configure =
|
|||||||
| true -> app.UseDeveloperExceptionPage ()
|
| true -> app.UseDeveloperExceptionPage ()
|
||||||
| false -> app.UseGiraffeErrorHandler Handlers.Error.error
|
| false -> app.UseGiraffeErrorHandler Handlers.Error.error
|
||||||
|> ignore
|
|> ignore
|
||||||
app.UseAuthentication()
|
app.UseStaticFiles()
|
||||||
.UseStaticFiles()
|
.UseCookiePolicy()
|
||||||
.UseRouting()
|
.UseRouting()
|
||||||
|
.UseAuthentication()
|
||||||
|
// .UseAuthorization()
|
||||||
.UseEndpoints (fun e ->
|
.UseEndpoints (fun e ->
|
||||||
e.MapGiraffeEndpoints Handlers.routes
|
e.MapGiraffeEndpoints Handlers.routes
|
||||||
|
// TODO: fallback to 404
|
||||||
e.MapFallbackToFile "index.html" |> ignore)
|
e.MapFallbackToFile "index.html" |> ignore)
|
||||||
|> ignore
|
|> ignore
|
||||||
app
|
app
|
||||||
|
@ -9,7 +9,10 @@ open System
|
|||||||
module Helpers =
|
module Helpers =
|
||||||
|
|
||||||
/// Create a link that targets the `main` element and pushes a URL to history
|
/// Create a link that targets the `main` element and pushes a URL to history
|
||||||
let pageLink href attrs = a (attrs |> List.append [ _href href; _hxBoost; _hxTarget "main"; _hxPushUrl ])
|
let pageLink href attrs =
|
||||||
|
attrs
|
||||||
|
|> List.append [ _href href; _hxBoost; _hxTarget "main"; _hxPushUrl ]
|
||||||
|
|> a
|
||||||
|
|
||||||
/// Create a Material icon
|
/// Create a Material icon
|
||||||
let icon name = span [ _class "material-icons" ] [ str name ]
|
let icon name = span [ _class "material-icons" ] [ str name ]
|
||||||
@ -235,18 +238,18 @@ module Navigation =
|
|||||||
/// Generate the navigation items based on the current state
|
/// Generate the navigation items based on the current state
|
||||||
let currentNav isAuthenticated hasSnoozed (url : Uri option) =
|
let currentNav isAuthenticated hasSnoozed (url : Uri option) =
|
||||||
seq {
|
seq {
|
||||||
match isAuthenticated with
|
|
||||||
| true ->
|
|
||||||
let currUrl = match url with Some u -> (u.PathAndQuery.Split '?').[0] | None -> ""
|
let currUrl = match url with Some u -> (u.PathAndQuery.Split '?').[0] | None -> ""
|
||||||
let navLink (matchUrl : string) =
|
let navLink (matchUrl : string) =
|
||||||
match currUrl.StartsWith matchUrl with true -> [ _class "is-active-route" ] | false -> []
|
match currUrl.StartsWith matchUrl with true -> [ _class "is-active-route" ] | false -> []
|
||||||
|> pageLink matchUrl
|
|> pageLink matchUrl
|
||||||
|
match isAuthenticated with
|
||||||
|
| true ->
|
||||||
li [ _class "nav-item" ] [ navLink "/journal" [ str "Journal" ] ]
|
li [ _class "nav-item" ] [ navLink "/journal" [ str "Journal" ] ]
|
||||||
li [ _class "nav-item" ] [ navLink "/requests/active" [ str "Active" ] ]
|
li [ _class "nav-item" ] [ navLink "/requests/active" [ str "Active" ] ]
|
||||||
if hasSnoozed then li [ _class "nav-item" ] [ navLink "/requests/snoozed" [ str "Snoozed" ] ]
|
if hasSnoozed then li [ _class "nav-item" ] [ navLink "/requests/snoozed" [ str "Snoozed" ] ]
|
||||||
li [ _class "nav-item" ] [ navLink "/requests/answered" [ str "Answered" ] ]
|
li [ _class "nav-item" ] [ navLink "/requests/answered" [ str "Answered" ] ]
|
||||||
li [ _class "nav-item" ] [ a [ _href "/user/log-off"; _onclick "mpj.logOff(event)" ] [ str "Log Off" ] ]
|
li [ _class "nav-item" ] [ a [ _href "/user/log-off" ] [ str "Log Off" ] ]
|
||||||
| false -> li [ _class "nav-item"] [ a [ _href "/user/log-on"; _onclick "mpj.logOn(event)"] [ str "Log On" ] ]
|
| false -> li [ _class "nav-item"] [ a [ _href "/user/log-on" ] [ str "Log On" ] ]
|
||||||
li [ _class "nav-item" ] [ a [ _href "https://docs.prayerjournal.me"; _target "_blank" ] [ str "Docs" ] ]
|
li [ _class "nav-item" ] [ a [ _href "https://docs.prayerjournal.me"; _target "_blank" ] [ str "Docs" ] ]
|
||||||
}
|
}
|
||||||
|> List.ofSeq
|
|> List.ofSeq
|
||||||
@ -290,7 +293,13 @@ module Journal =
|
|||||||
|
|
||||||
/// The journal loading page
|
/// The journal loading page
|
||||||
let journal user = article [ _class "container-fluid mt-3" ] [
|
let journal user = article [ _class "container-fluid mt-3" ] [
|
||||||
h2 [ _class "pb-3" ] [ str user; rawText "’s Prayer Journal" ]
|
h2 [ _class "pb-3" ] [
|
||||||
|
str user
|
||||||
|
match user with
|
||||||
|
| "Your" -> ()
|
||||||
|
| _ -> rawText "’s"
|
||||||
|
str " Prayer Journal"
|
||||||
|
]
|
||||||
p [
|
p [
|
||||||
_hxGet "/components/journal-items"
|
_hxGet "/components/journal-items"
|
||||||
_hxSwap HxSwap.OuterHtml
|
_hxSwap HxSwap.OuterHtml
|
||||||
@ -571,7 +580,8 @@ module Layout =
|
|||||||
|
|
||||||
/// The HTML `head` element
|
/// The HTML `head` element
|
||||||
let htmlHead pageTitle =
|
let htmlHead pageTitle =
|
||||||
head [] [
|
head [ _lang "en" ] [
|
||||||
|
meta [ _name "viewport"; _content "width=device-width, initial-scale=1" ]
|
||||||
title [] [ str pageTitle; rawText " « myPrayerJournal" ]
|
title [] [ str pageTitle; rawText " « myPrayerJournal" ]
|
||||||
link [
|
link [
|
||||||
_href "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
|
_href "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
|
||||||
@ -581,11 +591,6 @@ module Layout =
|
|||||||
]
|
]
|
||||||
link [ _href "https://fonts.googleapis.com/icon?family=Material+Icons"; _rel "stylesheet" ]
|
link [ _href "https://fonts.googleapis.com/icon?family=Material+Icons"; _rel "stylesheet" ]
|
||||||
link [ _href "/style/style.css"; _rel "stylesheet" ]
|
link [ _href "/style/style.css"; _rel "stylesheet" ]
|
||||||
script [
|
|
||||||
_src "https://unpkg.com/htmx.org@1.5.0"
|
|
||||||
_integrity "sha384-oGA+prIp5Vchu6we2YkI51UtVzN9Jpx2Z7PnR1I78PnZlN8LkrCT4lqqqmDkyrvI"
|
|
||||||
_crossorigin "anonymous"
|
|
||||||
] []
|
|
||||||
]
|
]
|
||||||
|
|
||||||
/// Element used to display toasts
|
/// Element used to display toasts
|
||||||
@ -612,13 +617,17 @@ module Layout =
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
script [
|
||||||
|
_src "https://unpkg.com/htmx.org@1.5.0"
|
||||||
|
_integrity "sha384-oGA+prIp5Vchu6we2YkI51UtVzN9Jpx2Z7PnR1I78PnZlN8LkrCT4lqqqmDkyrvI"
|
||||||
|
_crossorigin "anonymous"
|
||||||
|
] []
|
||||||
script [
|
script [
|
||||||
_async
|
_async
|
||||||
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
|
_src "https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
|
||||||
_integrity "sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
|
_integrity "sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
|
||||||
_crossorigin "anonymous"
|
_crossorigin "anonymous"
|
||||||
] []
|
] []
|
||||||
script [ _src "https://cdn.auth0.com/js/auth0-spa-js/1.13/auth0-spa-js.production.js" ] []
|
|
||||||
script [ _src "/script/mpj.js" ] []
|
script [ _src "/script/mpj.js" ] []
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -2,41 +2,6 @@
|
|||||||
|
|
||||||
/** myPrayerJournal script */
|
/** myPrayerJournal script */
|
||||||
const mpj = {
|
const mpj = {
|
||||||
/** Auth0 configuration */
|
|
||||||
auth: {
|
|
||||||
/** The Auth0 client */
|
|
||||||
auth0: null,
|
|
||||||
/** Configure the Auth0 client */
|
|
||||||
async configureClient () {
|
|
||||||
const response = await fetch("/auth-config.json")
|
|
||||||
const config = await response.json()
|
|
||||||
mpj.auth.auth0 = await createAuth0Client({
|
|
||||||
domain: config.domain,
|
|
||||||
client_id: config.clientId,
|
|
||||||
audience: config.audience
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
/** Whether the user is currently authenticated */
|
|
||||||
isAuthenticated: false,
|
|
||||||
/** Whether we should redirect to the journal the next time the menu items are refreshed */
|
|
||||||
redirToJournal: false,
|
|
||||||
/**
|
|
||||||
* Process a log on request
|
|
||||||
* @param {Event} e The HTML event from the `onclick` event
|
|
||||||
*/
|
|
||||||
async logOn (e) {
|
|
||||||
e.preventDefault()
|
|
||||||
await mpj.auth.auth0.loginWithRedirect({ redirect_uri: `${window.location.origin}/user/log-on` })
|
|
||||||
},
|
|
||||||
/**
|
|
||||||
* Log the user off
|
|
||||||
* @param {Event} e The HTML event from the `onclick` event
|
|
||||||
*/
|
|
||||||
logOff (e) {
|
|
||||||
e.preventDefault()
|
|
||||||
mpj.auth.auth0.logout({ returnTo: window.location.origin })
|
|
||||||
},
|
|
||||||
/**
|
/**
|
||||||
* Show a message via toast
|
* Show a message via toast
|
||||||
* @param {string} message The message to show
|
* @param {string} message The message to show
|
||||||
@ -89,37 +54,6 @@ const mpj = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
window.onload = async () => {
|
|
||||||
/** If the user is authenticated, set the JWT on the `body` tag */
|
|
||||||
const establishAuth = async () => {
|
|
||||||
mpj.isAuthenticated = await mpj.auth.auth0.isAuthenticated()
|
|
||||||
if (mpj.isAuthenticated) {
|
|
||||||
const token = await mpj.auth.auth0.getTokenSilently()
|
|
||||||
const user = await mpj.auth.auth0.getUser()
|
|
||||||
document.querySelector("body")
|
|
||||||
.setAttribute("hx-headers", `{ "Authorization": "Bearer ${token}", "X-Given-Name": "${user.given_name}" }`)
|
|
||||||
htmx.trigger(htmx.find(".navbar-nav"), "menu-refresh")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set up Auth0
|
|
||||||
await mpj.auth.configureClient()
|
|
||||||
await establishAuth()
|
|
||||||
if (mpj.isAuthenticated) return
|
|
||||||
|
|
||||||
// Handle log on code, if present
|
|
||||||
const query = window.location.search
|
|
||||||
if (query.includes("code=") && query.includes("state=")) {
|
|
||||||
await mpj.auth.auth0.handleRedirectCallback()
|
|
||||||
await establishAuth()
|
|
||||||
if (window.location.pathname === "/user/log-on") {
|
|
||||||
mpj.redirToJournal = true
|
|
||||||
} else {
|
|
||||||
window.history.replaceState({}, document.title, "/")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
htmx.on("htmx:afterOnLoad", function (evt) {
|
htmx.on("htmx:afterOnLoad", function (evt) {
|
||||||
const hdrs = evt.detail.xhr.getAllResponseHeaders()
|
const hdrs = evt.detail.xhr.getAllResponseHeaders()
|
||||||
// Set the page title if a header was in the response
|
// Set the page title if a header was in the response
|
||||||
@ -133,11 +67,3 @@ htmx.on("htmx:afterOnLoad", function (evt) {
|
|||||||
mpj.showToast(evt.detail.xhr.getResponseHeader("x-toast"))
|
mpj.showToast(evt.detail.xhr.getResponseHeader("x-toast"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
htmx.on("htmx:afterSettle", function (evt) {
|
|
||||||
// Redirect to the journal (once menu items load after log on)
|
|
||||||
if (mpj.redirToJournal
|
|
||||||
&& ([...evt.target.attributes].find(it => it.name === "hx-target")?.value ?? "") === ".navbar-nav") {
|
|
||||||
mpj.redirToJournal = false
|
|
||||||
document.querySelector(`a[href="/journal"]`).click()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
Loading…
Reference in New Issue
Block a user