From 1897095ff25ef04feb94fb0cae734a2dcaa5d466 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 17 Apr 2022 21:30:00 -0400 Subject: [PATCH] Add log on; fix DotLiquid rendering --- src/MyWebLog.Data/Data.fs | 13 +- src/MyWebLog.Domain/ViewModels.fs | 22 ++ src/MyWebLog/Handlers.fs | 209 ++++++++++++++++-- src/MyWebLog/Program.fs | 8 + src/MyWebLog/themes/admin/dashboard.liquid | 50 +++++ src/MyWebLog/themes/admin/layout.liquid | 47 ++++ src/MyWebLog/themes/admin/log-on.liquid | 26 +++ src/MyWebLog/themes/default/_html-head.liquid | 11 - src/MyWebLog/themes/default/_page-foot.liquid | 6 - src/MyWebLog/themes/default/_page-head.liquid | 18 -- src/MyWebLog/themes/default/layout.liquid | 47 ++++ .../themes/default/single-page.liquid | 18 +- src/MyWebLog/wwwroot/img/logo-dark.png | Bin 0 -> 3362 bytes src/MyWebLog/wwwroot/img/logo-light.png | Bin 0 -> 4135 bytes .../wwwroot/{ => themes}/admin/admin.css | 0 15 files changed, 409 insertions(+), 66 deletions(-) create mode 100644 src/MyWebLog/themes/admin/dashboard.liquid create mode 100644 src/MyWebLog/themes/admin/layout.liquid create mode 100644 src/MyWebLog/themes/admin/log-on.liquid delete mode 100644 src/MyWebLog/themes/default/_html-head.liquid delete mode 100644 src/MyWebLog/themes/default/_page-foot.liquid delete mode 100644 src/MyWebLog/themes/default/_page-head.liquid create mode 100644 src/MyWebLog/themes/default/layout.liquid create mode 100644 src/MyWebLog/wwwroot/img/logo-dark.png create mode 100644 src/MyWebLog/wwwroot/img/logo-light.png rename src/MyWebLog/wwwroot/{ => themes}/admin/admin.css (100%) diff --git a/src/MyWebLog.Data/Data.fs b/src/MyWebLog.Data/Data.fs index b2b734a..71e1d59 100644 --- a/src/MyWebLog.Data/Data.fs +++ b/src/MyWebLog.Data/Data.fs @@ -94,7 +94,7 @@ module Startup = let! _ = rethink { withTable table - indexCreate "logOn" (fun row -> r.Array(row.G "webLogId", row.G "email")) + indexCreate "logOn" (fun row -> r.Array(row.G "webLogId", row.G "userName")) write withRetryOnce conn } @@ -341,4 +341,15 @@ module WebLogUser = withRetryDefault ignoreResult } + + /// Find a user by their e-mail address + let findByEmail (email : string) (webLogId : WebLogId) = + rethink { + withTable Table.WebLogUser + getAll [ r.Array (webLogId, email) ] "logOn" + limit 1 + result + withRetryDefault + } + |> tryFirst \ No newline at end of file diff --git a/src/MyWebLog.Domain/ViewModels.fs b/src/MyWebLog.Domain/ViewModels.fs index 3d421f0..227e460 100644 --- a/src/MyWebLog.Domain/ViewModels.fs +++ b/src/MyWebLog.Domain/ViewModels.fs @@ -30,3 +30,25 @@ type SinglePageModel = } /// Is this the home page? member this.isHome with get () = PageId.toString this.page.id = this.webLog.defaultPage + + +/// The model used to display the admin dashboard +type DashboardModel = + { /// The number of published posts + posts : int + + /// The number of post drafts + drafts : int + + /// The number of pages + pages : int + + /// The number of pages in the page list + listedPages : int + + /// The number of categories + categories : int + + /// The top-level categories + topLevelCategories : int + } \ No newline at end of file diff --git a/src/MyWebLog/Handlers.fs b/src/MyWebLog/Handlers.fs index 262e2b7..339f633 100644 --- a/src/MyWebLog/Handlers.fs +++ b/src/MyWebLog/Handlers.fs @@ -1,36 +1,139 @@ [] module MyWebLog.Handlers +open DotLiquid open Giraffe +open Microsoft.AspNetCore.Http open MyWebLog open MyWebLog.ViewModels +open RethinkDb.Driver.Net open System +open System.Net +open System.Threading.Tasks + +/// Handlers for error conditions +module Error = + + (* open Microsoft.Extensions.Logging *) + + (*/// Handle errors + let error (ex : Exception) (log : ILogger) = + log.LogError (EventId(), ex, "An unhandled exception has occurred while executing the request.") + clearResponse + >=> setStatusCode 500 + >=> setHttpHeader "X-Toast" (sprintf "error|||%s: %s" (ex.GetType().Name) ex.Message) + >=> text ex.Message *) + + /// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response + let notAuthorized : HttpHandler = + fun next ctx -> + (next, ctx) + ||> match ctx.Request.Method with + | "GET" -> redirectTo false $"/user/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}" + | _ -> setStatusCode 401 >=> fun _ _ -> Task.FromResult None + + /// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there + let notFound : HttpHandler = + setStatusCode 404 >=> text "Not found" + [] module private Helpers = - open DotLiquid + open Microsoft.Extensions.DependencyInjection open System.Collections.Concurrent open System.IO /// Cache for parsed templates - let private themeViews = ConcurrentDictionary () + module private TemplateCache = + + /// Cache of parsed templates + let private views = ConcurrentDictionary () + + /// Get a template for the given web log + let get (theme : string) (templateName : string) = task { + let templatePath = $"themes/{theme}/{templateName}" + match views.ContainsKey templatePath with + | true -> () + | false -> + let! file = File.ReadAllTextAsync $"{templatePath}.liquid" + views[templatePath] <- Template.Parse (file, SyntaxCompatibility.DotLiquid22) + return views[templatePath] + } - /// Return a view for a theme - let themedView<'T> (template : string) (model : obj) : HttpHandler = fun next ctx -> task { - let webLog = WebLogCache.getByCtx ctx - let templatePath = $"themes/{webLog.themePath}/{template}" - match themeViews.ContainsKey templatePath with - | true -> () + /// Either get the web log from the hash, or get it from the cache and add it to the hash + let deriveWebLogFromHash (hash : Hash) ctx = + match hash.ContainsKey "web_log" with + | true -> hash["web_log"] :?> WebLog | false -> - let! file = File.ReadAllTextAsync $"{templatePath}.liquid" - themeViews[templatePath] <- Template.Parse file - let view = themeViews[templatePath].Render (Hash.FromAnonymousObject model) - return! htmlString view next ctx + let wl = WebLogCache.getByCtx ctx + hash.Add ("web_log", wl) + wl + + /// Render a view for the specified theme, using the specified template, layout, and hash + let viewForTheme theme template layout next ctx = fun (hash : Hash) -> task { + // Don't need the web log, but this adds it to the hash if the function is called directly + let _ = deriveWebLogFromHash hash ctx + hash.Add ("logged_on", ctx.User.Identity.IsAuthenticated) + + // NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a two-pass + // render; the net effect is a "layout" capability similar to Razor or Pug + + // Render view content... + let! contentTemplate = TemplateCache.get theme template + hash.Add ("content", contentTemplate.Render hash) + + // ...then render that content with its layout + let! layoutTemplate = TemplateCache.get theme (defaultArg layout "layout") + return! htmlString (layoutTemplate.Render hash) next ctx } + + /// Return a view for the web log's default theme + let themedView template layout next ctx = fun (hash : Hash) -> task { + return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template layout next ctx hash + } + + /// The web log ID for the current request + let webLogId ctx = (WebLogCache.getByCtx ctx).id + + let conn (ctx : HttpContext) = ctx.RequestServices.GetRequiredService () + + +module Admin = + + // GET /admin/ + let dashboard : HttpHandler = + requiresAuthentication Error.notFound + >=> fun next ctx -> task { + let webLogId' = webLogId ctx + let conn' = conn ctx + let getCount (f : WebLogId -> IConnection -> Task) = f webLogId' conn' + let! posts = Data.Post.countByStatus Published |> getCount + let! drafts = Data.Post.countByStatus Draft |> getCount + let! pages = Data.Page.countAll |> getCount + let! listed = Data.Page.countListed |> getCount + let! cats = Data.Category.countAll |> getCount + let! topCats = Data.Category.countTopLevel |> getCount + return! + Hash.FromAnonymousObject + {| page_title = "Dashboard" + model = + { posts = posts + drafts = drafts + pages = pages + listedPages = listed + categories = cats + topLevelCategories = topCats + } + |} + |> viewForTheme "admin" "dashboard" None next ctx + } module User = + open Microsoft.AspNetCore.Authentication; + open Microsoft.AspNetCore.Authentication.Cookies + open System.Security.Claims open System.Security.Cryptography open System.Text @@ -39,13 +142,74 @@ module User = let allSalt = Array.concat [ salt.ToByteArray(); (Encoding.UTF8.GetBytes email) ] use alg = new Rfc2898DeriveBytes (plainText, allSalt, 2_048) Convert.ToBase64String(alg.GetBytes(64)) + + // GET /user/log-on + let logOn : HttpHandler = fun next ctx -> task { + return! + Hash.FromAnonymousObject {| page_title = "Log On" |} + |> viewForTheme "admin" "log-on" None next ctx + } + + // POST /user/log-on + let doLogOn : HttpHandler = fun next ctx -> task { + let! model = ctx.BindFormAsync () + match! Data.WebLogUser.findByEmail model.emailAddress (webLogId ctx) (conn ctx) with + | Some user when user.passwordHash = hashedPassword model.password user.userName user.salt -> + let claims = seq { + Claim (ClaimTypes.NameIdentifier, WebLogUserId.toString user.id) + Claim (ClaimTypes.Name, $"{user.firstName} {user.lastName}") + Claim (ClaimTypes.GivenName, user.preferredName) + Claim (ClaimTypes.Role, user.authorizationLevel.ToString ()) + } + let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme) + + do! ctx.SignInAsync (identity.AuthenticationType, ClaimsPrincipal identity, + AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow)) + + // TODO: confirmation message + + return! redirectTo false "/admin/" next ctx + | _ -> + // TODO: make error, not 404 + return! Error.notFound next ctx + } + + let logOff : HttpHandler = fun next ctx -> task { + do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme + + // TODO: confirmation message + + return! redirectTo false "/" next ctx + } module CatchAll = + // GET / + let home : HttpHandler = fun next ctx -> task { + let webLog = WebLogCache.getByCtx ctx + match webLog.defaultPage with + | "posts" -> + // TODO: page of posts + return! Error.notFound next ctx + | pageId -> + match! Data.Page.findById (PageId pageId) webLog.id (conn ctx) with + | Some page -> + return! + Hash.FromAnonymousObject {| page = page; page_title = page.title |} + |> themedView "single-page" page.template next ctx + | None -> return! Error.notFound next ctx + } + let catchAll : HttpHandler = fun next ctx -> task { - let testPage = { Page.empty with text = "Howdy, folks!" } - return! themedView "single-page" { page = testPage; webLog = WebLogCache.getByCtx ctx } next ctx + let webLog = WebLogCache.getByCtx ctx + let pageId = PageId webLog.defaultPage + match! Data.Page.findById pageId webLog.id (conn ctx) with + | Some page -> + return! + Hash.FromAnonymousObject {| page = page; page_title = page.title |} + |> themedView "single-page" page.template next ctx + | None -> return! Error.notFound next ctx } open Giraffe.EndpointRouting @@ -53,7 +217,20 @@ open Giraffe.EndpointRouting /// The endpoints defined in the above handlers let endpoints = [ GET [ - route "" CatchAll.catchAll + route "/" CatchAll.home + ] + subRoute "/admin" [ + GET [ + route "/" Admin.dashboard + ] + ] + subRoute "/user" [ + GET [ + route "/log-on" User.logOn + route "/log-off" User.logOff + ] + POST [ + route "/log-on" User.doLogOn + ] ] ] - \ No newline at end of file diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index 6f89575..b1402f5 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -103,6 +103,8 @@ let initDb args sp = task { return! System.Threading.Tasks.Task.CompletedTask } +open DotLiquid +open MyWebLog.ViewModels [] let main args = @@ -131,6 +133,12 @@ let main args = return conn } |> Async.AwaitTask |> Async.RunSynchronously let _ = builder.Services.AddSingleton conn + + // Set up DotLiquid + let all = [| "*" |] + Template.RegisterSafeType (typeof, all) + Template.RegisterSafeType (typeof, all) + Template.RegisterSafeType (typeof, all) let app = builder.Build () diff --git a/src/MyWebLog/themes/admin/dashboard.liquid b/src/MyWebLog/themes/admin/dashboard.liquid new file mode 100644 index 0000000..7347437 --- /dev/null +++ b/src/MyWebLog/themes/admin/dashboard.liquid @@ -0,0 +1,50 @@ + diff --git a/src/MyWebLog/themes/admin/layout.liquid b/src/MyWebLog/themes/admin/layout.liquid new file mode 100644 index 0000000..88e2929 --- /dev/null +++ b/src/MyWebLog/themes/admin/layout.liquid @@ -0,0 +1,47 @@ + + + + + {{ page_title | escape }} « Admin « {{ web_log.name | escape }} + + + + +
+ +
+
+ {{ content }} +
+
+
+
+
myWebLog
+
+
+
+ + + diff --git a/src/MyWebLog/themes/admin/log-on.liquid b/src/MyWebLog/themes/admin/log-on.liquid new file mode 100644 index 0000000..ead975f --- /dev/null +++ b/src/MyWebLog/themes/admin/log-on.liquid @@ -0,0 +1,26 @@ +

Log On to {{ web_log.name }}

+
+
+
+
+
+
+ + +
+
+
+
+ + +
+
+
+
+
+ +
+
+
+
+
diff --git a/src/MyWebLog/themes/default/_html-head.liquid b/src/MyWebLog/themes/default/_html-head.liquid deleted file mode 100644 index 68400c0..0000000 --- a/src/MyWebLog/themes/default/_html-head.liquid +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - - - - {{ title | escape }} « {{ web_log_name | escape }} - diff --git a/src/MyWebLog/themes/default/_page-foot.liquid b/src/MyWebLog/themes/default/_page-foot.liquid deleted file mode 100644 index f96fc6f..0000000 --- a/src/MyWebLog/themes/default/_page-foot.liquid +++ /dev/null @@ -1,6 +0,0 @@ -
-
-
- myWebLog -
-
diff --git a/src/MyWebLog/themes/default/_page-head.liquid b/src/MyWebLog/themes/default/_page-head.liquid deleted file mode 100644 index 7a3ebcd..0000000 --- a/src/MyWebLog/themes/default/_page-head.liquid +++ /dev/null @@ -1,18 +0,0 @@ -
- -
diff --git a/src/MyWebLog/themes/default/layout.liquid b/src/MyWebLog/themes/default/layout.liquid new file mode 100644 index 0000000..893d41a --- /dev/null +++ b/src/MyWebLog/themes/default/layout.liquid @@ -0,0 +1,47 @@ + + + + + + + + + {{ page_title | escape }} « {{ web_log.name | escape }} + + +
+ +
+
+ {{ content }} +
+
+
+
+ myWebLog +
+
+ + diff --git a/src/MyWebLog/themes/default/single-page.liquid b/src/MyWebLog/themes/default/single-page.liquid index 9b6f6a4..58d0cc2 100644 --- a/src/MyWebLog/themes/default/single-page.liquid +++ b/src/MyWebLog/themes/default/single-page.liquid @@ -1,14 +1,4 @@ - - - {{ render "_html-head", title: title, web_log_name: web_log.name }} - - {{ render "_page-head", web_log: web_log }} -
-

{{ page.title }}

-
- {{ page.text }} -
-
- {{ render "_page-foot" }} - - +

{{ page.title }}

+
+ {{ page.text }} +
diff --git a/src/MyWebLog/wwwroot/img/logo-dark.png b/src/MyWebLog/wwwroot/img/logo-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..19bdcca2af3095d7f18345a28fbf9c6da0363daa GIT binary patch literal 3362 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 l1PBlyK!5-N0tD!0_<#2