From 039d09aed5aa1ff95c35300c3be5498f341ac07b Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Fri, 15 Jul 2022 22:51:51 -0400 Subject: [PATCH] WIP on page revisions (#13) - Simplify redirectToGet usage - Move a few functions to HttpContext extension properties - Modify bare response to allow content not from a template - Fix uploaded date/time handling --- src/MyWebLog.Domain/DataTypes.fs | 5 -- src/MyWebLog/Caches.fs | 19 +++- src/MyWebLog/Handlers/Admin.fs | 142 +++++++++++++++++++++--------- src/MyWebLog/Handlers/Error.fs | 23 +++-- src/MyWebLog/Handlers/Feed.fs | 15 ++-- src/MyWebLog/Handlers/Helpers.fs | 47 +++++----- src/MyWebLog/Handlers/Post.fs | 23 +++-- src/MyWebLog/Handlers/Routes.fs | 21 +++-- src/MyWebLog/Handlers/Upload.fs | 11 ++- src/MyWebLog/Handlers/User.fs | 19 ++-- src/admin-theme/page-edit.liquid | 11 ++- src/admin-theme/revisions.liquid | 49 +++++++---- src/admin-theme/wwwroot/admin.css | 3 + 13 files changed, 233 insertions(+), 155 deletions(-) diff --git a/src/MyWebLog.Domain/DataTypes.fs b/src/MyWebLog.Domain/DataTypes.fs index 39272bd..7a2437e 100644 --- a/src/MyWebLog.Domain/DataTypes.fs +++ b/src/MyWebLog.Domain/DataTypes.fs @@ -405,11 +405,6 @@ module WebLog = TimeZoneInfo.ConvertTimeFromUtc (DateTime (date.Ticks, DateTimeKind.Utc), TimeZoneInfo.FindSystemTimeZoneById webLog.timeZone) - /// Convert a date/time in the web log's local date/time to UTC - let utcTime webLog (date : DateTime) = - TimeZoneInfo.ConvertTimeToUtc - (DateTime (date.Ticks, DateTimeKind.Unspecified), TimeZoneInfo.FindSystemTimeZoneById webLog.timeZone) - /// A user of the web log [] diff --git a/src/MyWebLog/Caches.fs b/src/MyWebLog/Caches.fs index c391216..0324ab8 100644 --- a/src/MyWebLog/Caches.fs +++ b/src/MyWebLog/Caches.fs @@ -7,16 +7,29 @@ open MyWebLog.Data [] module Extensions = + open System.Security.Claims + open Microsoft.AspNetCore.Antiforgery open Microsoft.Extensions.DependencyInjection type HttpContext with - /// The web log for the current request - member this.WebLog = this.Items["webLog"] :?> WebLog + /// The anti-CSRF service + member this.AntiForgery = this.RequestServices.GetRequiredService () + + /// The cross-site request forgery token set for this request + member this.CsrfTokenSet = this.AntiForgery.GetAndStoreTokens this + /// The data implementation member this.Data = this.RequestServices.GetRequiredService () - + /// The user ID for the current request + member this.UserId = + WebLogUserId (this.User.Claims |> Seq.find (fun c -> c.Type = ClaimTypes.NameIdentifier)).Value + + /// The web log for the current request + member this.WebLog = this.Items["webLog"] :?> WebLog + + open System.Collections.Concurrent /// diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index 4c181d2..9d1c2a6 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -39,10 +39,10 @@ let dashboard : HttpHandler = fun next ctx -> task { let listCategories : HttpHandler = fun next ctx -> task { let! catListTemplate = TemplateCache.get "admin" "category-list-body" ctx.Data let hash = Hash.FromAnonymousObject {| + page_title = "Categories" + csrf = ctx.CsrfTokenSet web_log = ctx.WebLog categories = CategoryCache.get ctx - page_title = "Categories" - csrf = csrfToken ctx |} hash.Add ("category_list", catListTemplate.Render hash) return! viewForTheme "admin" "category-list" next ctx hash @@ -53,7 +53,7 @@ let listCategoriesBare : HttpHandler = fun next ctx -> task { return! Hash.FromAnonymousObject {| categories = CategoryCache.get ctx - csrf = csrfToken ctx + csrf = ctx.CsrfTokenSet |} |> bareForTheme "admin" "category-list-body" next ctx } @@ -73,9 +73,9 @@ let editCategory catId : HttpHandler = fun next ctx -> task { | Some (title, cat) -> return! Hash.FromAnonymousObject {| - csrf = csrfToken ctx - model = EditCategoryModel.fromCategory cat page_title = title + csrf = ctx.CsrfTokenSet + model = EditCategoryModel.fromCategory cat categories = CategoryCache.get ctx |} |> bareForTheme "admin" "category-edit" next ctx @@ -127,9 +127,9 @@ let listPages pageNbr : HttpHandler = fun next ctx -> task { let! pages = ctx.Data.Page.findPageOfPages webLog.id pageNbr return! Hash.FromAnonymousObject {| - csrf = csrfToken ctx - pages = pages |> List.map (DisplayPage.fromPageMinimal webLog) page_title = "Pages" + csrf = ctx.CsrfTokenSet + pages = pages |> List.map (DisplayPage.fromPageMinimal webLog) page_nbr = pageNbr prev_page = if pageNbr = 2 then "" else $"/page/{pageNbr - 1}" next_page = $"/page/{pageNbr + 1}" @@ -153,26 +153,37 @@ let editPage pgId : HttpHandler = fun next ctx -> task { let! templates = templatesForTheme ctx "page" return! Hash.FromAnonymousObject {| - csrf = csrfToken ctx + page_title = title + csrf = ctx.CsrfTokenSet model = model metadata = Array.zip model.metaNames model.metaValues |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]) - page_title = title templates = templates |} |> viewForTheme "admin" "page-edit" next ctx | None -> return! Error.notFound next ctx } +// POST /admin/page/{id}/delete +let deletePage pgId : HttpHandler = fun next ctx -> task { + let webLog = ctx.WebLog + match! ctx.Data.Page.delete (PageId pgId) webLog.id with + | true -> + do! PageListCache.update ctx + do! addMessage ctx { UserMessage.success with message = "Page deleted successfully" } + | false -> do! addMessage ctx { UserMessage.error with message = "Page not found; nothing deleted" } + return! redirectToGet "admin/pages" next ctx +} + // GET /admin/page/{id}/permalinks let editPagePermalinks pgId : HttpHandler = fun next ctx -> task { match! ctx.Data.Page.findFullById (PageId pgId) ctx.WebLog.id with | Some pg -> return! Hash.FromAnonymousObject {| - csrf = csrfToken ctx + page_title = "Manage Prior Permalinks" + csrf = ctx.CsrfTokenSet model = ManagePermalinksModel.fromPage pg - page_title = $"Manage Prior Permalinks" |} |> viewForTheme "admin" "permalinks" next ctx | None -> return! Error.notFound next ctx @@ -186,7 +197,7 @@ let savePagePermalinks : HttpHandler = fun next ctx -> task { match! ctx.Data.Page.updatePriorPermalinks (PageId model.id) webLog.id links with | true -> do! addMessage ctx { UserMessage.success with message = "Page permalinks saved successfully" } - return! redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/page/{model.id}/permalinks")) next ctx + return! redirectToGet $"admin/page/{model.id}/permalinks" next ctx | false -> return! Error.notFound next ctx } @@ -197,27 +208,74 @@ let editPageRevisions pgId : HttpHandler = fun next ctx -> task { | Some pg -> return! Hash.FromAnonymousObject {| - csrf = csrfToken ctx + page_title = "Manage Page Revisions" + csrf = ctx.CsrfTokenSet model = ManageRevisionsModel.fromPage webLog pg - page_title = $"Manage Page Permalinks" |} |> viewForTheme "admin" "revisions" next ctx | None -> return! Error.notFound next ctx } -// POST /admin/page/{id}/delete -let deletePage pgId : HttpHandler = fun next ctx -> task { +// GET /admin/page/{id}/revisions/purge +let purgePageRevisions pgId : HttpHandler = fun next ctx -> task { let webLog = ctx.WebLog - match! ctx.Data.Page.delete (PageId pgId) webLog.id with - | true -> - do! PageListCache.update ctx - do! addMessage ctx { UserMessage.success with message = "Page deleted successfully" } - | false -> do! addMessage ctx { UserMessage.error with message = "Page not found; nothing deleted" } - return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/pages")) next ctx + let data = ctx.Data + match! data.Page.findFullById (PageId pgId) webLog.id with + | Some pg -> + do! data.Page.update { pg with revisions = [ List.head pg.revisions ] } + do! addMessage ctx { UserMessage.success with message = "Prior revisions purged successfully" } + return! redirectToGet $"admin/page/{pgId}/revisions" next ctx + | None -> return! Error.notFound next ctx +} + +open Microsoft.AspNetCore.Http + +/// Find the page and the requested revision +let private findPageRevision pgId revDate (ctx : HttpContext) = task { + match! ctx.Data.Page.findFullById (PageId pgId) ctx.WebLog.id with + | Some pg -> + let asOf = parseToUtc revDate + return Some pg, pg.revisions |> List.tryFind (fun r -> r.asOf = asOf) + | None -> return None, None +} + +// GET /admin/page/{id}/revision/{revision-date}/preview +let previewPageRevision (pgId, revDate) : HttpHandler = fun next ctx -> task { + match! findPageRevision pgId revDate ctx with + | Some _, Some rev -> + return! bareForTheme "admin" "" next ctx (Hash.FromAnonymousObject {| content = MarkupText.toHtml rev.text |}) + | None, _ + | _, None -> return! Error.notFound next ctx } open System +// POST /admin/page/{id}/revision/{revision-date}/restore +let restorePageRevision (pgId, revDate) : HttpHandler = fun next ctx -> task { + match! findPageRevision pgId revDate ctx with + | Some pg, Some rev -> + do! ctx.Data.Page.update + { pg with + revisions = { rev with asOf = DateTime.UtcNow } + :: (pg.revisions |> List.filter (fun r -> r.asOf <> rev.asOf)) + } + do! addMessage ctx { UserMessage.success with message = "Revision restored successfully" } + return! redirectToGet $"admin/page/{pgId}/revisions" next ctx + | None, _ + | _, None -> return! Error.notFound next ctx +} + +// POST /admin/page/{id}/revision/{revision-date}/delete +let deletePageRevision (pgId, revDate) : HttpHandler = fun next ctx -> task { + match! findPageRevision pgId revDate ctx with + | Some pg, Some rev -> + do! ctx.Data.Page.update { pg with revisions = pg.revisions |> List.filter (fun r -> r.asOf <> rev.asOf) } + do! addMessage ctx { UserMessage.success with message = "Revision deleted successfully" } + return! bareForTheme "admin" "" next ctx (Hash.FromAnonymousObject {| content = "" |}) + | None, _ + | _, None -> return! Error.notFound next ctx +} + #nowarn "3511" // POST /admin/page/save @@ -233,7 +291,7 @@ let savePage : HttpHandler = fun next ctx -> task { { Page.empty with id = PageId.create () webLogId = webLog.id - authorId = userId ctx + authorId = ctx.UserId publishedOn = now } | pgId -> return! data.Page.findFullById (PageId pgId) webLog.id @@ -268,21 +326,18 @@ let savePage : HttpHandler = fun next ctx -> task { do! (if model.pageId = "new" then data.Page.add else data.Page.update) page if updateList then do! PageListCache.update ctx do! addMessage ctx { UserMessage.success with message = "Page saved successfully" } - return! - redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/page/{PageId.toString page.id}/edit")) next ctx + return! redirectToGet $"admin/page/{PageId.toString page.id}/edit" next ctx | None -> return! Error.notFound next ctx } // -- TAG MAPPINGS -- -open Microsoft.AspNetCore.Http - /// Get the hash necessary to render the tag mapping list let private tagMappingHash (ctx : HttpContext) = task { let! mappings = ctx.Data.TagMap.findByWebLog ctx.WebLog.id return Hash.FromAnonymousObject {| + csrf = ctx.CsrfTokenSet web_log = ctx.WebLog - csrf = csrfToken ctx mappings = mappings mapping_ids = mappings |> List.map (fun it -> { name = it.tag; value = TagMapId.toString it.id }) |} @@ -317,9 +372,9 @@ let editMapping tagMapId : HttpHandler = fun next ctx -> task { | Some tm -> return! Hash.FromAnonymousObject {| - csrf = csrfToken ctx - model = EditTagMapModel.fromMapping tm page_title = if isNew then "Add Tag Mapping" else $"Mapping for {tm.tag} Tag" + csrf = ctx.CsrfTokenSet + model = EditTagMapModel.fromMapping tm |} |> bareForTheme "admin" "tag-mapping-edit" next ctx | None -> return! Error.notFound next ctx @@ -361,8 +416,8 @@ open MyWebLog.Data let themeUpdatePage : HttpHandler = fun next ctx -> task { return! Hash.FromAnonymousObject {| - csrf = csrfToken ctx page_title = "Upload Theme" + csrf = ctx.CsrfTokenSet |} |> viewForTheme "admin" "upload-theme" next ctx } @@ -457,13 +512,13 @@ let updateTheme : HttpHandler = fun next ctx -> task { do! ThemeAssetCache.refreshTheme (ThemeId themeName) data TemplateCache.invalidateTheme themeName do! addMessage ctx { UserMessage.success with message = "Theme updated successfully" } - return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/dashboard")) next ctx + return! redirectToGet "admin/dashboard" next ctx | Ok _ -> do! addMessage ctx { UserMessage.error with message = "You may not replace the admin theme" } - return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/theme/update")) next ctx + return! redirectToGet "admin/theme/update" next ctx | Error message -> do! addMessage ctx { UserMessage.error with message = message } - return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/theme/update")) next ctx + return! redirectToGet "admin/theme/update" next ctx else return! RequestErrors.BAD_REQUEST "Bad request" next ctx } @@ -480,11 +535,12 @@ let settings : HttpHandler = fun next ctx -> task { let! themes = data.Theme.all () return! Hash.FromAnonymousObject {| - csrf = csrfToken ctx - model = SettingsModel.fromWebLog webLog - pages = - seq { - KeyValuePair.Create ("posts", "- First Page of Posts -") + page_title = "Web Log Settings" + csrf = ctx.CsrfTokenSet + web_log = webLog + model = SettingsModel.fromWebLog webLog + pages = seq + { KeyValuePair.Create ("posts", "- First Page of Posts -") yield! allPages |> List.sortBy (fun p -> p.title.ToLower ()) |> List.map (fun p -> KeyValuePair.Create (PageId.toString p.id, p.title)) @@ -495,12 +551,10 @@ let settings : HttpHandler = fun next ctx -> task { |> Seq.ofList |> Seq.map (fun it -> KeyValuePair.Create (ThemeId.toString it.id, $"{it.name} (v{it.version})")) |> Array.ofSeq - upload_values = - [| KeyValuePair.Create (UploadDestination.toString Database, "Database") + upload_values = [| + KeyValuePair.Create (UploadDestination.toString Database, "Database") KeyValuePair.Create (UploadDestination.toString Disk, "Disk") |] - web_log = webLog - page_title = "Web Log Settings" |} |> viewForTheme "admin" "settings" next ctx } @@ -526,6 +580,6 @@ let saveSettings : HttpHandler = fun next ctx -> task { if Directory.Exists oldDir then Directory.Move (oldDir, Path.Combine (uploadRoot, webLog.slug)) do! addMessage ctx { UserMessage.success with message = "Web log settings saved successfully" } - return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings")) next ctx + return! redirectToGet "admin/settings" next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Error.fs b/src/MyWebLog/Handlers/Error.fs index 0f7157c..00ce24c 100644 --- a/src/MyWebLog/Handlers/Error.fs +++ b/src/MyWebLog/Handlers/Error.fs @@ -2,21 +2,20 @@ module MyWebLog.Handlers.Error open System.Net -open System.Threading.Tasks open Giraffe -open Microsoft.AspNetCore.Http open MyWebLog /// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response -let notAuthorized : HttpHandler = fun next ctx -> task { - if ctx.Request.Method = "GET" then - let returnUrl = WebUtility.UrlEncode ctx.Request.Path - return! - redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink $"user/log-on?returnUrl={returnUrl}")) next ctx - else - return! (setStatusCode 401 >=> fun _ _ -> Task.FromResult None) next ctx -} +let notAuthorized : HttpHandler = + handleContext (fun ctx -> + if ctx.Request.Method = "GET" then + let returnUrl = WebUtility.UrlEncode ctx.Request.Path + redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink $"user/log-on?returnUrl={returnUrl}")) + earlyReturn ctx + else + setStatusCode 401 earlyReturn ctx) + /// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there -let notFound : HttpHandler = - setStatusCode 404 >=> text "Not found" +let notFound : HttpHandler = fun _ -> + (setStatusCode 404 >=> text "Not found") earlyReturn diff --git a/src/MyWebLog/Handlers/Feed.fs b/src/MyWebLog/Handlers/Feed.fs index 4f99184..c102d64 100644 --- a/src/MyWebLog/Handlers/Feed.fs +++ b/src/MyWebLog/Handlers/Feed.fs @@ -423,9 +423,9 @@ let editSettings : HttpHandler = fun next ctx -> task { webLog.rss.customFeeds |> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx)) |> Array.ofList - return! Hash.FromAnonymousObject - {| csrf = csrfToken ctx + return! Hash.FromAnonymousObject {| page_title = "RSS Settings" + csrf = ctx.CsrfTokenSet model = EditRssModel.fromRssOptions webLog.rss custom_feeds = feeds |} @@ -442,7 +442,7 @@ let saveSettings : HttpHandler = fun next ctx -> task { do! data.WebLog.updateRssOptions webLog WebLogCache.set webLog do! addMessage ctx { UserMessage.success with message = "RSS settings updated successfully" } - return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings/rss")) next ctx + return! redirectToGet "admin/settings/rss" next ctx | None -> return! Error.notFound next ctx } @@ -454,9 +454,9 @@ let editCustomFeed feedId : HttpHandler = fun next ctx -> task { | _ -> ctx.WebLog.rss.customFeeds |> List.tryFind (fun f -> f.id = CustomFeedId feedId) match customFeed with | Some f -> - return! Hash.FromAnonymousObject - {| csrf = csrfToken ctx + return! Hash.FromAnonymousObject {| page_title = $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed""" + csrf = ctx.CsrfTokenSet model = EditCustomFeedModel.fromFeed f categories = CategoryCache.get ctx medium_values = [| @@ -494,8 +494,7 @@ let saveCustomFeed : HttpHandler = fun next ctx -> task { UserMessage.success with message = $"""Successfully {if model.id = "new" then "add" else "sav"}ed custom feed""" } - let nextUrl = $"admin/settings/rss/{CustomFeedId.toString feed.id}/edit" - return! redirectToGet (WebLog.relativeUrl webLog (Permalink nextUrl)) next ctx + return! redirectToGet $"admin/settings/rss/{CustomFeedId.toString feed.id}/edit" next ctx | None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx } @@ -519,6 +518,6 @@ let deleteCustomFeed feedId : HttpHandler = fun next ctx -> task { do! addMessage ctx { UserMessage.success with message = "Custom feed deleted successfully" } else do! addMessage ctx { UserMessage.warning with message = "Custom feed not found; no action taken" } - return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/settings/rss")) next ctx + return! redirectToGet "admin/settings/rss" next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Helpers.fs b/src/MyWebLog/Handlers/Helpers.fs index e3efe15..60591c9 100644 --- a/src/MyWebLog/Handlers/Helpers.fs +++ b/src/MyWebLog/Handlers/Helpers.fs @@ -101,7 +101,7 @@ let private populateHash hash ctx = task { } /// Render a view for the specified theme, using the specified template, layout, and hash -let viewForTheme theme template next ctx = fun (hash : Hash) -> task { +let viewForTheme theme template next ctx (hash : Hash) = task { do! populateHash hash ctx // NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a 2-pass render; @@ -119,13 +119,14 @@ let viewForTheme theme template next ctx = fun (hash : Hash) -> task { } /// Render a bare view for the specified theme, using the specified template and hash -let bareForTheme theme template next ctx = fun (hash : Hash) -> task { +let bareForTheme theme template next ctx (hash : Hash) = task { do! populateHash hash ctx - // Bare templates are rendered with layout-bare - let! contentTemplate = TemplateCache.get theme template ctx.Data - hash.Add ("content", contentTemplate.Render hash) + if not (hash.ContainsKey "content") then + let! contentTemplate = TemplateCache.get theme template ctx.Data + hash.Add ("content", contentTemplate.Render hash) + // Bare templates are rendered with layout-bare let! layoutTemplate = TemplateCache.get theme "layout-bare" ctx.Data // add messages as HTTP headers @@ -146,36 +147,21 @@ let bareForTheme theme template next ctx = fun (hash : Hash) -> task { } /// Return a view for the web log's default theme -let themedView template next ctx = fun (hash : Hash) -> task { - return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template next ctx hash -} +let themedView template next ctx hash = + viewForTheme (deriveWebLogFromHash hash ctx).themePath template next ctx hash + /// Redirect after doing some action; commits session and issues a temporary redirect -let redirectToGet url : HttpHandler = fun next ctx -> task { +let redirectToGet url : HttpHandler = fun _ ctx -> task { do! commitSession ctx - return! redirectTo false url next ctx + return! redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink url)) earlyReturn ctx } -open System.Security.Claims - -/// 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 - -open Microsoft.AspNetCore.Antiforgery - -/// Get the Anti-CSRF service -let private antiForgery (ctx : HttpContext) = ctx.RequestServices.GetRequiredService () - -/// Get the cross-site request forgery token set -let csrfToken (ctx : HttpContext) = - (antiForgery ctx).GetAndStoreTokens ctx - /// Validate the cross-site request forgery token in the current request let validateCsrf : HttpHandler = fun next ctx -> task { - match! (antiForgery ctx).IsRequestValidAsync ctx with + match! ctx.AntiForgery.IsRequestValidAsync ctx with | true -> return! next ctx - | false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" next ctx + | false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" earlyReturn ctx } /// Require a user to be logged on @@ -226,6 +212,13 @@ let getCategoryIds slug ctx = |> Seq.map (fun c -> CategoryId c.id) |> List.ofSeq +open System +open System.Globalization + +/// Parse a date/time to UTC +let parseToUtc (date : string) = + DateTime.Parse (date, null, DateTimeStyles.AdjustToUniversal) + open Microsoft.Extensions.Logging /// Log level for debugging diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index f35cdf1..b16d992 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -198,9 +198,9 @@ let home : HttpHandler = fun next ctx -> task { | Some page -> return! Hash.FromAnonymousObject {| + page_title = page.title page = DisplayPage.fromPage webLog page categories = CategoryCache.get ctx - page_title = page.title is_home = true |} |> themedView (defaultArg page.template "single-page") next ctx @@ -215,7 +215,7 @@ let all pageNbr : HttpHandler = fun next ctx -> task { let! posts = data.Post.findPageOfPosts webLog.id pageNbr 25 let! hash = preparePostList webLog posts AdminList "" pageNbr 25 ctx data hash.Add ("page_title", "Posts") - hash.Add ("csrf", csrfToken ctx) + hash.Add ("csrf", ctx.CsrfTokenSet) return! viewForTheme "admin" "post-list" next ctx hash } @@ -238,11 +238,11 @@ let edit postId : HttpHandler = fun next ctx -> task { let model = EditPostModel.fromPost webLog post return! Hash.FromAnonymousObject {| - csrf = csrfToken ctx + page_title = title + csrf = ctx.CsrfTokenSet model = model metadata = Array.zip model.metaNames model.metaValues |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]) - page_title = title templates = templates categories = cats explicit_values = [| @@ -262,9 +262,9 @@ let editPermalinks postId : HttpHandler = fun next ctx -> task { | Some post -> return! Hash.FromAnonymousObject {| - csrf = csrfToken ctx + page_title = "Manage Prior Permalinks" + csrf = ctx.CsrfTokenSet model = ManagePermalinksModel.fromPost post - page_title = $"Manage Prior Permalinks" |} |> viewForTheme "admin" "permalinks" next ctx | None -> return! Error.notFound next ctx @@ -278,7 +278,7 @@ let savePermalinks : HttpHandler = fun next ctx -> task { match! ctx.Data.Post.updatePriorPermalinks (PostId model.id) webLog.id links with | true -> do! addMessage ctx { UserMessage.success with message = "Post permalinks saved successfully" } - return! redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/post/{model.id}/permalinks")) next ctx + return! redirectToGet $"admin/post/{model.id}/permalinks" next ctx | false -> return! Error.notFound next ctx } @@ -288,7 +288,7 @@ let delete postId : HttpHandler = fun next ctx -> task { match! ctx.Data.Post.delete (PostId postId) webLog.id with | true -> do! addMessage ctx { UserMessage.success with message = "Post deleted successfully" } | false -> do! addMessage ctx { UserMessage.error with message = "Post not found; nothing deleted" } - return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/posts")) next ctx + return! redirectToGet "admin/posts" next ctx } #nowarn "3511" @@ -306,7 +306,7 @@ let save : HttpHandler = fun next ctx -> task { { Post.empty with id = PostId.create () webLogId = webLog.id - authorId = userId ctx + authorId = ctx.UserId } | postId -> return! data.Post.findFullById (PostId postId) webLog.id } @@ -323,7 +323,7 @@ let save : HttpHandler = fun next ctx -> task { let post = match model.setPublished with | true -> - let dt = WebLog.utcTime webLog model.pubOverride.Value + let dt = parseToUtc (model.pubOverride.Value.ToString "o") match model.setUpdated with | true -> { post with @@ -342,7 +342,6 @@ let save : HttpHandler = fun next ctx -> task { |> List.length = List.length pst.Value.categoryIds) then do! CategoryCache.update ctx do! addMessage ctx { UserMessage.success with message = "Post saved successfully" } - return! - redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/post/{PostId.toString post.id}/edit")) next ctx + return! redirectToGet $"admin/post/{PostId.toString post.id}/edit" next ctx | None -> return! Error.notFound next ctx } diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index d6c9989..159d870 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -40,9 +40,9 @@ module CatchAll = debug (fun () -> $"Found page by permalink") yield fun next ctx -> Hash.FromAnonymousObject {| + page_title = page.title page = DisplayPage.fromPage webLog page categories = CategoryCache.get ctx - page_title = page.title is_page = true |} |> themedView (defaultArg page.template "single-page") next ctx @@ -119,10 +119,12 @@ let router : HttpHandler = choose [ ]) route "/dashboard" >=> Admin.dashboard subRoute "/page" (choose [ - route "s" >=> Admin.listPages 1 - routef "s/page/%i" Admin.listPages - routef "/%s/edit" Admin.editPage - routef "/%s/permalinks" Admin.editPagePermalinks + route "s" >=> Admin.listPages 1 + routef "s/page/%i" Admin.listPages + routef "/%s/edit" Admin.editPage + routef "/%s/permalinks" Admin.editPagePermalinks + routef "/%s/revision/%s/preview" Admin.previewPageRevision + routef "/%s/revisions" Admin.editPageRevisions ]) subRoute "/post" (choose [ route "s" >=> Post.all 1 @@ -155,9 +157,12 @@ let router : HttpHandler = choose [ routef "/%s/delete" Admin.deleteCategory ]) subRoute "/page" (choose [ - route "/save" >=> Admin.savePage - route "/permalinks" >=> Admin.savePagePermalinks - routef "/%s/delete" Admin.deletePage + route "/save" >=> Admin.savePage + route "/permalinks" >=> Admin.savePagePermalinks + routef "/%s/delete" Admin.deletePage + routef "/%s/revision/%s/delete" Admin.deletePageRevision + routef "/%s/revision/%s/restore" Admin.restorePageRevision + routef "/%s/revisions/purge" Admin.purgePageRevisions ]) subRoute "/post" (choose [ route "/save" >=> Post.save diff --git a/src/MyWebLog/Handlers/Upload.fs b/src/MyWebLog/Handlers/Upload.fs index af2783d..1cec558 100644 --- a/src/MyWebLog/Handlers/Upload.fs +++ b/src/MyWebLog/Handlers/Upload.fs @@ -118,8 +118,8 @@ let list : HttpHandler = fun next ctx -> task { return! Hash.FromAnonymousObject {| - csrf = csrfToken ctx page_title = "Uploaded Files" + csrf = ctx.CsrfTokenSet files = allFiles |} |> viewForTheme "admin" "upload-list" next ctx @@ -129,17 +129,16 @@ let list : HttpHandler = fun next ctx -> task { let showNew : HttpHandler = fun next ctx -> task { return! Hash.FromAnonymousObject {| - csrf = csrfToken ctx - destination = UploadDestination.toString ctx.WebLog.uploads page_title = "Upload a File" + csrf = ctx.CsrfTokenSet + destination = UploadDestination.toString ctx.WebLog.uploads |} |> viewForTheme "admin" "upload-new" next ctx } /// Redirect to the upload list -let showUploads : HttpHandler = fun next ctx -> task { - return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/uploads")) next ctx -} +let showUploads : HttpHandler = + redirectToGet "admin/uploads" // POST /admin/upload/save let save : HttpHandler = fun next ctx -> task { diff --git a/src/MyWebLog/Handlers/User.fs b/src/MyWebLog/Handlers/User.fs index ed08809..b0f176c 100644 --- a/src/MyWebLog/Handlers/User.fs +++ b/src/MyWebLog/Handlers/User.fs @@ -13,6 +13,7 @@ let hashedPassword (plainText : string) (email : string) (salt : Guid) = open DotLiquid open Giraffe +open MyWebLog open MyWebLog.ViewModels // GET /user/log-on @@ -26,9 +27,9 @@ let logOn returnUrl : HttpHandler = fun next ctx -> task { | false -> None return! Hash.FromAnonymousObject {| - model = { LogOnModel.empty with returnTo = returnTo } page_title = "Log On" - csrf = csrfToken ctx + csrf = ctx.CsrfTokenSet + model = { LogOnModel.empty with returnTo = returnTo } |} |> viewForTheme "admin" "log-on" next ctx } @@ -36,7 +37,6 @@ let logOn returnUrl : HttpHandler = fun next ctx -> task { open System.Security.Claims open Microsoft.AspNetCore.Authentication open Microsoft.AspNetCore.Authentication.Cookies -open MyWebLog // POST /user/log-on let doLogOn : HttpHandler = fun next ctx -> task { @@ -56,8 +56,7 @@ let doLogOn : HttpHandler = fun next ctx -> task { AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow)) do! addMessage ctx { UserMessage.success with message = $"Logged on successfully | Welcome to {webLog.name}!" } - return! redirectToGet (defaultArg model.returnTo (WebLog.relativeUrl webLog (Permalink "admin/dashboard"))) - next ctx + return! redirectToGet (defaultArg model.returnTo "admin/dashboard") next ctx | _ -> do! addMessage ctx { UserMessage.error with message = "Log on attempt unsuccessful" } return! logOn model.returnTo next ctx @@ -67,19 +66,19 @@ let doLogOn : HttpHandler = fun next ctx -> task { let logOff : HttpHandler = fun next ctx -> task { do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme do! addMessage ctx { UserMessage.info with message = "Log off successful" } - return! redirectToGet (WebLog.relativeUrl ctx.WebLog Permalink.empty) next ctx + return! redirectToGet "" next ctx } /// Display the user edit page, with information possibly filled in let private showEdit (hash : Hash) : HttpHandler = fun next ctx -> task { hash.Add ("page_title", "Edit Your Information") - hash.Add ("csrf", csrfToken ctx) + hash.Add ("csrf", ctx.CsrfTokenSet) return! viewForTheme "admin" "user-edit" next ctx hash } // GET /admin/user/edit let edit : HttpHandler = fun next ctx -> task { - match! ctx.Data.WebLogUser.findById (userId ctx) ctx.WebLog.id with + match! ctx.Data.WebLogUser.findById ctx.UserId ctx.WebLog.id with | Some user -> return! showEdit (Hash.FromAnonymousObject {| model = EditUserModel.fromUser user |}) next ctx | None -> return! Error.notFound next ctx } @@ -89,7 +88,7 @@ let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { let! model = ctx.BindFormAsync () if model.newPassword = model.newPasswordConfirm then let data = ctx.Data - match! data.WebLogUser.findById (userId ctx) ctx.WebLog.id with + match! data.WebLogUser.findById ctx.UserId ctx.WebLog.id with | Some user -> let pw, salt = if model.newPassword = "" then @@ -108,7 +107,7 @@ let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { do! data.WebLogUser.update user let pwMsg = if model.newPassword = "" then "" else " and updated your password" do! addMessage ctx { UserMessage.success with message = $"Saved your information{pwMsg} successfully" } - return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/user/edit")) next ctx + return! redirectToGet "admin/user/edit" next ctx | None -> return! Error.notFound next ctx else do! addMessage ctx { UserMessage.error with message = "Passwords did not match; no updates made" } diff --git a/src/admin-theme/page-edit.liquid b/src/admin-theme/page-edit.liquid index e5db77e..f1253fe 100644 --- a/src/admin-theme/page-edit.liquid +++ b/src/admin-theme/page-edit.liquid @@ -16,8 +16,15 @@ value="{{ model.permalink }}"> {%- if model.page_id != "new" %} - {%- capture perm_edit %}admin/page/{{ model.page_id }}/permalinks{% endcapture -%} - Manage Permalinks + + + Manage Permalinks + + + + Manage Revisions + + {% endif -%}
diff --git a/src/admin-theme/revisions.liquid b/src/admin-theme/revisions.liquid index 4fe5bcf..0b01409 100644 --- a/src/admin-theme/revisions.liquid +++ b/src/admin-theme/revisions.liquid @@ -1,6 +1,6 @@ 

{{ page_title }}

{%- assign revision_count = model.revisions | size -%} - {%- capture rev_url_base %}admin/{{ model.entity }}/{{ model.id }}/revision{% endcapture -%} + {%- assign rev_url_base = "admin/" | append: model.entity | append: "/" | append: model.id | append: "/revision" -%} {%- if revision_count > 1 %} - {% capture delete_all %}{{ rev_url_base }}s/purge{% endcapture %}
- @@ -29,28 +30,40 @@
{%- endif %} {% for rev in model.revisions %} -
-
- {{ rev.as_of_local | date: "MMMM d, yyyy" }} at {{ rev.as_of_local | date: "h:mmaa" | downcase }} - {{ ref.format }} + {%- assign as_of_string = rev.as_of | date: "o" -%} + {%- assign as_of_id = "rev_" | append: as_of_string | replace: "\.", "_" | replace: ":", "-" -%} +
+
+ {{ rev.as_of_local | date: "MMMM d, yyyy" }} at {{ rev.as_of_local | date: "h:mmtt" | downcase }} + {{ rev.format }} {%- if forloop.first %} Current Revision {%- endif %}
{% unless forloop.first %} - {%- capture rev_url_prefix %}{{ rev_url_base }}/{{ rev.as_of | date: "o" }}{% endcapture -%} + {%- assign rev_url_prefix = rev_url_base | append: "/" | append: as_of_string -%} + {%- assign rev_restore = rev_url_prefix | append: "/restore" | relative_link -%} + {%- assign rev_delete = rev_url_prefix | append: "/delete" | relative_link -%} - Preview + + Preview + - {%- capture rev_restore %}{{ rev_url_prefix }}/restore{% endcapture -%} - {%- capture rev_restore_link %}{{ rev_restore | relative_link }}{% endcapture -%} - Restore as Current + Restore as Current - {%- capture rev_del %}{{ rev_url_prefix }}/delete{% endcapture -%} - {%- capture rev_del_link %}{{ rev_del | relative_link }}{% endcapture -%} - Delete + + Delete + {% endunless %}
+ {% unless forloop.first %} +
+
+ preview not loaded +
+
+ {% endunless %}
{% endfor %}
diff --git a/src/admin-theme/wwwroot/admin.css b/src/admin-theme/wwwroot/admin.css index 3802723..2e9592c 100644 --- a/src/admin-theme/wwwroot/admin.css +++ b/src/admin-theme/wwwroot/admin.css @@ -85,3 +85,6 @@ a.text-danger:link:hover, a.text-danger:visited:hover { background-color: var(--light-accent); color: var(--dark-gray); } +.mwl-revision-preview { + max-height: 90vh; +}