Delete user / admin clean-up (#19)

- Add CLI help (#22)
- Add constants for common view items
- Construct hashes with piped functions
This commit is contained in:
Daniel J. Summers 2022-07-21 21:42:38 -04:00
parent 59f385122b
commit 99ccdebcc7
13 changed files with 499 additions and 302 deletions

View File

@ -255,6 +255,9 @@ type IWebLogUserData =
/// Add a web log user /// Add a web log user
abstract member Add : WebLogUser -> Task<unit> abstract member Add : WebLogUser -> Task<unit>
/// Delete a web log user
abstract member Delete : WebLogUserId -> WebLogId -> Task<Result<bool, string>>
/// Find a web log user by their e-mail address /// Find a web log user by their e-mail address
abstract member FindByEmail : email : string -> WebLogId -> Task<WebLogUser option> abstract member FindByEmail : email : string -> WebLogId -> Task<WebLogUser option>

View File

@ -955,6 +955,44 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
write; withRetryDefault; ignoreResult conn write; withRetryDefault; ignoreResult conn
} }
member _.FindById userId webLogId =
rethink<WebLogUser> {
withTable Table.WebLogUser
get userId
resultOption; withRetryOptionDefault
}
|> verifyWebLog webLogId (fun u -> u.WebLogId) <| conn
member this.Delete userId webLogId = backgroundTask {
match! this.FindById userId webLogId with
| Some _ ->
let! pageCount = rethink<int> {
withTable Table.Page
getAll [ webLogId ] (nameof Page.empty.WebLogId)
filter (nameof Page.empty.AuthorId) userId
count
result; withRetryDefault conn
}
let! postCount = rethink<int> {
withTable Table.Post
getAll [ webLogId ] (nameof Post.empty.WebLogId)
filter (nameof Post.empty.AuthorId) userId
count
result; withRetryDefault conn
}
if pageCount + postCount > 0 then
return Result.Error "User has pages or posts; cannot delete"
else
do! rethink {
withTable Table.WebLogUser
get userId
delete
write; withRetryDefault; ignoreResult conn
}
return Ok true
| None -> return Result.Error "User does not exist"
}
member _.FindByEmail email webLogId = member _.FindByEmail email webLogId =
rethink<WebLogUser list> { rethink<WebLogUser list> {
withTable Table.WebLogUser withTable Table.WebLogUser
@ -964,17 +1002,10 @@ type RethinkDbData (conn : Net.IConnection, config : DataConfig, log : ILogger<R
} }
|> tryFirst <| conn |> tryFirst <| conn
member _.FindById userId webLogId =
rethink<WebLogUser> {
withTable Table.WebLogUser
get userId
resultOption; withRetryOptionDefault
}
|> verifyWebLog webLogId (fun u -> u.WebLogId) <| conn
member _.FindByWebLog webLogId = rethink<WebLogUser list> { member _.FindByWebLog webLogId = rethink<WebLogUser list> {
withTable Table.WebLogUser withTable Table.WebLogUser
getAll [ webLogId ] (nameof WebLogUser.empty.WebLogId) getAll [ webLogId ] (nameof WebLogUser.empty.WebLogId)
orderByFunc (fun row -> row[nameof WebLogUser.empty.PreferredName].Downcase ())
result; withRetryDefault conn result; withRetryDefault conn
} }

View File

@ -43,6 +43,34 @@ type SQLiteWebLogUserData (conn : SqliteConnection) =
do! write cmd do! write cmd
} }
/// Find a user by their ID for the given web log
let findById userId webLogId = backgroundTask {
use cmd = conn.CreateCommand ()
cmd.CommandText <- "SELECT * FROM web_log_user WHERE id = @id"
cmd.Parameters.AddWithValue ("@id", WebLogUserId.toString userId) |> ignore
use! rdr = cmd.ExecuteReaderAsync ()
return Helpers.verifyWebLog<WebLogUser> webLogId (fun u -> u.WebLogId) Map.toWebLogUser rdr
}
/// Delete a user if they have no posts or pages
let delete userId webLogId = backgroundTask {
match! findById userId webLogId with
| Some _ ->
use cmd = conn.CreateCommand ()
cmd.CommandText <- "SELECT COUNT(id) FROM page WHERE author_id = @userId"
cmd.Parameters.AddWithValue ("@userId", WebLogUserId.toString userId) |> ignore
let! pageCount = count cmd
cmd.CommandText <- "SELECT COUNT(id) FROM post WHERE author_id = @userId"
let! postCount = count cmd
if pageCount + postCount > 0 then
return Error "User has pages or posts; cannot delete"
else
cmd.CommandText <- "DELETE FROM web_log_user WHERE id = @userId"
let! _ = cmd.ExecuteNonQueryAsync ()
return Ok true
| None -> return Error "User does not exist"
}
/// Find a user by their e-mail address for the given web log /// Find a user by their e-mail address for the given web log
let findByEmail (email : string) webLogId = backgroundTask { let findByEmail (email : string) webLogId = backgroundTask {
use cmd = conn.CreateCommand () use cmd = conn.CreateCommand ()
@ -53,19 +81,10 @@ type SQLiteWebLogUserData (conn : SqliteConnection) =
return if rdr.Read () then Some (Map.toWebLogUser rdr) else None return if rdr.Read () then Some (Map.toWebLogUser rdr) else None
} }
/// Find a user by their ID for the given web log
let findById userId webLogId = backgroundTask {
use cmd = conn.CreateCommand ()
cmd.CommandText <- "SELECT * FROM web_log_user WHERE id = @id"
cmd.Parameters.AddWithValue ("@id", WebLogUserId.toString userId) |> ignore
use! rdr = cmd.ExecuteReaderAsync ()
return Helpers.verifyWebLog<WebLogUser> webLogId (fun u -> u.WebLogId) Map.toWebLogUser rdr
}
/// Get all users for the given web log /// Get all users for the given web log
let findByWebLog webLogId = backgroundTask { let findByWebLog webLogId = backgroundTask {
use cmd = conn.CreateCommand () use cmd = conn.CreateCommand ()
cmd.CommandText <- "SELECT * FROM web_log_user WHERE web_log_id = @webLogId" cmd.CommandText <- "SELECT * FROM web_log_user WHERE web_log_id = @webLogId ORDER BY LOWER(preferred_name)"
addWebLogId cmd webLogId addWebLogId cmd webLogId
use! rdr = cmd.ExecuteReaderAsync () use! rdr = cmd.ExecuteReaderAsync ()
return toList Map.toWebLogUser rdr return toList Map.toWebLogUser rdr
@ -133,6 +152,7 @@ type SQLiteWebLogUserData (conn : SqliteConnection) =
interface IWebLogUserData with interface IWebLogUserData with
member _.Add user = add user member _.Add user = add user
member _.Delete userId webLogId = delete userId webLogId
member _.FindByEmail email webLogId = findByEmail email webLogId member _.FindByEmail email webLogId = findByEmail email webLogId
member _.FindById userId webLogId = findById userId webLogId member _.FindById userId webLogId = findById userId webLogId
member _.FindByWebLog webLogId = findByWebLog webLogId member _.FindByWebLog webLogId = findByWebLog webLogId

View File

@ -18,17 +18,16 @@ let dashboard : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let topCats = getCount data.Category.CountTopLevel let topCats = getCount data.Category.CountTopLevel
let! _ = Task.WhenAll (posts, drafts, pages, listed, cats, topCats) let! _ = Task.WhenAll (posts, drafts, pages, listed, cats, topCats)
return! return!
{| page_title = "Dashboard" hashForPage "Dashboard"
model = |> addToHash ViewContext.Model {
{ Posts = posts.Result Posts = posts.Result
Drafts = drafts.Result Drafts = drafts.Result
Pages = pages.Result Pages = pages.Result
ListedPages = listed.Result ListedPages = listed.Result
Categories = cats.Result Categories = cats.Result
TopLevelCategories = topCats.Result TopLevelCategories = topCats.Result
} }
|} |> adminView "dashboard" next ctx
|> makeHash |> adminView "dashboard" next ctx
} }
// -- CATEGORIES -- // -- CATEGORIES --
@ -36,12 +35,10 @@ let dashboard : HttpHandler = requireAccess Author >=> fun next ctx -> task {
// GET /admin/categories // GET /admin/categories
let listCategories : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let listCategories : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! catListTemplate = TemplateCache.get "admin" "category-list-body" ctx.Data let! catListTemplate = TemplateCache.get "admin" "category-list-body" ctx.Data
let hash = makeHash {| let! hash =
page_title = "Categories" hashForPage "Categories"
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
web_log = ctx.WebLog |> addViewContext ctx
categories = CategoryCache.get ctx
|}
return! return!
addToHash "category_list" (catListTemplate.Render hash) hash addToHash "category_list" (catListTemplate.Render hash) hash
|> adminView "category-list" next ctx |> adminView "category-list" next ctx
@ -49,10 +46,9 @@ let listCategories : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
// GET /admin/categories/bare // GET /admin/categories/bare
let listCategoriesBare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> let listCategoriesBare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
{| categories = CategoryCache.get ctx hashForPage "Categories"
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
|} |> adminBareView "category-list-body" next ctx
|> makeHash |> adminBareView "category-list-body" next ctx
// GET /admin/category/{id}/edit // GET /admin/category/{id}/edit
@ -67,13 +63,11 @@ let editCategory catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ct
} }
match result with match result with
| Some (title, cat) -> | Some (title, cat) ->
return! {| return!
page_title = title hashForPage title
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
model = EditCategoryModel.fromCategory cat |> addToHash ViewContext.Model (EditCategoryModel.fromCategory cat)
categories = CategoryCache.get ctx |> adminBareView "category-edit" next ctx
|}
|> makeHash |> adminBareView "category-edit" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -117,12 +111,12 @@ open Microsoft.AspNetCore.Http
/// Get the hash necessary to render the tag mapping list /// Get the hash necessary to render the tag mapping list
let private tagMappingHash (ctx : HttpContext) = task { let private tagMappingHash (ctx : HttpContext) = task {
let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id
return makeHash {| return!
csrf = ctx.CsrfTokenSet hashForPage "Tag Mappings"
web_log = ctx.WebLog |> withAntiCsrf ctx
mappings = mappings |> addToHash "mappings" mappings
mapping_ids = mappings |> List.map (fun it -> { Name = it.Tag; Value = TagMapId.toString it.Id }) |> addToHash "mapping_ids" (mappings |> List.map (fun it -> { Name = it.Tag; Value = TagMapId.toString it.Id }))
|} |> addViewContext ctx
} }
// GET /admin/settings/tag-mappings // GET /admin/settings/tag-mappings
@ -131,7 +125,6 @@ let tagMappings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> ta
let! listTemplate = TemplateCache.get "admin" "tag-mapping-list-body" ctx.Data let! listTemplate = TemplateCache.get "admin" "tag-mapping-list-body" ctx.Data
return! return!
addToHash "tag_mapping_list" (listTemplate.Render hash) hash addToHash "tag_mapping_list" (listTemplate.Render hash) hash
|> addToHash "page_title" "Tag Mappings"
|> adminView "tag-mapping-list" next ctx |> adminView "tag-mapping-list" next ctx
} }
@ -149,12 +142,11 @@ let editMapping tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next
else ctx.Data.TagMap.FindById (TagMapId tagMapId) ctx.WebLog.Id else ctx.Data.TagMap.FindById (TagMapId tagMapId) ctx.WebLog.Id
match! tagMap with match! tagMap with
| Some tm -> | Some tm ->
return! {| return!
page_title = if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag" hashForPage (if isNew then "Add Tag Mapping" else $"Mapping for {tm.Tag} Tag")
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
model = EditTagMapModel.fromMapping tm |> addToHash ViewContext.Model (EditTagMapModel.fromMapping tm)
|} |> adminBareView "tag-mapping-edit" next ctx
|> makeHash |> adminBareView "tag-mapping-edit" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -191,10 +183,9 @@ open MyWebLog.Data
// GET /admin/theme/update // GET /admin/theme/update
let themeUpdatePage : HttpHandler = requireAccess Administrator >=> fun next ctx -> let themeUpdatePage : HttpHandler = requireAccess Administrator >=> fun next ctx ->
{| page_title = "Upload Theme" hashForPage "Upload Theme"
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
|} |> adminView "upload-theme" next ctx
|> makeHash |> adminView "upload-theme" next ctx
/// Update the name and version for a theme based on the version.txt file, if present /// Update the name and version for a theme based on the version.txt file, if present
let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = backgroundTask { let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = backgroundTask {
@ -244,9 +235,9 @@ let private updateAssets themeId (zip : ZipArchive) (data : IData) = backgroundT
use stream = new MemoryStream () use stream = new MemoryStream ()
do! asset.Open().CopyToAsync stream do! asset.Open().CopyToAsync stream
do! data.ThemeAsset.Save do! data.ThemeAsset.Save
{ Id = ThemeAssetId (themeId, assetName) { Id = ThemeAssetId (themeId, assetName)
UpdatedOn = asset.LastWriteTime.DateTime UpdatedOn = asset.LastWriteTime.DateTime
Data = stream.ToArray () Data = stream.ToArray ()
} }
} }
@ -303,28 +294,28 @@ let settings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task
let data = ctx.Data let data = ctx.Data
let! allPages = data.Page.All ctx.WebLog.Id let! allPages = data.Page.All ctx.WebLog.Id
let! themes = data.Theme.All () let! themes = data.Theme.All ()
return! {| return!
page_title = "Web Log Settings" hashForPage "Web Log Settings"
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
model = SettingsModel.fromWebLog ctx.WebLog |> addToHash ViewContext.Model (SettingsModel.fromWebLog ctx.WebLog)
pages = seq |> addToHash "pages" (
{ KeyValuePair.Create ("posts", "- First Page of Posts -") seq {
KeyValuePair.Create ("posts", "- First Page of Posts -")
yield! allPages yield! allPages
|> List.sortBy (fun p -> p.Title.ToLower ()) |> List.sortBy (fun p -> p.Title.ToLower ())
|> List.map (fun p -> KeyValuePair.Create (PageId.toString p.Id, p.Title)) |> List.map (fun p -> KeyValuePair.Create (PageId.toString p.Id, p.Title))
} }
|> Array.ofSeq |> Array.ofSeq)
themes = |> addToHash "themes" (
themes themes
|> Seq.ofList |> Seq.ofList
|> Seq.map (fun it -> KeyValuePair.Create (ThemeId.toString it.Id, $"{it.Name} (v{it.Version})")) |> Seq.map (fun it -> KeyValuePair.Create (ThemeId.toString it.Id, $"{it.Name} (v{it.Version})"))
|> Array.ofSeq |> Array.ofSeq)
upload_values = [| |> addToHash "upload_values" [|
KeyValuePair.Create (UploadDestination.toString Database, "Database") KeyValuePair.Create (UploadDestination.toString Database, "Database")
KeyValuePair.Create (UploadDestination.toString Disk, "Disk") KeyValuePair.Create (UploadDestination.toString Disk, "Disk")
|] |]
|} |> adminView "settings" next ctx
|> makeHash |> adminView "settings" next ctx
} }
// POST /admin/settings // POST /admin/settings

View File

@ -416,16 +416,14 @@ let generate (feedType : FeedType) postCount : HttpHandler = fun next ctx -> bac
// GET /admin/settings/rss // GET /admin/settings/rss
let editSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> let editSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
let feeds = hashForPage "RSS Settings"
|> withAntiCsrf ctx
|> addToHash ViewContext.Model (EditRssModel.fromRssOptions ctx.WebLog.Rss)
|> addToHash "custom_feeds" (
ctx.WebLog.Rss.CustomFeeds ctx.WebLog.Rss.CustomFeeds
|> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx)) |> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx))
|> Array.ofList |> Array.ofList)
{| page_title = "RSS Settings" |> adminView "rss-settings" next ctx
csrf = ctx.CsrfTokenSet
model = EditRssModel.fromRssOptions ctx.WebLog.Rss
custom_feeds = feeds
|}
|> makeHash |> adminView "rss-settings" next ctx
// POST /admin/settings/rss // POST /admin/settings/rss
let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
@ -449,22 +447,20 @@ let editCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun next
| _ -> ctx.WebLog.Rss.CustomFeeds |> List.tryFind (fun f -> f.Id = CustomFeedId feedId) | _ -> ctx.WebLog.Rss.CustomFeeds |> List.tryFind (fun f -> f.Id = CustomFeedId feedId)
match customFeed with match customFeed with
| Some f -> | Some f ->
{| page_title = $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed""" hashForPage $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed"""
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
model = EditCustomFeedModel.fromFeed f |> addToHash ViewContext.Model (EditCustomFeedModel.fromFeed f)
categories = CategoryCache.get ctx |> addToHash "medium_values" [|
medium_values = [| KeyValuePair.Create ("", "&ndash; Unspecified &ndash;")
KeyValuePair.Create ("", "&ndash; Unspecified &ndash;") KeyValuePair.Create (PodcastMedium.toString Podcast, "Podcast")
KeyValuePair.Create (PodcastMedium.toString Podcast, "Podcast") KeyValuePair.Create (PodcastMedium.toString Music, "Music")
KeyValuePair.Create (PodcastMedium.toString Music, "Music") KeyValuePair.Create (PodcastMedium.toString Video, "Video")
KeyValuePair.Create (PodcastMedium.toString Video, "Video") KeyValuePair.Create (PodcastMedium.toString Film, "Film")
KeyValuePair.Create (PodcastMedium.toString Film, "Film") KeyValuePair.Create (PodcastMedium.toString Audiobook, "Audiobook")
KeyValuePair.Create (PodcastMedium.toString Audiobook, "Audiobook") KeyValuePair.Create (PodcastMedium.toString Newsletter, "Newsletter")
KeyValuePair.Create (PodcastMedium.toString Newsletter, "Newsletter") KeyValuePair.Create (PodcastMedium.toString Blog, "Blog")
KeyValuePair.Create (PodcastMedium.toString Blog, "Blog") |]
|] |> adminView "custom-feed-edit" next ctx
|}
|> makeHash |> adminView "custom-feed-edit" next ctx
| None -> Error.notFound next ctx | None -> Error.notFound next ctx
// POST /admin/settings/rss/save // POST /admin/settings/rss/save

View File

@ -12,12 +12,117 @@ type ISession with
this.SetString (key, JsonSerializer.Serialize item) this.SetString (key, JsonSerializer.Serialize item)
/// Get an item from the session /// Get an item from the session
member this.Get<'T> key = member this.TryGet<'T> key =
match this.GetString key with match this.GetString key with
| null -> None | null -> None
| item -> Some (JsonSerializer.Deserialize<'T> item) | item -> Some (JsonSerializer.Deserialize<'T> item)
/// Keys used in the myWebLog-standard DotLiquid hash
module ViewContext =
/// The anti cross-site request forgery (CSRF) token set to use for form submissions
[<Literal>]
let AntiCsrfTokens = "csrf"
/// The categories for this web log
[<Literal>]
let Categories = "categories"
/// The main content of the view
[<Literal>]
let Content = "content"
/// The current page URL
[<Literal>]
let CurrentPage = "current_page"
/// The generator string for the current version of myWebLog
[<Literal>]
let Generator = "generator"
/// The HTML to load htmx from the unpkg CDN
[<Literal>]
let HtmxScript = "htmx_script"
/// Whether the current user has Administrator privileges
[<Literal>]
let IsAdministrator = "is_administrator"
/// Whether the current user has Author (or above) privileges
[<Literal>]
let IsAuthor = "is_author"
/// Whether the current view is displaying a category archive page
[<Literal>]
let IsCategory = "is_category"
/// Whether the current view is displaying the first page of a category archive
[<Literal>]
let IsCategoryHome = "is_category_home"
/// Whether the current user has Editor (or above) privileges
[<Literal>]
let IsEditor = "is_editor"
/// Whether the current view is the home page for the web log
[<Literal>]
let IsHome = "is_home"
/// Whether there is a user logged on
[<Literal>]
let IsLoggedOn = "is_logged_on"
/// Whether the current view is displaying a page
[<Literal>]
let IsPage = "is_page"
/// Whether the current view is displaying a post
[<Literal>]
let IsPost = "is_post"
/// Whether the current view is a tag archive page
[<Literal>]
let IsTag = "is_tag"
/// Whether the current view is the first page of a tag archive
[<Literal>]
let IsTagHome = "is_tag_home"
/// Whether the current user has Web Log Admin (or above) privileges
[<Literal>]
let IsWebLogAdmin = "is_web_log_admin"
/// Messages to be displayed to the user
[<Literal>]
let Messages = "messages"
/// The view model / form for the page
[<Literal>]
let Model = "model"
/// The listed pages for the web log
[<Literal>]
let PageList = "page_list"
/// The title of the page being displayed
[<Literal>]
let PageTitle = "page_title"
/// The slug for category or tag archive pages
[<Literal>]
let Slug = "slug"
/// The ID of the current user
[<Literal>]
let UserId = "user_id"
/// The current web log
[<Literal>]
let WebLog = "web_log"
/// The HTTP item key for loading the session /// The HTTP item key for loading the session
let private sessionLoadedKey = "session-loaded" let private sessionLoadedKey = "session-loaded"
@ -38,34 +143,41 @@ open MyWebLog.ViewModels
/// Add a message to the user's session /// Add a message to the user's session
let addMessage (ctx : HttpContext) message = task { let addMessage (ctx : HttpContext) message = task {
do! loadSession ctx do! loadSession ctx
let msg = match ctx.Session.Get<UserMessage list> "messages" with Some it -> it | None -> [] let msg = match ctx.Session.TryGet<UserMessage list> ViewContext.Messages with Some it -> it | None -> []
ctx.Session.Set ("messages", message :: msg) ctx.Session.Set (ViewContext.Messages, message :: msg)
} }
/// Get any messages from the user's session, removing them in the process /// Get any messages from the user's session, removing them in the process
let messages (ctx : HttpContext) = task { let messages (ctx : HttpContext) = task {
do! loadSession ctx do! loadSession ctx
match ctx.Session.Get<UserMessage list> "messages" with match ctx.Session.TryGet<UserMessage list> ViewContext.Messages with
| Some msg -> | Some msg ->
ctx.Session.Remove "messages" ctx.Session.Remove ViewContext.Messages
return msg |> (List.rev >> Array.ofList) return msg |> (List.rev >> Array.ofList)
| None -> return [||] | None -> return [||]
} }
open System.Collections.Generic
open MyWebLog open MyWebLog
open DotLiquid open DotLiquid
/// Shorthand for creating a DotLiquid hash from an anonymous object
let makeHash (values : obj) = let makeHash (values : obj) =
Hash.FromAnonymousObject values Hash.FromAnonymousObject values
/// Create a hash with the page title filled
let hashForPage (title : string) =
makeHash {| page_title = title |}
/// Add a key to the hash, returning the modified hash /// Add a key to the hash, returning the modified hash
// (note that the hash itself is mutated; this is only used to make it pipeable) // (note that the hash itself is mutated; this is only used to make it pipeable)
let addToHash key (value : obj) (hash : Hash) = let addToHash key (value : obj) (hash : Hash) =
if hash.ContainsKey key then hash[key] <- value else hash.Add (key, value) if hash.ContainsKey key then hash[key] <- value else hash.Add (key, value)
hash hash
/// Add anti-CSRF tokens to the given hash
let withAntiCsrf (ctx : HttpContext) =
addToHash ViewContext.AntiCsrfTokens ctx.CsrfTokenSet
open System.Security.Claims open System.Security.Claims
open Giraffe open Giraffe
open Giraffe.Htmx open Giraffe.Htmx
@ -75,27 +187,31 @@ open Giraffe.ViewEngine
let private htmxScript = RenderView.AsString.htmlNode Htmx.Script.minified let private htmxScript = RenderView.AsString.htmlNode Htmx.Script.minified
/// Populate the DotLiquid hash with standard information /// Populate the DotLiquid hash with standard information
let private populateHash hash ctx = task { let addViewContext ctx (hash : Hash) = task {
let! messages = messages ctx let! messages = messages ctx
do! commitSession ctx do! commitSession ctx
ctx.User.Claims
|> Seq.tryFind (fun claim -> claim.Type = ClaimTypes.NameIdentifier)
|> Option.map (fun claim -> claim.Value)
|> Option.iter (fun userId -> addToHash "user_id" userId hash |> ignore)
return return
addToHash "web_log" ctx.WebLog hash if hash.ContainsKey ViewContext.HtmxScript && hash.ContainsKey ViewContext.Messages then
|> addToHash "page_list" (PageListCache.get ctx) // We have already populated everything; just update messages
|> addToHash "current_page" ctx.Request.Path.Value[1..] hash[ViewContext.Messages] <- Array.concat [ hash[ViewContext.Messages] :?> UserMessage[]; messages ]
|> addToHash "messages" messages hash
|> addToHash "generator" ctx.Generator else
|> addToHash "htmx_script" htmxScript ctx.User.Claims
|> addToHash "is_logged_on" ctx.User.Identity.IsAuthenticated |> Seq.tryFind (fun claim -> claim.Type = ClaimTypes.NameIdentifier)
|> addToHash "is_author" (ctx.HasAccessLevel Author) |> Option.map (fun claim -> addToHash ViewContext.UserId claim.Value hash)
|> addToHash "is_editor" (ctx.HasAccessLevel Editor) |> Option.defaultValue hash
|> addToHash "is_web_log_admin" (ctx.HasAccessLevel WebLogAdmin) |> addToHash ViewContext.WebLog ctx.WebLog
|> addToHash "is_administrator" (ctx.HasAccessLevel Administrator) |> addToHash ViewContext.PageList (PageListCache.get ctx)
|> addToHash ViewContext.Categories (CategoryCache.get ctx)
|> addToHash ViewContext.CurrentPage ctx.Request.Path.Value[1..]
|> addToHash ViewContext.Messages messages
|> addToHash ViewContext.Generator ctx.Generator
|> addToHash ViewContext.HtmxScript htmxScript
|> addToHash ViewContext.IsLoggedOn ctx.User.Identity.IsAuthenticated
|> addToHash ViewContext.IsAuthor (ctx.HasAccessLevel Author)
|> addToHash ViewContext.IsEditor (ctx.HasAccessLevel Editor)
|> addToHash ViewContext.IsWebLogAdmin (ctx.HasAccessLevel WebLogAdmin)
|> addToHash ViewContext.IsAdministrator (ctx.HasAccessLevel Administrator)
} }
/// Is the request from htmx? /// Is the request from htmx?
@ -104,16 +220,14 @@ let isHtmx (ctx : HttpContext) =
/// Render a view for the specified theme, using the specified template, layout, and hash /// Render a view for the specified theme, using the specified template, layout, and hash
let viewForTheme themeId template next ctx (hash : Hash) = task { let viewForTheme themeId template next ctx (hash : Hash) = task {
if not (hash.ContainsKey "htmx_script") then let! hash = addViewContext ctx hash
let! _ = populateHash hash ctx
()
let (ThemeId theme) = themeId let (ThemeId theme) = themeId
// NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a 2-pass render; // NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a 2-pass render;
// the net effect is a "layout" capability similar to Razor or Pug // the net effect is a "layout" capability similar to Razor or Pug
// Render view content... // Render view content...
let! contentTemplate = TemplateCache.get theme template ctx.Data let! contentTemplate = TemplateCache.get theme template ctx.Data
let _ = addToHash "content" (contentTemplate.Render hash) hash let _ = addToHash ViewContext.Content (contentTemplate.Render hash) hash
// ...then render that content with its layout // ...then render that content with its layout
let! layoutTemplate = TemplateCache.get theme (if isHtmx ctx then "layout-partial" else "layout") ctx.Data let! layoutTemplate = TemplateCache.get theme (if isHtmx ctx then "layout-partial" else "layout") ctx.Data
@ -137,24 +251,25 @@ let messagesToHeaders (messages : UserMessage array) : HttpHandler =
/// Render a bare view for the specified theme, using the specified template and hash /// Render a bare view for the specified theme, using the specified template and hash
let bareForTheme themeId template next ctx (hash : Hash) = task { let bareForTheme themeId template next ctx (hash : Hash) = task {
let! hash = populateHash hash ctx let! hash = addViewContext ctx hash
let (ThemeId theme) = themeId let (ThemeId theme) = themeId
if not (hash.ContainsKey "content") then if not (hash.ContainsKey ViewContext.Content) then
let! contentTemplate = TemplateCache.get theme template ctx.Data let! contentTemplate = TemplateCache.get theme template ctx.Data
addToHash "content" (contentTemplate.Render hash) hash |> ignore addToHash ViewContext.Content (contentTemplate.Render hash) hash |> ignore
// Bare templates are rendered with layout-bare // Bare templates are rendered with layout-bare
let! layoutTemplate = TemplateCache.get theme "layout-bare" ctx.Data let! layoutTemplate = TemplateCache.get theme "layout-bare" ctx.Data
return! return!
(messagesToHeaders (hash["messages"] :?> UserMessage[]) >=> htmlString (layoutTemplate.Render hash)) next ctx (messagesToHeaders (hash[ViewContext.Messages] :?> UserMessage[])
>=> htmlString (layoutTemplate.Render hash))
next ctx
} }
/// Return a view for the web log's default theme /// Return a view for the web log's default theme
let themedView template next ctx hash = task { let themedView template next ctx hash = task {
let! hash = populateHash hash ctx let! hash = addViewContext ctx hash
return! viewForTheme (hash["web_log"] :?> WebLog).ThemeId template next ctx hash return! viewForTheme (hash[ViewContext.WebLog] :?> WebLog).ThemeId template next ctx hash
} }
/// Display a view for the admin theme /// Display a view for the admin theme
@ -171,7 +286,7 @@ let redirectToGet url : HttpHandler = fun _ ctx -> task {
return! redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink url)) earlyReturn ctx return! redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink url)) earlyReturn ctx
} }
/// Validate the cross-site request forgery token in the current request /// Validate the anti cross-site request forgery token in the current request
let validateCsrf : HttpHandler = fun next ctx -> task { let validateCsrf : HttpHandler = fun next ctx -> task {
match! ctx.AntiForgery.IsRequestValidAsync ctx with match! ctx.AntiForgery.IsRequestValidAsync ctx with
| true -> return! next ctx | true -> return! next ctx

View File

@ -9,15 +9,14 @@ open MyWebLog.ViewModels
// GET /admin/pages/page/{pageNbr} // GET /admin/pages/page/{pageNbr}
let all pageNbr : HttpHandler = requireAccess Author >=> fun next ctx -> task { let all pageNbr : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let! pages = ctx.Data.Page.FindPageOfPages ctx.WebLog.Id pageNbr let! pages = ctx.Data.Page.FindPageOfPages ctx.WebLog.Id pageNbr
return! {| return!
page_title = "Pages" hashForPage "Pages"
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
pages = pages |> List.map (DisplayPage.fromPageMinimal ctx.WebLog) |> addToHash "pages" (pages |> List.map (DisplayPage.fromPageMinimal ctx.WebLog))
page_nbr = pageNbr |> addToHash "page_nbr" pageNbr
prev_page = if pageNbr = 2 then "" else $"/page/{pageNbr - 1}" |> addToHash "prev_page" (if pageNbr = 2 then "" else $"/page/{pageNbr - 1}")
next_page = $"/page/{pageNbr + 1}" |> addToHash "next_page" $"/page/{pageNbr + 1}"
|} |> adminView "page-list" next ctx
|> makeHash |> adminView "page-list" next ctx
} }
// GET /admin/page/{id}/edit // GET /admin/page/{id}/edit
@ -34,15 +33,15 @@ let edit pgId : HttpHandler = requireAccess Author >=> fun next ctx -> task {
| Some (title, page) when canEdit page.AuthorId ctx -> | Some (title, page) when canEdit page.AuthorId ctx ->
let model = EditPageModel.fromPage page let model = EditPageModel.fromPage page
let! templates = templatesForTheme ctx "page" let! templates = templatesForTheme ctx "page"
return! {| return!
page_title = title hashForPage title
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
model = model |> addToHash ViewContext.Model model
metadata = Array.zip model.MetaNames model.MetaValues |> addToHash "metadata" (
|> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]) Array.zip model.MetaNames model.MetaValues
templates = templates |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]))
|} |> addToHash "templates" templates
|> makeHash |> adminView "page-edit" next ctx |> adminView "page-edit" next ctx
| Some _ -> return! Error.notAuthorized next ctx | Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -61,12 +60,11 @@ let delete pgId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> ta
let editPermalinks pgId : HttpHandler = requireAccess Author >=> fun next ctx -> task { let editPermalinks pgId : HttpHandler = requireAccess Author >=> fun next ctx -> task {
match! ctx.Data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with match! ctx.Data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with
| Some pg when canEdit pg.AuthorId ctx -> | Some pg when canEdit pg.AuthorId ctx ->
return! {| return!
page_title = "Manage Prior Permalinks" hashForPage "Manage Prior Permalinks"
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
model = ManagePermalinksModel.fromPage pg |> addToHash ViewContext.Model (ManagePermalinksModel.fromPage pg)
|} |> adminView "permalinks" next ctx
|> makeHash |> adminView "permalinks" next ctx
| Some _ -> return! Error.notAuthorized next ctx | Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -91,12 +89,11 @@ let savePermalinks : HttpHandler = requireAccess Author >=> fun next ctx -> task
let editRevisions pgId : HttpHandler = requireAccess Author >=> fun next ctx -> task { let editRevisions pgId : HttpHandler = requireAccess Author >=> fun next ctx -> task {
match! ctx.Data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with match! ctx.Data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with
| Some pg when canEdit pg.AuthorId ctx -> | Some pg when canEdit pg.AuthorId ctx ->
return! {| return!
page_title = "Manage Page Revisions" hashForPage "Manage Page Revisions"
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
model = ManageRevisionsModel.fromPage ctx.WebLog pg |> addToHash ViewContext.Model (ManageRevisionsModel.fromPage ctx.WebLog pg)
|} |> adminView "revisions" next ctx
|> makeHash |> adminView "revisions" next ctx
| Some _ -> return! Error.notAuthorized next ctx | Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }

View File

@ -39,7 +39,7 @@ open MyWebLog.Data
open MyWebLog.ViewModels open MyWebLog.ViewModels
/// Convert a list of posts into items ready to be displayed /// Convert a list of posts into items ready to be displayed
let preparePostList webLog posts listType (url : string) pageNbr perPage ctx (data : IData) = task { let preparePostList webLog posts listType (url : string) pageNbr perPage (data : IData) = task {
let! authors = getAuthors webLog posts data let! authors = getAuthors webLog posts data
let! tagMappings = getTagMappings webLog posts data let! tagMappings = getTagMappings webLog posts data
let relUrl it = Some <| WebLog.relativeUrl webLog (Permalink it) let relUrl it = Some <| WebLog.relativeUrl webLog (Permalink it)
@ -85,12 +85,11 @@ let preparePostList webLog posts listType (url : string) pageNbr perPage ctx (da
OlderLink = olderLink OlderLink = olderLink
OlderName = olderPost |> Option.map (fun p -> p.Title) OlderName = olderPost |> Option.map (fun p -> p.Title)
} }
return makeHash {| return
model = model makeHash {||}
categories = CategoryCache.get ctx |> addToHash ViewContext.Model model
tag_mappings = tagMappings |> addToHash "tag_mappings" tagMappings
is_post = match listType with SinglePost -> true | _ -> false |> addToHash ViewContext.IsPost (match listType with SinglePost -> true | _ -> false)
|}
} }
open Giraffe open Giraffe
@ -100,15 +99,18 @@ let pageOfPosts pageNbr : HttpHandler = fun next ctx -> task {
let count = ctx.WebLog.PostsPerPage let count = ctx.WebLog.PostsPerPage
let data = ctx.Data let data = ctx.Data
let! posts = data.Post.FindPageOfPublishedPosts ctx.WebLog.Id pageNbr count let! posts = data.Post.FindPageOfPublishedPosts ctx.WebLog.Id pageNbr count
let! hash = preparePostList ctx.WebLog posts PostList "" pageNbr count ctx data let! hash = preparePostList ctx.WebLog posts PostList "" pageNbr count data
let title = let title =
match pageNbr, ctx.WebLog.DefaultPage with match pageNbr, ctx.WebLog.DefaultPage with
| 1, "posts" -> None | 1, "posts" -> None
| _, "posts" -> Some $"Page {pageNbr}" | _, "posts" -> Some $"Page {pageNbr}"
| _, _ -> Some $"Page {pageNbr} &laquo; Posts" | _, _ -> Some $"Page {pageNbr} &laquo; Posts"
match title with Some ttl -> hash.Add ("page_title", ttl) | None -> () return!
if pageNbr = 1 && ctx.WebLog.DefaultPage = "posts" then hash.Add ("is_home", true) match title with Some ttl -> addToHash ViewContext.PageTitle ttl hash | None -> hash
return! themedView "index" next ctx hash |> function
| hash ->
if pageNbr = 1 && ctx.WebLog.DefaultPage = "posts" then addToHash ViewContext.IsHome true hash else hash
|> themedView "index" next ctx
} }
// GET /page/{pageNbr}/ // GET /page/{pageNbr}/
@ -131,14 +133,14 @@ let pageOfCategorizedPosts slugAndPage : HttpHandler = fun next ctx -> task {
match! data.Post.FindPageOfCategorizedPosts webLog.Id (getCategoryIds slug ctx) pageNbr webLog.PostsPerPage match! data.Post.FindPageOfCategorizedPosts webLog.Id (getCategoryIds slug ctx) pageNbr webLog.PostsPerPage
with with
| posts when List.length posts > 0 -> | posts when List.length posts > 0 ->
let! hash = preparePostList webLog posts CategoryList cat.Slug pageNbr webLog.PostsPerPage ctx data let! hash = preparePostList webLog posts CategoryList cat.Slug pageNbr webLog.PostsPerPage data
let pgTitle = if pageNbr = 1 then "" else $""" <small class="archive-pg-nbr">(Page {pageNbr})</small>""" let pgTitle = if pageNbr = 1 then "" else $""" <small class="archive-pg-nbr">(Page {pageNbr})</small>"""
return! return!
addToHash "page_title" $"{cat.Name}: Category Archive{pgTitle}" hash addToHash ViewContext.PageTitle $"{cat.Name}: Category Archive{pgTitle}" hash
|> addToHash "subtitle" (defaultArg cat.Description "") |> addToHash "subtitle" (defaultArg cat.Description "")
|> addToHash "is_category" true |> addToHash ViewContext.IsCategory true
|> addToHash "is_category_home" (pageNbr = 1) |> addToHash ViewContext.IsCategoryHome (pageNbr = 1)
|> addToHash "slug" slug |> addToHash ViewContext.Slug slug
|> themedView "index" next ctx |> themedView "index" next ctx
| _ -> return! Error.notFound next ctx | _ -> return! Error.notFound next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
@ -166,13 +168,13 @@ let pageOfTaggedPosts slugAndPage : HttpHandler = fun next ctx -> task {
else else
match! data.Post.FindPageOfTaggedPosts webLog.Id tag pageNbr webLog.PostsPerPage with match! data.Post.FindPageOfTaggedPosts webLog.Id tag pageNbr webLog.PostsPerPage with
| posts when List.length posts > 0 -> | posts when List.length posts > 0 ->
let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.PostsPerPage ctx data let! hash = preparePostList webLog posts TagList rawTag pageNbr webLog.PostsPerPage data
let pgTitle = if pageNbr = 1 then "" else $""" <small class="archive-pg-nbr">(Page {pageNbr})</small>""" let pgTitle = if pageNbr = 1 then "" else $""" <small class="archive-pg-nbr">(Page {pageNbr})</small>"""
return! return!
addToHash "page_title" $"Posts Tagged &ldquo;{tag}&rdquo;{pgTitle}" hash addToHash ViewContext.PageTitle $"Posts Tagged &ldquo;{tag}&rdquo;{pgTitle}" hash
|> addToHash "is_tag" true |> addToHash ViewContext.IsTag true
|> addToHash "is_tag_home" (pageNbr = 1) |> addToHash ViewContext.IsTagHome (pageNbr = 1)
|> addToHash "slug" rawTag |> addToHash ViewContext.Slug rawTag
|> themedView "index" next ctx |> themedView "index" next ctx
// Other systems use hyphens for spaces; redirect if this is an old tag link // Other systems use hyphens for spaces; redirect if this is an old tag link
| _ -> | _ ->
@ -196,13 +198,11 @@ let home : HttpHandler = fun next ctx -> task {
| pageId -> | pageId ->
match! ctx.Data.Page.FindById (PageId pageId) webLog.Id with match! ctx.Data.Page.FindById (PageId pageId) webLog.Id with
| Some page -> | Some page ->
return! {| return!
page_title = page.Title hashForPage page.Title
page = DisplayPage.fromPage webLog page |> addToHash "page" (DisplayPage.fromPage webLog page)
categories = CategoryCache.get ctx |> addToHash ViewContext.IsHome true
is_home = true |> themedView (defaultArg page.Template "single-page") next ctx
|}
|> makeHash |> themedView (defaultArg page.Template "single-page") next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -211,10 +211,10 @@ let home : HttpHandler = fun next ctx -> task {
let all pageNbr : HttpHandler = requireAccess Author >=> fun next ctx -> task { let all pageNbr : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let data = ctx.Data let data = ctx.Data
let! posts = data.Post.FindPageOfPosts ctx.WebLog.Id pageNbr 25 let! posts = data.Post.FindPageOfPosts ctx.WebLog.Id pageNbr 25
let! hash = preparePostList ctx.WebLog posts AdminList "" pageNbr 25 ctx data let! hash = preparePostList ctx.WebLog posts AdminList "" pageNbr 25 data
return! return!
addToHash "page_title" "Posts" hash addToHash ViewContext.PageTitle "Posts" hash
|> addToHash "csrf" ctx.CsrfTokenSet |> withAntiCsrf ctx
|> adminView "post-list" next ctx |> adminView "post-list" next ctx
} }
@ -231,25 +231,23 @@ let edit postId : HttpHandler = requireAccess Author >=> fun next ctx -> task {
} }
match result with match result with
| Some (title, post) when canEdit post.AuthorId ctx -> | Some (title, post) when canEdit post.AuthorId ctx ->
let! cats = data.Category.FindAllForView ctx.WebLog.Id
let! templates = templatesForTheme ctx "post" let! templates = templatesForTheme ctx "post"
let model = EditPostModel.fromPost ctx.WebLog post let model = EditPostModel.fromPost ctx.WebLog post
return! {| return!
page_title = title hashForPage title
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
model = model |> addToHash ViewContext.Model model
metadata = Array.zip model.MetaNames model.MetaValues |> addToHash "metadata" (
|> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]) Array.zip model.MetaNames model.MetaValues
templates = templates |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]))
categories = cats |> addToHash "templates" templates
explicit_values = [| |> addToHash "explicit_values" [|
KeyValuePair.Create ("", "&ndash; Default &ndash;") KeyValuePair.Create ("", "&ndash; Default &ndash;")
KeyValuePair.Create (ExplicitRating.toString Yes, "Yes") KeyValuePair.Create (ExplicitRating.toString Yes, "Yes")
KeyValuePair.Create (ExplicitRating.toString No, "No") KeyValuePair.Create (ExplicitRating.toString No, "No")
KeyValuePair.Create (ExplicitRating.toString Clean, "Clean") KeyValuePair.Create (ExplicitRating.toString Clean, "Clean")
|] |]
|} |> adminView "post-edit" next ctx
|> makeHash |> adminView "post-edit" next ctx
| Some _ -> return! Error.notAuthorized next ctx | Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -266,12 +264,11 @@ let delete postId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
let editPermalinks postId : HttpHandler = requireAccess Author >=> fun next ctx -> task { let editPermalinks postId : HttpHandler = requireAccess Author >=> fun next ctx -> task {
match! ctx.Data.Post.FindFullById (PostId postId) ctx.WebLog.Id with match! ctx.Data.Post.FindFullById (PostId postId) ctx.WebLog.Id with
| Some post when canEdit post.AuthorId ctx -> | Some post when canEdit post.AuthorId ctx ->
return! {| return!
page_title = "Manage Prior Permalinks" hashForPage "Manage Prior Permalinks"
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
model = ManagePermalinksModel.fromPost post |> addToHash ViewContext.Model (ManagePermalinksModel.fromPost post)
|} |> adminView "permalinks" next ctx
|> makeHash |> adminView "permalinks" next ctx
| Some _ -> return! Error.notAuthorized next ctx | Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -296,12 +293,11 @@ let savePermalinks : HttpHandler = requireAccess Author >=> fun next ctx -> task
let editRevisions postId : HttpHandler = requireAccess Author >=> fun next ctx -> task { let editRevisions postId : HttpHandler = requireAccess Author >=> fun next ctx -> task {
match! ctx.Data.Post.FindFullById (PostId postId) ctx.WebLog.Id with match! ctx.Data.Post.FindFullById (PostId postId) ctx.WebLog.Id with
| Some post when canEdit post.AuthorId ctx -> | Some post when canEdit post.AuthorId ctx ->
return! {| return!
page_title = "Manage Post Revisions" hashForPage "Manage Post Revisions"
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
model = ManageRevisionsModel.fromPost ctx.WebLog post |> addToHash ViewContext.Model (ManageRevisionsModel.fromPost ctx.WebLog post)
|} |> adminView "revisions" next ctx
|> makeHash |> adminView "revisions" next ctx
| Some _ -> return! Error.notAuthorized next ctx | Some _ -> return! Error.notAuthorized next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }

View File

@ -29,9 +29,9 @@ module CatchAll =
match data.Post.FindByPermalink permalink webLog.Id |> await with match data.Post.FindByPermalink permalink webLog.Id |> await with
| Some post -> | Some post ->
debug (fun () -> "Found post by permalink") debug (fun () -> "Found post by permalink")
let hash = Post.preparePostList webLog [ post ] Post.ListType.SinglePost "" 1 1 ctx data |> await let hash = Post.preparePostList webLog [ post ] Post.ListType.SinglePost "" 1 1 data |> await
yield fun next ctx -> yield fun next ctx ->
addToHash "page_title" post.Title hash addToHash ViewContext.PageTitle post.Title hash
|> themedView (defaultArg post.Template "single-post") next ctx |> themedView (defaultArg post.Template "single-post") next ctx
| None -> () | None -> ()
// Current page // Current page
@ -39,13 +39,10 @@ module CatchAll =
| Some page -> | Some page ->
debug (fun () -> "Found page by permalink") debug (fun () -> "Found page by permalink")
yield fun next ctx -> yield fun next ctx ->
{| hashForPage page.Title
page_title = page.Title |> addToHash "page" (DisplayPage.fromPage webLog page)
page = DisplayPage.fromPage webLog page |> addToHash ViewContext.IsPage true
categories = CategoryCache.get ctx |> themedView (defaultArg page.Template "single-page") next ctx
is_page = true
|}
|> makeHash |> themedView (defaultArg page.Template "single-page") next ctx
| None -> () | None -> ()
// RSS feed // RSS feed
match Feed.deriveFeedType ctx textLink with match Feed.deriveFeedType ctx textLink with
@ -195,8 +192,9 @@ let router : HttpHandler = choose [
routef "/%s/delete" Upload.deleteFromDb routef "/%s/delete" Upload.deleteFromDb
]) ])
subRoute "/user" (choose [ subRoute "/user" (choose [
route "/my-info" >=> User.saveMyInfo route "/my-info" >=> User.saveMyInfo
route "/save" >=> User.save route "/save" >=> User.save
routef "/%s/delete" User.delete
]) ])
] ]
]) ])

View File

@ -118,22 +118,19 @@ let list : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|> List.map (DisplayUpload.fromUpload webLog Database) |> List.map (DisplayUpload.fromUpload webLog Database)
|> List.append diskUploads |> List.append diskUploads
|> List.sortByDescending (fun file -> file.UpdatedOn, file.Path) |> List.sortByDescending (fun file -> file.UpdatedOn, file.Path)
return!
return! {| hashForPage "Uploaded Files"
page_title = "Uploaded Files" |> withAntiCsrf ctx
csrf = ctx.CsrfTokenSet |> addToHash "files" allFiles
files = allFiles |> adminView "upload-list" next ctx
|}
|> makeHash |> adminView "upload-list" next ctx
} }
// GET /admin/upload/new // GET /admin/upload/new
let showNew : HttpHandler = requireAccess Author >=> fun next ctx -> let showNew : HttpHandler = requireAccess Author >=> fun next ctx ->
{| page_title = "Upload a File" hashForPage "Upload a File"
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
destination = UploadDestination.toString ctx.WebLog.Uploads |> addToHash "destination" (UploadDestination.toString ctx.WebLog.Uploads)
|} |> adminView "upload-new" next ctx
|> makeHash |> adminView "upload-new" next ctx
/// Redirect to the upload list /// Redirect to the upload list

View File

@ -23,11 +23,10 @@ let logOn returnUrl : HttpHandler = fun next ctx ->
match returnUrl with match returnUrl with
| Some _ -> returnUrl | Some _ -> returnUrl
| None -> if ctx.Request.Query.ContainsKey "returnUrl" then Some ctx.Request.Query["returnUrl"].[0] else None | None -> if ctx.Request.Query.ContainsKey "returnUrl" then Some ctx.Request.Query["returnUrl"].[0] else None
{| page_title = "Log On" hashForPage "Log On"
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
model = { LogOnModel.empty with ReturnTo = returnTo } |> addToHash ViewContext.Model { LogOnModel.empty with ReturnTo = returnTo }
|} |> adminView "log-on" next ctx
|> makeHash |> adminView "log-on" next ctx
open System.Security.Claims open System.Security.Claims
@ -72,21 +71,22 @@ let logOff : HttpHandler = fun next ctx -> task {
// ~~ ADMINISTRATION ~~ // ~~ ADMINISTRATION ~~
open System.Collections.Generic open System.Collections.Generic
open DotLiquid
open Giraffe.Htmx open Giraffe.Htmx
open Microsoft.AspNetCore.Http open Microsoft.AspNetCore.Http
/// Create the hash needed to display the user list /// Create the hash needed to display the user list
let private userListHash (ctx : HttpContext) = task { let private userListHash (ctx : HttpContext) = task {
let! users = ctx.Data.WebLogUser.FindByWebLog ctx.WebLog.Id let! users = ctx.Data.WebLogUser.FindByWebLog ctx.WebLog.Id
return makeHash {| return!
page_title = "User Administration" hashForPage "User Administration"
csrf = ctx.CsrfTokenSet |> withAntiCsrf ctx
web_log = ctx.WebLog |> addToHash "users" (users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList)
users = users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList |> addViewContext ctx
|}
} }
/// Got no time for URL/form manipulators...
let private goAway : HttpHandler = RequestErrors.BAD_REQUEST "really?"
// GET /admin/users // GET /admin/users
let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let! hash = userListHash ctx let! hash = userListHash ctx
@ -103,16 +103,17 @@ let bare : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
} }
/// Show the edit user page /// Show the edit user page
let private showEdit (hash : Hash) : HttpHandler = fun next ctx -> let private showEdit (model : EditUserModel) : HttpHandler = fun next ctx ->
addToHash "page_title" (if (hash["model"] :?> EditUserModel).IsNew then "Add a New User" else "Edit User") hash hashForPage (if model.IsNew then "Add a New User" else "Edit User")
|> addToHash "csrf" ctx.CsrfTokenSet |> withAntiCsrf ctx
|> addToHash "access_levels" |> addToHash ViewContext.Model model
[| KeyValuePair.Create (AccessLevel.toString Author, "Author") |> addToHash "access_levels" [|
KeyValuePair.Create (AccessLevel.toString Editor, "Editor") KeyValuePair.Create (AccessLevel.toString Author, "Author")
KeyValuePair.Create (AccessLevel.toString WebLogAdmin, "Web Log Admin") KeyValuePair.Create (AccessLevel.toString Editor, "Editor")
if ctx.HasAccessLevel Administrator then KeyValuePair.Create (AccessLevel.toString WebLogAdmin, "Web Log Admin")
KeyValuePair.Create (AccessLevel.toString Administrator, "Administrator") if ctx.HasAccessLevel Administrator then
|] KeyValuePair.Create (AccessLevel.toString Administrator, "Administrator")
|]
|> adminBareView "user-edit" next ctx |> adminBareView "user-edit" next ctx
// GET /admin/user/{id}/edit // GET /admin/user/{id}/edit
@ -123,7 +124,7 @@ let edit usrId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> tas
if isNew then someTask { WebLogUser.empty with Id = userId } if isNew then someTask { WebLogUser.empty with Id = userId }
else ctx.Data.WebLogUser.FindById userId ctx.WebLog.Id else ctx.Data.WebLogUser.FindById userId ctx.WebLog.Id
match! tryUser with match! tryUser with
| Some user -> return! showEdit (makeHash {| model = EditUserModel.fromUser user |}) next ctx | Some user -> return! showEdit (EditUserModel.fromUser user) next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -143,7 +144,7 @@ let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
| Some user when model.Password = model.PasswordConfirm -> | Some user when model.Password = model.PasswordConfirm ->
let updatedUser = model.UpdateUser user let updatedUser = model.UpdateUser user
if updatedUser.AccessLevel = Administrator && not (ctx.HasAccessLevel Administrator) then if updatedUser.AccessLevel = Administrator && not (ctx.HasAccessLevel Administrator) then
return! RequestErrors.BAD_REQUEST "really?" next ctx return! goAway next ctx
else else
let updatedUser = let updatedUser =
if model.Password = "" then updatedUser if model.Password = "" then updatedUser
@ -159,26 +160,51 @@ let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
| Some _ -> | Some _ ->
do! addMessage ctx { UserMessage.error with Message = "The passwords did not match; nothing saved" } do! addMessage ctx { UserMessage.error with Message = "The passwords did not match; nothing saved" }
return! return!
(withHxRetarget $"#user_{model.Id}" (withHxRetarget $"#user_{model.Id}" >=> showEdit { model with Password = ""; PasswordConfirm = "" })
>=> showEdit (makeHash {| model = { model with Password = ""; PasswordConfirm = "" } |}))
next ctx next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// POST /admin/user/{id}/delete
let delete userId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
let data = ctx.Data
match! data.WebLogUser.FindById (WebLogUserId userId) ctx.WebLog.Id with
| Some user ->
if user.AccessLevel = Administrator && not (ctx.HasAccessLevel Administrator) then
return! goAway next ctx
else
match! data.WebLogUser.Delete user.Id user.WebLogId with
| Ok _ ->
do! addMessage ctx
{ UserMessage.success with
Message = $"User {WebLogUser.displayName user} deleted successfully"
}
return! bare next ctx
| Error msg ->
do! addMessage ctx
{ UserMessage.error with
Message = $"User {WebLogUser.displayName user} was not deleted"
Detail = Some msg
}
return! bare next ctx
| None -> return! Error.notFound next ctx
}
/// Display the user "my info" page, with information possibly filled in /// Display the user "my info" page, with information possibly filled in
let private showMyInfo (user : WebLogUser) (hash : Hash) : HttpHandler = fun next ctx -> let private showMyInfo (model : EditMyInfoModel) (user : WebLogUser) : HttpHandler = fun next ctx ->
addToHash "page_title" "Edit Your Information" hash hashForPage "Edit Your Information"
|> addToHash "csrf" ctx.CsrfTokenSet |> withAntiCsrf ctx
|> addToHash "access_level" (AccessLevel.toString user.AccessLevel) |> addToHash ViewContext.Model model
|> addToHash "created_on" (WebLog.localTime ctx.WebLog user.CreatedOn) |> addToHash "access_level" (AccessLevel.toString user.AccessLevel)
|> addToHash "last_seen_on" (WebLog.localTime ctx.WebLog (defaultArg user.LastSeenOn DateTime.UnixEpoch)) |> addToHash "created_on" (WebLog.localTime ctx.WebLog user.CreatedOn)
|> addToHash "last_seen_on" (WebLog.localTime ctx.WebLog (defaultArg user.LastSeenOn DateTime.UnixEpoch))
|> adminView "my-info" next ctx |> adminView "my-info" next ctx
// GET /admin/user/my-info // GET /admin/user/my-info
let myInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task { let myInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task {
match! ctx.Data.WebLogUser.FindById ctx.UserId ctx.WebLog.Id with match! ctx.Data.WebLogUser.FindById ctx.UserId ctx.WebLog.Id with
| Some user -> return! showMyInfo user (makeHash {| model = EditMyInfoModel.fromUser user |}) next ctx | Some user -> return! showMyInfo (EditMyInfoModel.fromUser user) user next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -208,7 +234,6 @@ let saveMyInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task {
return! redirectToGet "admin/user/my-info" next ctx return! redirectToGet "admin/user/my-info" next ctx
| Some user -> | Some user ->
do! addMessage ctx { UserMessage.error with Message = "Passwords did not match; no updates made" } do! addMessage ctx { UserMessage.error with Message = "Passwords did not match; no updates made" }
return! showMyInfo user (makeHash {| model = { model with NewPassword = ""; NewPasswordConfirm = "" } |}) return! showMyInfo { model with NewPassword = ""; NewPasswordConfirm = "" } user next ctx
next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }

View File

@ -46,7 +46,7 @@ module DataImplementation =
let createSQLite connStr = let createSQLite connStr =
let log = sp.GetRequiredService<ILogger<SQLiteData>> () let log = sp.GetRequiredService<ILogger<SQLiteData>> ()
let conn = new SqliteConnection (connStr) let conn = new SqliteConnection (connStr)
log.LogInformation $"Using SQL database {conn.DataSource}" log.LogInformation $"Using SQLite database {conn.DataSource}"
await (SQLiteData.setUpConnection conn) await (SQLiteData.setUpConnection conn)
SQLiteData (conn, log) SQLiteData (conn, log)
@ -62,6 +62,26 @@ module DataImplementation =
upcast createSQLite "Data Source=./myweblog.db;Cache=Shared" upcast createSQLite "Data Source=./myweblog.db;Cache=Shared"
open System.Threading.Tasks
/// Show a list of valid command-line interface commands
let showHelp () =
printfn " "
printfn "COMMAND WHAT IT DOES"
printfn "----------- ------------------------------------------------------"
printfn "backup Create a JSON file backup of a web log"
printfn "do-restore Restore a JSON file backup (overwrite data silently)"
printfn "help Display this information"
printfn "import-links Import prior permalinks"
printfn "init Initializes a new web log"
printfn "load-theme Load a theme"
printfn "restore Restore a JSON file backup (prompt before overwriting)"
printfn "upgrade-user Upgrade a WebLogAdmin user to a full Administrator"
printfn " "
printfn "For more information on a particular command, run it with no options."
Task.FromResult ()
open Giraffe open Giraffe
open Giraffe.EndpointRouting open Giraffe.EndpointRouting
open Microsoft.AspNetCore.Authentication.Cookies open Microsoft.AspNetCore.Authentication.Cookies
@ -138,7 +158,11 @@ let rec main args =
| Some it when it = "restore" -> Maintenance.Backup.restoreFromBackup args app.Services | Some it when it = "restore" -> Maintenance.Backup.restoreFromBackup args app.Services
| Some it when it = "do-restore" -> Maintenance.Backup.restoreFromBackup args app.Services | Some it when it = "do-restore" -> Maintenance.Backup.restoreFromBackup args app.Services
| Some it when it = "upgrade-user" -> Maintenance.upgradeUser args app.Services | Some it when it = "upgrade-user" -> Maintenance.upgradeUser args app.Services
| _ -> | Some it when it = "help" -> showHelp ()
| Some it ->
printfn $"""Unrecognized command "{it}" - valid commands are:"""
showHelp ()
| None ->
let _ = app.UseForwardedHeaders () let _ = app.UseForwardedHeaders ()
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict)) let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
let _ = app.UseMiddleware<WebLogMiddleware> () let _ = app.UseMiddleware<WebLogMiddleware> ()
@ -148,7 +172,7 @@ let rec main args =
let _ = app.UseSession () let _ = app.UseSession ()
let _ = app.UseGiraffe Handlers.Routes.endpoint let _ = app.UseGiraffe Handlers.Routes.endpoint
System.Threading.Tasks.Task.FromResult (app.Run ()) Task.FromResult (app.Run ())
|> Async.AwaitTask |> Async.RunSynchronously |> Async.AwaitTask |> Async.RunSynchronously
0 // Exit code 0 // Exit code

View File

@ -19,19 +19,23 @@
{%- elsif user.access_level == "Author" %} {%- elsif user.access_level == "Author" %}
<span class="{{ badge }}-dark">AUTHOR</span> <span class="{{ badge }}-dark">AUTHOR</span>
{%- endif %}<br> {%- endif %}<br>
<small> {%- unless is_administrator == false and user.access_level == "Administrator" %}
{%- assign user_url_base = "admin/user/" | append: user.id -%} <small>
<a href="{{ user_url_base | append: "/edit" | relative_link }}" hx-target="#user_{{ user.id }}" {%- assign user_url_base = "admin/user/" | append: user.id -%}
hx-swap="innerHTML show:#user_{{ user.id }}:top"> <a href="{{ user_url_base | append: "/edit" | relative_link }}" hx-target="#user_{{ user.id }}"
Edit hx-swap="innerHTML show:#user_{{ user.id }}:top">
</a> Edit
<span class="text-muted"> &bull; </span> </a>
{%- assign user_del_link = user_url_base | append: "/delete" | relative_link -%} {% unless user_id == user.id %}
<a href="{{ user_del_link }}" hx-post="{{ user_del_link }}" class="text-danger" <span class="text-muted"> &bull; </span>
hx-confirm="Are you sure you want to delete the category &ldquo;{{ cat.name }}&rdquo;? This action cannot be undone. (This action will not succeed if the user has authored any posts or pages.)"> {%- assign user_del_link = user_url_base | append: "/delete" | relative_link -%}
Delete <a href="{{ user_del_link }}" hx-post="{{ user_del_link }}" class="text-danger"
</a> hx-confirm="Are you sure you want to delete the user &ldquo;{{ user.preferred_name }}&rdquo;? This action cannot be undone. (This action will not succeed if the user has authored any posts or pages.)">
</small> Delete
</a>
{% endunless %}
</small>
{%- endunless %}
</div> </div>
<div class="{{ email_col }}"> <div class="{{ email_col }}">
{{ user.first_name }} {{ user.last_name }}<br> {{ user.first_name }} {{ user.last_name }}<br>