diff --git a/src/api/MyPrayerJournal.Api/Data.fs b/src/api/MyPrayerJournal.Api/Data.fs index eba57ec..45b4831 100644 --- a/src/api/MyPrayerJournal.Api/Data.fs +++ b/src/api/MyPrayerJournal.Api/Data.fs @@ -58,11 +58,14 @@ module Entities = m.Property(fun e -> e.requestId).IsRequired () |> ignore m.Property(fun e -> e.asOf).IsRequired () |> ignore m.Property(fun e -> e.status).IsRequired() |> ignore + m.Property(fun e -> e.text) |> ignore m.HasOne(fun e -> e.request) .WithMany(fun r -> r.history :> IEnumerable) .HasForeignKey(fun e -> e.requestId :> obj) |> ignore) |> ignore + let typ = mb.Model.FindEntityType(typeof) + let prop = typ.FindProperty("text") mb.Model.FindEntityType(typeof).FindProperty("text").SetValueConverter (OptionConverter ()) /// Note is a note regarding a prayer request that does not result in an update to its text @@ -171,13 +174,9 @@ open System.Linq open System.Threading.Tasks /// Data context -type AppDbContext (opts : DbContextOptions) as self = +type AppDbContext (opts : DbContextOptions) = inherit DbContext (opts) - /// Register a disconnected entity with the context, having the given state - let registerAs state (e : 'TEntity when 'TEntity : not struct) = - self.Entry<'TEntity>(e).State <- state - [] val mutable private history : DbSet [] @@ -209,13 +208,17 @@ type AppDbContext (opts : DbContextOptions) as self = ] |> List.iter (fun x -> x mb) + /// Register a disconnected entity with the context, having the given state + member private this.RegisterAs<'TEntity when 'TEntity : not struct> state e = + this.Entry<'TEntity>(e).State <- state + /// Add an entity instance to the context - member __.AddEntry e = - registerAs EntityState.Added e + member this.AddEntry e = + this.RegisterAs EntityState.Added e /// Update the entity instance's values - member __.UpdateEntry e = - registerAs EntityState.Modified e + member this.UpdateEntry e = + this.RegisterAs EntityState.Modified e /// Retrieve all answered requests for the given user member this.AnsweredRequests userId : JournalRequest seq = @@ -227,7 +230,7 @@ type AppDbContext (opts : DbContextOptions) as self = member this.JournalByUserId userId : JournalRequest seq = upcast this.Journal .Where(fun r -> r.userId = userId && r.lastStatus <> "Answered") - .OrderBy(fun r -> r.asOf) + .OrderByDescending(fun r -> r.asOf) /// Retrieve a request by its ID and user ID member this.TryRequestById reqId userId : Task = diff --git a/src/api/MyPrayerJournal.Api/Handlers.fs b/src/api/MyPrayerJournal.Api/Handlers.fs index 986478e..ca839c9 100644 --- a/src/api/MyPrayerJournal.Api/Handlers.fs +++ b/src/api/MyPrayerJournal.Api/Handlers.fs @@ -32,8 +32,8 @@ module Error = module private Helpers = open Microsoft.AspNetCore.Http - open Microsoft.AspNetCore.Authorization open System.Threading.Tasks + open System.Security.Claims /// Get the database context from DI let db (ctx : HttpContext) = @@ -41,7 +41,12 @@ module private Helpers = /// Get the user's "sub" claim let user (ctx : HttpContext) = - ctx.User.Claims |> Seq.tryFind (fun u -> u.Type = "sub") + ctx.User.Claims |> Seq.tryFind (fun u -> u.Type = ClaimTypes.NameIdentifier) + + /// Get the current user's ID + // NOTE: this may raise if you don't run the request through the authorize handler first + let userId ctx = + ((user >> Option.get) ctx).Value /// Return a 201 CREATED response let created next ctx = @@ -51,20 +56,17 @@ module private Helpers = let jsNow () = DateTime.Now.Subtract(DateTime (1970, 1, 1)).TotalSeconds |> int64 |> (*) 1000L + /// Handler to return a 403 Not Authorized reponse let notAuthorized : HttpHandler = setStatusCode 403 >=> fun _ _ -> Task.FromResult None /// Handler to require authorization let authorize : HttpHandler = - fun next ctx -> - task { - let auth = ctx.GetService() - let! result = auth.AuthorizeAsync (ctx.User, "LoggedOn") - Console.WriteLine (sprintf "*** Auth succeeded = %b" result.Succeeded) - match result.Succeeded with - | true -> return! next ctx - | false -> return! notAuthorized next ctx - } + fun next ctx -> match user ctx with Some _ -> next ctx | None -> notAuthorized next ctx + + /// Flip JSON result so we can pipe into it + let asJson<'T> next ctx (o : 'T) = + json o next ctx /// Strongly-typed models for post requests @@ -107,9 +109,9 @@ module Journal = let journal : HttpHandler = authorize >=> fun next ctx -> - match user ctx with - | Some u -> json ((db ctx).JournalByUserId u.Value) next ctx - | None -> Error.notFound next ctx + userId ctx + |> (db ctx).JournalByUserId + |> asJson next ctx /// /api/request URLs @@ -119,155 +121,141 @@ module Request = /// POST /api/request let add : HttpHandler = - fun next ctx -> + authorize + >=> fun next ctx -> task { - match user ctx with - | Some u -> - let! r = ctx.BindJsonAsync () - let db = db ctx - let reqId = Cuid.Generate () - let now = jsNow () - { Request.empty with - requestId = reqId - userId = u.Value - enteredOn = now - snoozedUntil = 0L - } - |> db.AddEntry - { History.empty with - requestId = reqId - asOf = now - status = "Created" - text = Some r.requestText - } - |> db.AddEntry - let! _ = db.SaveChangesAsync () - let! req = db.TryJournalById reqId u.Value - match req with - | Some rqst -> return! (setStatusCode 201 >=> json rqst) next ctx - | None -> return! Error.notFound next ctx + let! r = ctx.BindJsonAsync () + let db = db ctx + let reqId = Cuid.Generate () + let usrId = userId ctx + let now = jsNow () + { Request.empty with + requestId = reqId + userId = usrId + enteredOn = now + snoozedUntil = 0L + } + |> db.AddEntry + { History.empty with + requestId = reqId + asOf = now + status = "Created" + text = Some r.requestText + } + |> db.AddEntry + let! _ = db.SaveChangesAsync () + let! req = db.TryJournalById reqId usrId + match req with + | Some rqst -> return! (setStatusCode 201 >=> json rqst) next ctx | None -> return! Error.notFound next ctx } /// POST /api/request/[req-id]/history let addHistory reqId : HttpHandler = - fun next ctx -> + authorize + >=> fun next ctx -> task { - match user ctx with - | Some u -> - let db = db ctx - let! req = db.TryRequestById reqId u.Value - match req with - | Some _ -> - let! hist = ctx.BindJsonAsync () - { History.empty with - requestId = reqId - asOf = jsNow () - status = hist.status - text = match hist.updateText with null | "" -> None | x -> Some x - } - |> db.AddEntry - let! _ = db.SaveChangesAsync () - return! created next ctx - | None -> return! Error.notFound next ctx + let db = db ctx + let! req = db.TryRequestById reqId (userId ctx) + match req with + | Some _ -> + let! hist = ctx.BindJsonAsync () + { History.empty with + requestId = reqId + asOf = jsNow () + status = hist.status + text = match hist.updateText with null | "" -> None | x -> Some x + } + |> db.AddEntry + let! _ = db.SaveChangesAsync () + return! created next ctx | None -> return! Error.notFound next ctx } /// POST /api/request/[req-id]/note let addNote reqId : HttpHandler = - fun next ctx -> + authorize + >=> fun next ctx -> task { - match user ctx with - | Some u -> - let db = db ctx - let! req = db.TryRequestById reqId u.Value - match req with - | Some _ -> - let! notes = ctx.BindJsonAsync () - { Note.empty with - requestId = reqId - asOf = jsNow () - notes = notes.notes - } - |> db.AddEntry - let! _ = db.SaveChangesAsync () - return! created next ctx - | None -> return! Error.notFound next ctx + let db = db ctx + let! req = db.TryRequestById reqId (userId ctx) + match req with + | Some _ -> + let! notes = ctx.BindJsonAsync () + { Note.empty with + requestId = reqId + asOf = jsNow () + notes = notes.notes + } + |> db.AddEntry + let! _ = db.SaveChangesAsync () + return! created next ctx | None -> return! Error.notFound next ctx } /// GET /api/requests/answered let answered : HttpHandler = - fun next ctx -> - match user ctx with - | Some u -> json ((db ctx).AnsweredRequests u.Value) next ctx - | None -> Error.notFound next ctx + authorize + >=> fun next ctx -> + userId ctx + |> (db ctx).AnsweredRequests + |> asJson next ctx /// GET /api/request/[req-id] let get reqId : HttpHandler = - fun next ctx -> + authorize + >=> fun next ctx -> task { - match user ctx with - | Some u -> - let! req = (db ctx).TryRequestById reqId u.Value - match req with - | Some r -> return! json r next ctx - | None -> return! Error.notFound next ctx + let! req = (db ctx).TryRequestById reqId (userId ctx) + match req with + | Some r -> return! json r next ctx | None -> return! Error.notFound next ctx } /// GET /api/request/[req-id]/complete let getComplete reqId : HttpHandler = - fun next ctx -> + authorize + >=> fun next ctx -> task { - match user ctx with - | Some u -> - let! req = (db ctx).TryCompleteRequestById reqId u.Value - match req with - | Some r -> return! json r next ctx - | None -> return! Error.notFound next ctx + let! req = (db ctx).TryCompleteRequestById reqId (userId ctx) + match req with + | Some r -> return! json r next ctx | None -> return! Error.notFound next ctx } /// GET /api/request/[req-id]/full let getFull reqId : HttpHandler = - fun next ctx -> + authorize + >=> fun next ctx -> task { - match user ctx with - | Some u -> - let! req = (db ctx).TryFullRequestById reqId u.Value - match req with - | Some r -> return! json r next ctx - | None -> return! Error.notFound next ctx + let! req = (db ctx).TryFullRequestById reqId (userId ctx) + match req with + | Some r -> return! json r next ctx | None -> return! Error.notFound next ctx } /// GET /api/request/[req-id]/notes let getNotes reqId : HttpHandler = - fun next ctx -> + authorize + >=> fun next ctx -> task { - match user ctx with - | Some u -> - let! notes = (db ctx).NotesById reqId u.Value - return! json notes next ctx - | None -> return! Error.notFound next ctx + let! notes = (db ctx).NotesById reqId (userId ctx) + return! json notes next ctx } /// POST /api/request/[req-id]/snooze let snooze reqId : HttpHandler = - fun next ctx -> + authorize + >=> fun next ctx -> task { - match user ctx with - | Some u -> - let db = db ctx - let! req = db.TryRequestById reqId u.Value - match req with - | Some r -> - let! until = ctx.BindJsonAsync () - { r with snoozedUntil = until.until } - |> db.UpdateEntry - let! _ = db.SaveChangesAsync () - return! setStatusCode 204 next ctx - | None -> return! Error.notFound next ctx + let db = db ctx + let! req = db.TryRequestById reqId (userId ctx) + match req with + | Some r -> + let! until = ctx.BindJsonAsync () + { r with snoozedUntil = until.until } + |> db.UpdateEntry + let! _ = db.SaveChangesAsync () + return! setStatusCode 204 next ctx | None -> return! Error.notFound next ctx } diff --git a/src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj b/src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj index 66b438d..62d8149 100644 --- a/src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj +++ b/src/api/MyPrayerJournal.Api/MyPrayerJournal.Api.fsproj @@ -16,6 +16,7 @@ + diff --git a/src/api/MyPrayerJournal.Api/Program.fs b/src/api/MyPrayerJournal.Api/Program.fs index 7cf8951..f777256 100644 --- a/src/api/MyPrayerJournal.Api/Program.fs +++ b/src/api/MyPrayerJournal.Api/Program.fs @@ -2,18 +2,21 @@ namespace MyPrayerJournal.Api open Microsoft.AspNetCore.Builder open Microsoft.AspNetCore.Hosting +open System /// Configuration functions for the application module Configure = + open Giraffe + open Giraffe.TokenRouter open Microsoft.AspNetCore.Authentication.JwtBearer open Microsoft.AspNetCore.Server.Kestrel.Core + open Microsoft.EntityFrameworkCore open Microsoft.Extensions.Configuration open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.Logging - open Giraffe - open Giraffe.TokenRouter + open MyPrayerJournal /// Set up the configuration for the app let configuration (ctx : WebHostBuilderContext) (cfg : IConfigurationBuilder) = @@ -29,21 +32,22 @@ module Configure = /// Configure dependency injection let services (sc : IServiceCollection) = - sc.AddGiraffe () |> ignore - // mad props to Andrea Chiarelli @ https://auth0.com/blog/securing-asp-dot-net-core-2-applications-with-jwts/ use sp = sc.BuildServiceProvider() - let cfg = sp.GetRequiredService().GetSection "Auth0" - sc.AddAuthentication( - fun opts -> - opts.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme - opts.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme) - .AddJwtBearer ( + let cfg = sp.GetRequiredService () + sc.AddGiraffe() + .AddAuthentication( + /// Use HTTP "Bearer" authentication with JWTs fun opts -> - opts.Authority <- sprintf "https://%s/" cfg.["Domain"] - opts.Audience <- cfg.["Audience"] - opts.TokenValidationParameters.ValidateAudience <- false) + opts.DefaultAuthenticateScheme <- JwtBearerDefaults.AuthenticationScheme + opts.DefaultChallengeScheme <- JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer( + /// Configure JWT options with Auth0 options from configuration + fun opts -> + let jwtCfg = cfg.GetSection "Auth0" + opts.Authority <- sprintf "https://%s/" jwtCfg.["Domain"] + opts.Audience <- jwtCfg.["Id"]) |> ignore - sc.AddAuthorization (fun opts -> opts.AddPolicy ("LoggedOn", fun p -> p.RequireClaim "sub" |> ignore)) + sc.AddDbContext(fun opts -> opts.UseNpgsql(cfg.GetConnectionString "mpj") |> ignore) |> ignore /// Routes for the available URLs within myPrayerJournal @@ -96,12 +100,11 @@ module Configure = module Program = - open System open System.IO let exitCode = 0 - let CreateWebHostBuilder args = + let CreateWebHostBuilder _ = let contentRoot = Directory.GetCurrentDirectory () WebHostBuilder() .UseContentRoot(contentRoot) diff --git a/src/app/src/api/index.js b/src/app/src/api/index.js index e6d6436..d51e1ea 100644 --- a/src/app/src/api/index.js +++ b/src/app/src/api/index.js @@ -31,12 +31,12 @@ export default { * Add a new prayer request * @param {string} requestText The text of the request to be added */ - addRequest: requestText => http.post('request/', { requestText }), + addRequest: requestText => http.post('request', { requestText }), /** * Get all answered requests, along with the text they had when it was answered */ - getAnsweredRequests: () => http.get('request/answered'), + getAnsweredRequests: () => http.get('requests/answered'), /** * Get a prayer request (full; includes all history) @@ -64,7 +64,7 @@ export default { /** * Get all prayer requests and their most recent updates */ - journal: () => http.get('journal/'), + journal: () => http.get('journal'), /** * Update a prayer request