Add page edit
- Add nav link and user log-on link filter/tag - Add page list support - Add prior permalink index/search - Remove v2 C# projects
This commit is contained in:
74
src/MyWebLog/Caches.fs
Normal file
74
src/MyWebLog/Caches.fs
Normal file
@@ -0,0 +1,74 @@
|
||||
namespace MyWebLog
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
|
||||
/// Helper functions for caches
|
||||
module Cache =
|
||||
|
||||
/// Create the cache key for the web log for the current request
|
||||
let makeKey (ctx : HttpContext) = ctx.Request.Host.ToUriComponent ()
|
||||
|
||||
|
||||
open System.Collections.Concurrent
|
||||
|
||||
/// <summary>
|
||||
/// In-memory cache of web log details
|
||||
/// </summary>
|
||||
/// <remarks>This is filled by the middleware via the first request for each host, and can be updated via the web log
|
||||
/// settings update page</remarks>
|
||||
module WebLogCache =
|
||||
|
||||
/// The cache of web log details
|
||||
let private _cache = ConcurrentDictionary<string, WebLog> ()
|
||||
|
||||
/// Does a host exist in the cache?
|
||||
let exists ctx = _cache.ContainsKey (Cache.makeKey ctx)
|
||||
|
||||
/// Get the web log for the current request
|
||||
let get ctx = _cache[Cache.makeKey ctx]
|
||||
|
||||
/// Cache the web log for a particular host
|
||||
let set ctx webLog = _cache[Cache.makeKey ctx] <- webLog
|
||||
|
||||
|
||||
/// A cache of page information needed to display the page list in templates
|
||||
module PageListCache =
|
||||
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open MyWebLog.ViewModels
|
||||
open RethinkDb.Driver.Net
|
||||
|
||||
/// Cache of displayed pages
|
||||
let private _cache = ConcurrentDictionary<string, DisplayPage[]> ()
|
||||
|
||||
/// Get the pages for the web log for this request
|
||||
let get ctx = _cache[Cache.makeKey ctx]
|
||||
|
||||
/// Update the pages for the current web log
|
||||
let update ctx = task {
|
||||
let webLog = WebLogCache.get ctx
|
||||
let conn = ctx.RequestServices.GetRequiredService<IConnection> ()
|
||||
let! pages = Data.Page.findListed webLog.id conn
|
||||
_cache[Cache.makeKey ctx] <- pages |> List.map (DisplayPage.fromPage webLog) |> Array.ofList
|
||||
}
|
||||
|
||||
/// Cache for parsed templates
|
||||
module TemplateCache =
|
||||
|
||||
open DotLiquid
|
||||
open System.IO
|
||||
|
||||
/// Cache of parsed templates
|
||||
let private _cache = ConcurrentDictionary<string, Template> ()
|
||||
|
||||
/// Get a template for the given theme and template nate
|
||||
let get (theme : string) (templateName : string) = task {
|
||||
let templatePath = $"themes/{theme}/{templateName}"
|
||||
match _cache.ContainsKey templatePath with
|
||||
| true -> ()
|
||||
| false ->
|
||||
let! file = File.ReadAllTextAsync $"{templatePath}.liquid"
|
||||
_cache[templatePath] <- Template.Parse (file, SyntaxCompatibility.DotLiquid22)
|
||||
return _cache[templatePath]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
[<RequireQualifiedAccess>]
|
||||
module MyWebLog.Handlers
|
||||
|
||||
open System.Collections.Generic
|
||||
open DotLiquid
|
||||
open Giraffe
|
||||
open Microsoft.AspNetCore.Http
|
||||
@@ -26,12 +25,11 @@ module Error =
|
||||
>=> 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<HttpContext option> None
|
||||
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<HttpContext option> None
|
||||
|
||||
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
|
||||
let notFound : HttpHandler =
|
||||
@@ -41,42 +39,27 @@ module Error =
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
open Markdig
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open System.Collections.Concurrent
|
||||
open System.IO
|
||||
|
||||
/// Cache for parsed templates
|
||||
module private TemplateCache =
|
||||
|
||||
/// Cache of parsed templates
|
||||
let private views = ConcurrentDictionary<string, Template> ()
|
||||
|
||||
/// 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]
|
||||
}
|
||||
|
||||
open System.Security.Claims
|
||||
|
||||
/// 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 =
|
||||
let private deriveWebLogFromHash (hash : Hash) ctx =
|
||||
match hash.ContainsKey "web_log" with
|
||||
| true -> hash["web_log"] :?> WebLog
|
||||
| false ->
|
||||
let wl = WebLogCache.getByCtx ctx
|
||||
let wl = WebLogCache.get 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 {
|
||||
let viewForTheme theme template 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)
|
||||
hash.Add ("logged_on", ctx.User.Identity.IsAuthenticated)
|
||||
hash.Add ("page_list", PageListCache.get ctx)
|
||||
hash.Add ("current_page", ctx.Request.Path.Value.Substring 1)
|
||||
|
||||
// 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
|
||||
@@ -86,20 +69,26 @@ module private Helpers =
|
||||
hash.Add ("content", contentTemplate.Render hash)
|
||||
|
||||
// ...then render that content with its layout
|
||||
let! layoutTemplate = TemplateCache.get theme (defaultArg layout "layout")
|
||||
let! layoutTemplate = TemplateCache.get theme "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
|
||||
let themedView template next ctx = fun (hash : Hash) -> task {
|
||||
return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template next ctx hash
|
||||
}
|
||||
|
||||
/// The web log ID for the current request
|
||||
let webLogId ctx = (WebLogCache.getByCtx ctx).id
|
||||
/// Get the web log ID for the current request
|
||||
let webLogId ctx = (WebLogCache.get ctx).id
|
||||
|
||||
/// Get the user ID for the current request
|
||||
let userId (ctx : HttpContext) =
|
||||
WebLogUserId (ctx.User.Claims |> Seq.find (fun c -> c.Type = ClaimTypes.NameIdentifier)).Value
|
||||
|
||||
/// Get the RethinkDB connection
|
||||
let conn (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IConnection> ()
|
||||
|
||||
/// Get the Anti-CSRF service
|
||||
let private antiForgery (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IAntiforgery> ()
|
||||
|
||||
/// Get the cross-site request forgery token set
|
||||
@@ -115,16 +104,26 @@ module private Helpers =
|
||||
|
||||
/// Require a user to be logged on
|
||||
let requireUser = requiresAuthentication Error.notAuthorized
|
||||
|
||||
/// Pipeline with most extensions enabled
|
||||
let mdPipeline =
|
||||
MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().Build ()
|
||||
|
||||
/// Get the HTML representation of the text of a revision
|
||||
let revisionToHtml (rev : Revision) =
|
||||
match rev.sourceType with Html -> rev.text | Markdown -> Markdown.ToHtml (rev.text, mdPipeline)
|
||||
|
||||
|
||||
open System.Collections.Generic
|
||||
|
||||
/// Handlers to manipulate admin functions
|
||||
module Admin =
|
||||
|
||||
// GET /admin/
|
||||
// GET /admin
|
||||
let dashboard : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let webLogId' = webLogId ctx
|
||||
let conn' = conn ctx
|
||||
let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId' conn'
|
||||
let webLogId = webLogId ctx
|
||||
let conn = conn ctx
|
||||
let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId conn
|
||||
let! posts = Data.Post.countByStatus Published |> getCount
|
||||
let! drafts = Data.Post.countByStatus Draft |> getCount
|
||||
let! pages = Data.Page.countAll |> getCount
|
||||
@@ -143,12 +142,12 @@ module Admin =
|
||||
topLevelCategories = topCats
|
||||
}
|
||||
|}
|
||||
|> viewForTheme "admin" "dashboard" None next ctx
|
||||
|> viewForTheme "admin" "dashboard" next ctx
|
||||
}
|
||||
|
||||
// GET /admin/settings
|
||||
let settings : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let webLog = WebLogCache.getByCtx ctx
|
||||
let webLog = WebLogCache.get ctx
|
||||
let! allPages = Data.Page.findAll webLog.id (conn ctx)
|
||||
return!
|
||||
Hash.FromAnonymousObject
|
||||
@@ -170,14 +169,14 @@ module Admin =
|
||||
web_log = webLog
|
||||
page_title = "Web Log Settings"
|
||||
|}
|
||||
|> viewForTheme "admin" "settings" None next ctx
|
||||
|> viewForTheme "admin" "settings" next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings
|
||||
let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
let conn' = conn ctx
|
||||
let conn = conn ctx
|
||||
let! model = ctx.BindFormAsync<SettingsModel> ()
|
||||
match! Data.WebLog.findByHost (WebLogCache.getByCtx ctx).urlBase conn' with
|
||||
match! Data.WebLog.findById (WebLogCache.get ctx).id conn with
|
||||
| Some webLog ->
|
||||
let updated =
|
||||
{ webLog with
|
||||
@@ -187,14 +186,102 @@ module Admin =
|
||||
postsPerPage = model.postsPerPage
|
||||
timeZone = model.timeZone
|
||||
}
|
||||
do! Data.WebLog.updateSettings updated conn'
|
||||
do! Data.WebLog.updateSettings updated conn
|
||||
|
||||
// Update cache
|
||||
WebLogCache.set updated.urlBase updated
|
||||
WebLogCache.set ctx updated
|
||||
|
||||
// TODO: confirmation message
|
||||
|
||||
return! redirectTo false "/admin/" next ctx
|
||||
return! redirectTo false "/admin" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
|
||||
/// Handlers to manipulate pages
|
||||
module Page =
|
||||
|
||||
// GET /pages
|
||||
// GET /pages/page/{pageNbr}
|
||||
let all pageNbr : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let webLog = WebLogCache.get ctx
|
||||
let! pages = Data.Page.findPageOfPages webLog.id pageNbr (conn ctx)
|
||||
return!
|
||||
Hash.FromAnonymousObject
|
||||
{| pages = pages |> List.map (DisplayPage.fromPage webLog)
|
||||
page_title = "Pages"
|
||||
|}
|
||||
|> viewForTheme "admin" "page-list" next ctx
|
||||
}
|
||||
|
||||
// GET /page/{id}/edit
|
||||
let edit pgId : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let! hash = task {
|
||||
match pgId with
|
||||
| "new" ->
|
||||
return
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = EditPageModel.fromPage { Page.empty with id = PageId "new" }
|
||||
page_title = "Add a New Page"
|
||||
|} |> Some
|
||||
| _ ->
|
||||
match! Data.Page.findByFullId (PageId pgId) (webLogId ctx) (conn ctx) with
|
||||
| Some page ->
|
||||
return
|
||||
Hash.FromAnonymousObject {|
|
||||
csrf = csrfToken ctx
|
||||
model = EditPageModel.fromPage page
|
||||
page_title = "Edit Page"
|
||||
|} |> Some
|
||||
| None -> return None
|
||||
}
|
||||
match hash with
|
||||
| Some h -> return! viewForTheme "admin" "page-edit" next ctx h
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// POST /page/{id}/edit
|
||||
let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
let! model = ctx.BindFormAsync<EditPageModel> ()
|
||||
let webLogId = webLogId ctx
|
||||
let conn = conn ctx
|
||||
let now = DateTime.UtcNow
|
||||
let! pg = task {
|
||||
match model.pageId with
|
||||
| "new" ->
|
||||
return Some
|
||||
{ Page.empty with
|
||||
id = PageId.create ()
|
||||
webLogId = webLogId
|
||||
authorId = userId ctx
|
||||
publishedOn = now
|
||||
}
|
||||
| pgId -> return! Data.Page.findByFullId (PageId pgId) webLogId conn
|
||||
}
|
||||
match pg with
|
||||
| Some page ->
|
||||
let updateList = page.showInPageList <> model.isShownInPageList
|
||||
let revision = { asOf = now; sourceType = RevisionSource.ofString model.source; text = model.text }
|
||||
// Detect a permalink change, and add the prior one to the prior list
|
||||
let page =
|
||||
match Permalink.toString page.permalink with
|
||||
| "" -> page
|
||||
| link when link = model.permalink -> page
|
||||
| _ -> { page with priorPermalinks = page.permalink :: page.priorPermalinks }
|
||||
let page =
|
||||
{ page with
|
||||
title = model.title
|
||||
permalink = Permalink model.permalink
|
||||
updatedOn = now
|
||||
showInPageList = model.isShownInPageList
|
||||
text = revisionToHtml revision
|
||||
revisions = revision :: page.revisions
|
||||
}
|
||||
do! (match model.pageId with "new" -> Data.Page.add | _ -> Data.Page.update) page conn
|
||||
if updateList then do! PageListCache.update ctx
|
||||
// TODO: confirmation
|
||||
return! redirectTo false $"/page/{PageId.toString page.id}/edit" next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
@@ -204,21 +291,21 @@ module Post =
|
||||
|
||||
// GET /page/{pageNbr}
|
||||
let pageOfPosts (pageNbr : int) : HttpHandler = fun next ctx -> task {
|
||||
let webLog = WebLogCache.getByCtx ctx
|
||||
let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage (conn ctx)
|
||||
let hash = Hash.FromAnonymousObject {| posts = posts |}
|
||||
let title =
|
||||
let webLog = WebLogCache.get ctx
|
||||
let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage (conn ctx)
|
||||
let hash = Hash.FromAnonymousObject {| posts = posts |}
|
||||
let title =
|
||||
match pageNbr, webLog.defaultPage with
|
||||
| 1, "posts" -> None
|
||||
| _, "posts" -> Some $"Page {pageNbr}"
|
||||
| _, _ -> Some $"Page {pageNbr} « Posts"
|
||||
match title with Some ttl -> hash.Add ("page_title", ttl) | None -> ()
|
||||
return! themedView "index" None next ctx hash
|
||||
return! themedView "index" next ctx hash
|
||||
}
|
||||
|
||||
// GET /
|
||||
let home : HttpHandler = fun next ctx -> task {
|
||||
let webLog = WebLogCache.getByCtx ctx
|
||||
let webLog = WebLogCache.get ctx
|
||||
match webLog.defaultPage with
|
||||
| "posts" -> return! pageOfPosts 1 next ctx
|
||||
| pageId ->
|
||||
@@ -226,31 +313,38 @@ module Post =
|
||||
| Some page ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|
||||
|> themedView "single-page" page.template next ctx
|
||||
|> themedView (defaultArg page.template "single-page") next ctx
|
||||
| None -> return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
// GET *
|
||||
let catchAll (link : string) : HttpHandler = fun next ctx -> task {
|
||||
let webLog = WebLogCache.getByCtx ctx
|
||||
let conn' = conn ctx
|
||||
let permalink = Permalink link
|
||||
match! Data.Post.findByPermalink permalink webLog.id conn' with
|
||||
| Some post -> return! Error.notFound next ctx
|
||||
// GET {**link}
|
||||
let catchAll : HttpHandler = fun next ctx -> task {
|
||||
let webLog = WebLogCache.get ctx
|
||||
let conn = conn ctx
|
||||
let permalink = (string >> Permalink) ctx.Request.RouteValues["link"]
|
||||
// Current post
|
||||
match! Data.Post.findByPermalink permalink webLog.id conn with
|
||||
| Some _ -> return! Error.notFound next ctx
|
||||
// TODO: return via single-post action
|
||||
| None ->
|
||||
match! Data.Page.findByPermalink permalink webLog.id conn' with
|
||||
// Current page
|
||||
match! Data.Page.findByPermalink permalink webLog.id conn with
|
||||
| Some page ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|
||||
|> themedView "single-page" page.template next ctx
|
||||
|> themedView (defaultArg page.template "single-page") next ctx
|
||||
| None ->
|
||||
|
||||
// TOOD: search prior permalinks for posts and pages
|
||||
|
||||
// We tried, we really tried...
|
||||
Console.Write($"Returning 404 for permalink |{permalink}|");
|
||||
return! Error.notFound next ctx
|
||||
// Prior post
|
||||
match! Data.Post.findCurrentPermalink permalink webLog.id conn with
|
||||
| Some link -> return! redirectTo true $"/{Permalink.toString link}" next ctx
|
||||
| None ->
|
||||
// Prior page
|
||||
match! Data.Page.findCurrentPermalink permalink webLog.id conn with
|
||||
| Some link -> return! redirectTo true $"/{Permalink.toString link}" next ctx
|
||||
| None ->
|
||||
// We tried, we really did...
|
||||
Console.Write($"Returning 404 for permalink |{permalink}|");
|
||||
return! Error.notFound next ctx
|
||||
}
|
||||
|
||||
|
||||
@@ -265,15 +359,15 @@ module User =
|
||||
|
||||
/// Hash a password for a given user
|
||||
let hashedPassword (plainText : string) (email : string) (salt : Guid) =
|
||||
let allSalt = Array.concat [ salt.ToByteArray(); (Encoding.UTF8.GetBytes email) ]
|
||||
use alg = new Rfc2898DeriveBytes (plainText, allSalt, 2_048)
|
||||
Convert.ToBase64String(alg.GetBytes(64))
|
||||
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"; csrf = (csrfToken ctx) |}
|
||||
|> viewForTheme "admin" "log-on" None next ctx
|
||||
|> viewForTheme "admin" "log-on" next ctx
|
||||
}
|
||||
|
||||
// POST /user/log-on
|
||||
@@ -283,9 +377,9 @@ module User =
|
||||
| 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 ())
|
||||
Claim (ClaimTypes.Name, $"{user.firstName} {user.lastName}")
|
||||
Claim (ClaimTypes.GivenName, user.preferredName)
|
||||
Claim (ClaimTypes.Role, user.authorizationLevel.ToString ())
|
||||
}
|
||||
let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
|
||||
@@ -294,7 +388,7 @@ module User =
|
||||
|
||||
// TODO: confirmation message
|
||||
|
||||
return! redirectTo false "/admin/" next ctx
|
||||
return! redirectTo false "/admin" next ctx
|
||||
| _ ->
|
||||
// TODO: make error, not 404
|
||||
return! Error.notFound next ctx
|
||||
@@ -318,7 +412,7 @@ let endpoints = [
|
||||
]
|
||||
subRoute "/admin" [
|
||||
GET [
|
||||
route "/" Admin.dashboard
|
||||
route "" Admin.dashboard
|
||||
route "/settings" Admin.settings
|
||||
]
|
||||
POST [
|
||||
@@ -327,7 +421,13 @@ let endpoints = [
|
||||
]
|
||||
subRoute "/page" [
|
||||
GET [
|
||||
routef "/%d" Post.pageOfPosts
|
||||
routef "/%d" Post.pageOfPosts
|
||||
routef "/%s/edit" Page.edit
|
||||
route "s" (Page.all 1)
|
||||
routef "s/page/%d" Page.all
|
||||
]
|
||||
POST [
|
||||
route "/save" Page.save
|
||||
]
|
||||
]
|
||||
subRoute "/user" [
|
||||
@@ -339,4 +439,5 @@ let endpoints = [
|
||||
route "/log-on" User.doLogOn
|
||||
]
|
||||
]
|
||||
route "{**link}" Post.catchAll
|
||||
]
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<NoWarn>3391</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="appsettings.json" CopyToOutputDirectory="Always" />
|
||||
<Compile Include="WebLogCache.fs" />
|
||||
<Compile Include="Caches.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
@@ -15,6 +16,7 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="DotLiquid" Version="2.2.610" />
|
||||
<PackageReference Include="Giraffe" Version="6.0.0" />
|
||||
<PackageReference Include="Markdig" Version="0.28.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -9,19 +9,57 @@ open System
|
||||
type WebLogMiddleware (next : RequestDelegate) =
|
||||
|
||||
member this.InvokeAsync (ctx : HttpContext) = task {
|
||||
let host = ctx.Request.Host.ToUriComponent ()
|
||||
match WebLogCache.exists host with
|
||||
match WebLogCache.exists ctx with
|
||||
| true -> return! next.Invoke ctx
|
||||
| false ->
|
||||
let conn = ctx.RequestServices.GetRequiredService<IConnection> ()
|
||||
match! Data.WebLog.findByHost host conn with
|
||||
match! Data.WebLog.findByHost (Cache.makeKey ctx) conn with
|
||||
| Some webLog ->
|
||||
WebLogCache.set host webLog
|
||||
WebLogCache.set ctx webLog
|
||||
do! PageListCache.update ctx
|
||||
return! next.Invoke ctx
|
||||
| None -> ctx.Response.StatusCode <- 404
|
||||
}
|
||||
|
||||
|
||||
/// DotLiquid filters
|
||||
module DotLiquidBespoke =
|
||||
|
||||
open DotLiquid
|
||||
open System.IO
|
||||
|
||||
/// A filter to generate nav links, highlighting the active link (exact match)
|
||||
type NavLinkFilter () =
|
||||
static member NavLink (ctx : Context, url : string, text : string) =
|
||||
seq {
|
||||
"<li class=\"nav-item\"><a class=\"nav-link"
|
||||
if url = string ctx.Environments[0].["current_page"] then " active"
|
||||
"\" href=\"/"
|
||||
url
|
||||
"\">"
|
||||
text
|
||||
"</a></li>"
|
||||
}
|
||||
|> Seq.fold (+) ""
|
||||
|
||||
/// Create links for a user to log on or off, and a dashboard link if they are logged off
|
||||
type UserLinksTag () =
|
||||
inherit Tag ()
|
||||
|
||||
override this.Render (context : Context, result : TextWriter) =
|
||||
seq {
|
||||
"""<ul class="navbar-nav flex-grow-1 justify-content-end">"""
|
||||
match Convert.ToBoolean context.Environments[0].["logged_on"] with
|
||||
| true ->
|
||||
"""<li class="nav-item"><a class="nav-link" href="/admin">Dashboard</a></li>"""
|
||||
"""<li class="nav-item"><a class="nav-link" href="/user/log-off">Log Off</a></li>"""
|
||||
| false ->
|
||||
"""<li class="nav-item"><a class="nav-link" href="/user/log-on">Log On</a></li>"""
|
||||
"</ul>"
|
||||
}
|
||||
|> Seq.iter result.WriteLine
|
||||
|
||||
|
||||
/// Initialize a new database
|
||||
let initDbValidated (args : string[]) (sp : IServiceProvider) = task {
|
||||
|
||||
@@ -140,14 +178,20 @@ let main args =
|
||||
let _ = builder.Services.AddSingleton<IConnection> conn
|
||||
|
||||
// Set up DotLiquid
|
||||
Template.RegisterFilter typeof<DotLiquidBespoke.NavLinkFilter>
|
||||
Template.RegisterTag<DotLiquidBespoke.UserLinksTag> "user_links"
|
||||
|
||||
let all = [| "*" |]
|
||||
Template.RegisterSafeType (typeof<Page>, all)
|
||||
Template.RegisterSafeType (typeof<WebLog>, all)
|
||||
|
||||
Template.RegisterSafeType (typeof<DashboardModel>, all)
|
||||
Template.RegisterSafeType (typeof<DisplayPage>, all)
|
||||
Template.RegisterSafeType (typeof<SettingsModel>, all)
|
||||
Template.RegisterSafeType (typeof<EditPageModel>, all)
|
||||
|
||||
Template.RegisterSafeType (typeof<AntiforgeryTokenSet>, all)
|
||||
Template.RegisterSafeType (typeof<Option<_>>, all) // doesn't quite get the job done....
|
||||
Template.RegisterSafeType (typeof<string option>, all)
|
||||
Template.RegisterSafeType (typeof<KeyValuePair>, all)
|
||||
|
||||
let app = builder.Build ()
|
||||
@@ -160,7 +204,7 @@ let main args =
|
||||
let _ = app.UseAuthentication ()
|
||||
let _ = app.UseStaticFiles ()
|
||||
let _ = app.UseRouting ()
|
||||
let _ = app.UseEndpoints (fun endpoints -> endpoints.MapGiraffeEndpoints Handlers.endpoints)
|
||||
let _ = app.UseGiraffe Handlers.endpoints
|
||||
|
||||
app.Run()
|
||||
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
/// <summary>
|
||||
/// In-memory cache of web log details
|
||||
/// </summary>
|
||||
/// <remarks>This is filled by the middleware via the first request for each host, and can be updated via the web log
|
||||
/// settings update page</remarks>
|
||||
module MyWebLog.WebLogCache
|
||||
|
||||
open Microsoft.AspNetCore.Http
|
||||
open System.Collections.Concurrent
|
||||
|
||||
/// The cache of web log details
|
||||
let private _cache = ConcurrentDictionary<string, WebLog> ()
|
||||
|
||||
/// Does a host exist in the cache?
|
||||
let exists host = _cache.ContainsKey host
|
||||
|
||||
/// Get the details for a web log via its host
|
||||
let get host = _cache[host]
|
||||
|
||||
/// Get the details for a web log via its host
|
||||
let getByCtx (ctx : HttpContext) = _cache[ctx.Request.Host.ToUriComponent ()]
|
||||
|
||||
/// Set the details for a particular host
|
||||
let set host details = _cache[host] <- details
|
||||
@@ -1,4 +1,5 @@
|
||||
<article class="container pt-3">
|
||||
<h2 class="py-3">{{ web_log.name }} • Dashboard</h2>
|
||||
<article class="container">
|
||||
<div class="row">
|
||||
<section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
|
||||
<div class="card">
|
||||
@@ -21,7 +22,7 @@
|
||||
All <span class="badge rounded-pill bg-secondary">{{ model.pages }}</span>
|
||||
Shown in Page List <span class="badge rounded-pill bg-secondary">{{ model.listed_pages }}</span>
|
||||
</h6>
|
||||
<a href="/pages/list" class="btn btn-secondary me-2">View All</a>
|
||||
<a href="/pages" class="btn btn-secondary me-2">View All</a>
|
||||
<a href="/page/new/edit" class="btn btn-primary">Create a New Page</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -17,20 +17,26 @@
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarText">
|
||||
<span class="navbar-text">{{ page_title }}</span>
|
||||
{% if logged_on -%}
|
||||
<ul class="navbar-nav">
|
||||
{{ "admin" | nav_link: "Dashboard" }}
|
||||
{{ "pages" | nav_link: "Pages" }}
|
||||
{{ "posts" | nav_link: "Posts" }}
|
||||
{{ "categories" | nav_link: "Categories" }}
|
||||
</ul>
|
||||
{%- endif %}
|
||||
<ul class="navbar-nav flex-grow-1 justify-content-end">
|
||||
{% if logged_on -%}
|
||||
<li class="nav-item"><a class="nav-link" href="/admin/">Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/user/log-off">Log Off</a></li>
|
||||
{{ "user/log-off" | nav_link: "Log Off" }}
|
||||
{%- else -%}
|
||||
<li class="nav-item"><a class="nav-link" href="/user/log-on">Log On</a></li>
|
||||
{{ "user/log-on" | nav_link: "Log On" }}
|
||||
{%- endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<main class="mx-3">
|
||||
{{ content }}
|
||||
</main>
|
||||
<footer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h2 class="p-3 ">Log On to {{ web_log.name }}</h2>
|
||||
<h2 class="py-3">Log On to {{ web_log.name }}</h2>
|
||||
<article class="pb-3">
|
||||
<form action="/user/log-on" method="post">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
|
||||
55
src/MyWebLog/themes/admin/page-edit.liquid
Normal file
55
src/MyWebLog/themes/admin/page-edit.liquid
Normal file
@@ -0,0 +1,55 @@
|
||||
<h2 class="py-3">{{ page_title }}</h2>
|
||||
<article>
|
||||
<form action="/page/save" method="post">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<input type="hidden" name="pageId" value="{{ model.page_id }}">
|
||||
<div class="container">
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="title" id="title" class="form-control" autofocus required
|
||||
value="{{ model.title }}">
|
||||
<label for="title">Title</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col-9">
|
||||
<div class="form-floating">
|
||||
<input type="text" name="permalink" id="permalink" class="form-control" required
|
||||
value="{{ model.permalink }}">
|
||||
<label for="permalink">Permalink</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-3 align-self-center">
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" name="isShownInPageList" id="showList" class="form-check-input" value="true"
|
||||
{%- if model.is_shown_in_page_list %} checked="checked"{% endif %}>
|
||||
<label for="showList" class="form-check-label">Show in Page List</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="col">
|
||||
<label for="text">Text</label>
|
||||
<input type="radio" name="source" id="source_html" class="btn-check" value="HTML"
|
||||
{%- if model.source == "HTML" %} checked="checked"{% endif %}>
|
||||
<label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label>
|
||||
<input type="radio" name="source" id="source_md" class="btn-check" value="Markdown"
|
||||
{%- if model.source == "Markdown" %} checked="checked"{% endif %}>
|
||||
<label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<textarea name="Text" id="text" class="form-control" rows="10">{{ model.text }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</article>
|
||||
27
src/MyWebLog/themes/admin/page-list.liquid
Normal file
27
src/MyWebLog/themes/admin/page-list.liquid
Normal file
@@ -0,0 +1,27 @@
|
||||
<h2 class="py-3">{{ page_title }}</h2>
|
||||
<article class="container">
|
||||
<a href="/page/new/edit" class="btn btn-primary btn-sm my-3">Create a New Page</a>
|
||||
<table class="table table-sm table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Actions</th>
|
||||
<th scope="col">Title</th>
|
||||
<th scope="col">In List?</th>
|
||||
<th scope="col">Last Updated</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for pg in pages -%}
|
||||
<tr>
|
||||
<td><a href="/page/{{ pg.id }}/edit">Edit</a></td>
|
||||
<td>
|
||||
{{ pg.title }}
|
||||
{%- if pg.is_default %} <span class="badge bg-success">HOME PAGE</span>{% endif -%}
|
||||
</td>
|
||||
<td>{% if pg.show_in_page_list %} Yes {% else %} No {% endif %}</td>
|
||||
<td>{{ pg.updated_on | date: "MMMM d, yyyy" }}</td>
|
||||
</tr>
|
||||
{%- endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</article>
|
||||
@@ -1,4 +1,5 @@
|
||||
<article class="pt-3">
|
||||
<h2 class="py-3">{{ web_log.name }} Settings</h2>
|
||||
<article>
|
||||
<form action="/admin/settings" method="post">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<div class="container">
|
||||
|
||||
@@ -20,21 +20,21 @@
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarText">
|
||||
{% if web_log.subtitle -%}
|
||||
<span class="navbar-text">{{ web_log.subtitle | escape }}</span>
|
||||
<span class="navbar-text">{{ web_log.subtitle.value | escape }}</span>
|
||||
{%- endif %}
|
||||
<ul class="navbar-nav flex-grow-1 justify-content-end">
|
||||
{% if logged_on %}
|
||||
<li class="nav-item"><a class="nav-link" href="/admin/">Dashboard</a></li>
|
||||
<li class="nav-item"><a class="nav-link" href="/user/log-off">Log Off</a></li>
|
||||
{% else %}
|
||||
<li class="nav-item"><a class="nav-link" href="/user/log-on">Log On</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if page_list -%}
|
||||
<ul class="navbar-nav">
|
||||
{% for pg in page_list -%}
|
||||
{{ pg.permalink | nav_link: pg.title }}
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{%- endif %}
|
||||
{% user_links %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<main class="mx-3">
|
||||
{{ content }}
|
||||
</main>
|
||||
<footer>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h2>{{ page.title }}</h2>
|
||||
<h2 class="py-3">{{ page.title }}</h2>
|
||||
<article>
|
||||
{{ page.text }}
|
||||
</article>
|
||||
|
||||
Reference in New Issue
Block a user