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
This commit is contained in:
Daniel J. Summers 2022-07-15 22:51:51 -04:00
parent d667d09372
commit 039d09aed5
13 changed files with 233 additions and 155 deletions

View File

@ -405,11 +405,6 @@ module WebLog =
TimeZoneInfo.ConvertTimeFromUtc TimeZoneInfo.ConvertTimeFromUtc
(DateTime (date.Ticks, DateTimeKind.Utc), TimeZoneInfo.FindSystemTimeZoneById webLog.timeZone) (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 /// A user of the web log
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]

View File

@ -7,16 +7,29 @@ open MyWebLog.Data
[<AutoOpen>] [<AutoOpen>]
module Extensions = module Extensions =
open System.Security.Claims
open Microsoft.AspNetCore.Antiforgery
open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.DependencyInjection
type HttpContext with 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<IAntiforgery> ()
/// The cross-site request forgery token set for this request
member this.CsrfTokenSet = this.AntiForgery.GetAndStoreTokens this
/// The data implementation /// The data implementation
member this.Data = this.RequestServices.GetRequiredService<IData> () member this.Data = this.RequestServices.GetRequiredService<IData> ()
/// 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 open System.Collections.Concurrent
/// <summary> /// <summary>

View File

@ -39,10 +39,10 @@ let dashboard : HttpHandler = fun next ctx -> task {
let listCategories : HttpHandler = fun next ctx -> task { let listCategories : HttpHandler = 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 = Hash.FromAnonymousObject {| let hash = Hash.FromAnonymousObject {|
page_title = "Categories"
csrf = ctx.CsrfTokenSet
web_log = ctx.WebLog web_log = ctx.WebLog
categories = CategoryCache.get ctx categories = CategoryCache.get ctx
page_title = "Categories"
csrf = csrfToken ctx
|} |}
hash.Add ("category_list", catListTemplate.Render hash) hash.Add ("category_list", catListTemplate.Render hash)
return! viewForTheme "admin" "category-list" next ctx hash return! viewForTheme "admin" "category-list" next ctx hash
@ -53,7 +53,7 @@ let listCategoriesBare : HttpHandler = fun next ctx -> task {
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
categories = CategoryCache.get ctx categories = CategoryCache.get ctx
csrf = csrfToken ctx csrf = ctx.CsrfTokenSet
|} |}
|> bareForTheme "admin" "category-list-body" next ctx |> bareForTheme "admin" "category-list-body" next ctx
} }
@ -73,9 +73,9 @@ let editCategory catId : HttpHandler = fun next ctx -> task {
| Some (title, cat) -> | Some (title, cat) ->
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
csrf = csrfToken ctx
model = EditCategoryModel.fromCategory cat
page_title = title page_title = title
csrf = ctx.CsrfTokenSet
model = EditCategoryModel.fromCategory cat
categories = CategoryCache.get ctx categories = CategoryCache.get ctx
|} |}
|> bareForTheme "admin" "category-edit" next 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 let! pages = ctx.Data.Page.findPageOfPages webLog.id pageNbr
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
csrf = csrfToken ctx
pages = pages |> List.map (DisplayPage.fromPageMinimal webLog)
page_title = "Pages" page_title = "Pages"
csrf = ctx.CsrfTokenSet
pages = pages |> List.map (DisplayPage.fromPageMinimal webLog)
page_nbr = pageNbr page_nbr = pageNbr
prev_page = if pageNbr = 2 then "" else $"/page/{pageNbr - 1}" prev_page = if pageNbr = 2 then "" else $"/page/{pageNbr - 1}"
next_page = $"/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" let! templates = templatesForTheme ctx "page"
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
csrf = csrfToken ctx page_title = title
csrf = ctx.CsrfTokenSet
model = model model = model
metadata = Array.zip model.metaNames model.metaValues metadata = Array.zip model.metaNames model.metaValues
|> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]) |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |])
page_title = title
templates = templates templates = templates
|} |}
|> viewForTheme "admin" "page-edit" next ctx |> viewForTheme "admin" "page-edit" next ctx
| None -> return! Error.notFound 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 // GET /admin/page/{id}/permalinks
let editPagePermalinks pgId : HttpHandler = fun next ctx -> task { let editPagePermalinks pgId : HttpHandler = 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 -> | Some pg ->
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
csrf = csrfToken ctx page_title = "Manage Prior Permalinks"
csrf = ctx.CsrfTokenSet
model = ManagePermalinksModel.fromPage pg model = ManagePermalinksModel.fromPage pg
page_title = $"Manage Prior Permalinks"
|} |}
|> viewForTheme "admin" "permalinks" next ctx |> viewForTheme "admin" "permalinks" next ctx
| None -> return! Error.notFound 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 match! ctx.Data.Page.updatePriorPermalinks (PageId model.id) webLog.id links with
| true -> | true ->
do! addMessage ctx { UserMessage.success with message = "Page permalinks saved successfully" } 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 | false -> return! Error.notFound next ctx
} }
@ -197,27 +208,74 @@ let editPageRevisions pgId : HttpHandler = fun next ctx -> task {
| Some pg -> | Some pg ->
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
csrf = csrfToken ctx page_title = "Manage Page Revisions"
csrf = ctx.CsrfTokenSet
model = ManageRevisionsModel.fromPage webLog pg model = ManageRevisionsModel.fromPage webLog pg
page_title = $"Manage Page Permalinks"
|} |}
|> viewForTheme "admin" "revisions" next ctx |> viewForTheme "admin" "revisions" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// POST /admin/page/{id}/delete // GET /admin/page/{id}/revisions/purge
let deletePage pgId : HttpHandler = fun next ctx -> task { let purgePageRevisions pgId : HttpHandler = fun next ctx -> task {
let webLog = ctx.WebLog let webLog = ctx.WebLog
match! ctx.Data.Page.delete (PageId pgId) webLog.id with let data = ctx.Data
| true -> match! data.Page.findFullById (PageId pgId) webLog.id with
do! PageListCache.update ctx | Some pg ->
do! addMessage ctx { UserMessage.success with message = "Page deleted successfully" } do! data.Page.update { pg with revisions = [ List.head pg.revisions ] }
| false -> do! addMessage ctx { UserMessage.error with message = "Page not found; nothing deleted" } do! addMessage ctx { UserMessage.success with message = "Prior revisions purged successfully" }
return! redirectToGet (WebLog.relativeUrl webLog (Permalink "admin/pages")) next ctx 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 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" #nowarn "3511"
// POST /admin/page/save // POST /admin/page/save
@ -233,7 +291,7 @@ let savePage : HttpHandler = fun next ctx -> task {
{ Page.empty with { Page.empty with
id = PageId.create () id = PageId.create ()
webLogId = webLog.id webLogId = webLog.id
authorId = userId ctx authorId = ctx.UserId
publishedOn = now publishedOn = now
} }
| pgId -> return! data.Page.findFullById (PageId pgId) webLog.id | 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 do! (if model.pageId = "new" then data.Page.add else data.Page.update) page
if updateList then do! PageListCache.update ctx if updateList then do! PageListCache.update ctx
do! addMessage ctx { UserMessage.success with message = "Page saved successfully" } do! addMessage ctx { UserMessage.success with message = "Page saved successfully" }
return! return! redirectToGet $"admin/page/{PageId.toString page.id}/edit" next ctx
redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/page/{PageId.toString page.id}/edit")) next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// -- TAG MAPPINGS -- // -- TAG MAPPINGS --
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 Hash.FromAnonymousObject {| return Hash.FromAnonymousObject {|
csrf = ctx.CsrfTokenSet
web_log = ctx.WebLog web_log = ctx.WebLog
csrf = csrfToken ctx
mappings = mappings mappings = mappings
mapping_ids = mappings |> List.map (fun it -> { name = it.tag; value = TagMapId.toString it.id }) 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 -> | Some tm ->
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
csrf = csrfToken ctx
model = EditTagMapModel.fromMapping tm
page_title = if isNew then "Add Tag Mapping" else $"Mapping for {tm.tag} Tag" 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 |> bareForTheme "admin" "tag-mapping-edit" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
@ -361,8 +416,8 @@ open MyWebLog.Data
let themeUpdatePage : HttpHandler = fun next ctx -> task { let themeUpdatePage : HttpHandler = fun next ctx -> task {
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
csrf = csrfToken ctx
page_title = "Upload Theme" page_title = "Upload Theme"
csrf = ctx.CsrfTokenSet
|} |}
|> viewForTheme "admin" "upload-theme" next ctx |> viewForTheme "admin" "upload-theme" next ctx
} }
@ -457,13 +512,13 @@ let updateTheme : HttpHandler = fun next ctx -> task {
do! ThemeAssetCache.refreshTheme (ThemeId themeName) data do! ThemeAssetCache.refreshTheme (ThemeId themeName) data
TemplateCache.invalidateTheme themeName TemplateCache.invalidateTheme themeName
do! addMessage ctx { UserMessage.success with message = "Theme updated successfully" } 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 _ -> | Ok _ ->
do! addMessage ctx { UserMessage.error with message = "You may not replace the admin theme" } 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 -> | Error message ->
do! addMessage ctx { UserMessage.error with message = 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 else
return! RequestErrors.BAD_REQUEST "Bad request" next ctx return! RequestErrors.BAD_REQUEST "Bad request" next ctx
} }
@ -480,11 +535,12 @@ let settings : HttpHandler = fun next ctx -> task {
let! themes = data.Theme.all () let! themes = data.Theme.all ()
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
csrf = csrfToken ctx page_title = "Web Log Settings"
model = SettingsModel.fromWebLog webLog csrf = ctx.CsrfTokenSet
pages = web_log = webLog
seq { model = SettingsModel.fromWebLog webLog
KeyValuePair.Create ("posts", "- First Page of Posts -") pages = 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))
@ -495,12 +551,10 @@ let settings : HttpHandler = fun next ctx -> task {
|> 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 = 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")
|] |]
web_log = webLog
page_title = "Web Log Settings"
|} |}
|> viewForTheme "admin" "settings" next ctx |> 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)) 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" } 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 | None -> return! Error.notFound next ctx
} }

View File

@ -2,21 +2,20 @@
module MyWebLog.Handlers.Error module MyWebLog.Handlers.Error
open System.Net open System.Net
open System.Threading.Tasks
open Giraffe open Giraffe
open Microsoft.AspNetCore.Http
open MyWebLog open MyWebLog
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response /// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
let notAuthorized : HttpHandler = fun next ctx -> task { let notAuthorized : HttpHandler =
if ctx.Request.Method = "GET" then handleContext (fun ctx ->
let returnUrl = WebUtility.UrlEncode ctx.Request.Path if ctx.Request.Method = "GET" then
return! let returnUrl = WebUtility.UrlEncode ctx.Request.Path
redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink $"user/log-on?returnUrl={returnUrl}")) next ctx redirectTo false (WebLog.relativeUrl ctx.WebLog (Permalink $"user/log-on?returnUrl={returnUrl}"))
else earlyReturn ctx
return! (setStatusCode 401 >=> fun _ _ -> Task.FromResult<HttpContext option> None) next 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 /// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
let notFound : HttpHandler = let notFound : HttpHandler = fun _ ->
setStatusCode 404 >=> text "Not found" (setStatusCode 404 >=> text "Not found") earlyReturn

View File

@ -423,9 +423,9 @@ let editSettings : HttpHandler = fun next ctx -> task {
webLog.rss.customFeeds webLog.rss.customFeeds
|> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx)) |> List.map (DisplayCustomFeed.fromFeed (CategoryCache.get ctx))
|> Array.ofList |> Array.ofList
return! Hash.FromAnonymousObject return! Hash.FromAnonymousObject {|
{| csrf = csrfToken ctx
page_title = "RSS Settings" page_title = "RSS Settings"
csrf = ctx.CsrfTokenSet
model = EditRssModel.fromRssOptions webLog.rss model = EditRssModel.fromRssOptions webLog.rss
custom_feeds = feeds custom_feeds = feeds
|} |}
@ -442,7 +442,7 @@ let saveSettings : HttpHandler = fun next ctx -> task {
do! data.WebLog.updateRssOptions webLog do! data.WebLog.updateRssOptions webLog
WebLogCache.set webLog WebLogCache.set webLog
do! addMessage ctx { UserMessage.success with message = "RSS settings updated successfully" } 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 | 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) | _ -> ctx.WebLog.rss.customFeeds |> List.tryFind (fun f -> f.id = CustomFeedId feedId)
match customFeed with match customFeed with
| Some f -> | Some f ->
return! Hash.FromAnonymousObject return! Hash.FromAnonymousObject {|
{| csrf = csrfToken ctx
page_title = $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed""" page_title = $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed"""
csrf = ctx.CsrfTokenSet
model = EditCustomFeedModel.fromFeed f model = EditCustomFeedModel.fromFeed f
categories = CategoryCache.get ctx categories = CategoryCache.get ctx
medium_values = [| medium_values = [|
@ -494,8 +494,7 @@ let saveCustomFeed : HttpHandler = fun next ctx -> task {
UserMessage.success with UserMessage.success with
message = $"""Successfully {if model.id = "new" then "add" else "sav"}ed custom feed""" 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 $"admin/settings/rss/{CustomFeedId.toString feed.id}/edit" next ctx
return! redirectToGet (WebLog.relativeUrl webLog (Permalink nextUrl)) next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound 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" } do! addMessage ctx { UserMessage.success with message = "Custom feed deleted successfully" }
else else
do! addMessage ctx { UserMessage.warning with message = "Custom feed not found; no action taken" } 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 | None -> return! Error.notFound next ctx
} }

View File

@ -101,7 +101,7 @@ let private populateHash hash ctx = task {
} }
/// 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 theme template next ctx = fun (hash : Hash) -> task { let viewForTheme theme template next ctx (hash : Hash) = task {
do! populateHash hash ctx do! populateHash hash ctx
// 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;
@ -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 /// 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 do! populateHash hash ctx
// Bare templates are rendered with layout-bare if not (hash.ContainsKey "content") then
let! contentTemplate = TemplateCache.get theme template ctx.Data let! contentTemplate = TemplateCache.get theme template ctx.Data
hash.Add ("content", contentTemplate.Render hash) hash.Add ("content", contentTemplate.Render hash)
// 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
// add messages as HTTP headers // 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 /// Return a view for the web log's default theme
let themedView template next ctx = fun (hash : Hash) -> task { let themedView template next ctx hash =
return! viewForTheme (deriveWebLogFromHash hash ctx).themePath 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 /// 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 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<IAntiforgery> ()
/// 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 /// Validate the cross-site request forgery token in the current request
let validateCsrf : HttpHandler = fun next ctx -> task { let validateCsrf : HttpHandler = fun next ctx -> task {
match! (antiForgery ctx).IsRequestValidAsync ctx with match! ctx.AntiForgery.IsRequestValidAsync ctx with
| true -> return! next ctx | 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 /// Require a user to be logged on
@ -226,6 +212,13 @@ let getCategoryIds slug ctx =
|> Seq.map (fun c -> CategoryId c.id) |> Seq.map (fun c -> CategoryId c.id)
|> List.ofSeq |> 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 open Microsoft.Extensions.Logging
/// Log level for debugging /// Log level for debugging

View File

@ -198,9 +198,9 @@ let home : HttpHandler = fun next ctx -> task {
| Some page -> | Some page ->
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
page_title = page.title
page = DisplayPage.fromPage webLog page page = DisplayPage.fromPage webLog page
categories = CategoryCache.get ctx categories = CategoryCache.get ctx
page_title = page.title
is_home = true is_home = true
|} |}
|> themedView (defaultArg page.template "single-page") next ctx |> 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! posts = data.Post.findPageOfPosts webLog.id pageNbr 25
let! hash = preparePostList webLog posts AdminList "" pageNbr 25 ctx data let! hash = preparePostList webLog posts AdminList "" pageNbr 25 ctx data
hash.Add ("page_title", "Posts") hash.Add ("page_title", "Posts")
hash.Add ("csrf", csrfToken ctx) hash.Add ("csrf", ctx.CsrfTokenSet)
return! viewForTheme "admin" "post-list" next ctx hash 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 let model = EditPostModel.fromPost webLog post
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
csrf = csrfToken ctx page_title = title
csrf = ctx.CsrfTokenSet
model = model model = model
metadata = Array.zip model.metaNames model.metaValues metadata = Array.zip model.metaNames model.metaValues
|> Array.mapi (fun idx (name, value) -> [| string idx; name; value |]) |> Array.mapi (fun idx (name, value) -> [| string idx; name; value |])
page_title = title
templates = templates templates = templates
categories = cats categories = cats
explicit_values = [| explicit_values = [|
@ -262,9 +262,9 @@ let editPermalinks postId : HttpHandler = fun next ctx -> task {
| Some post -> | Some post ->
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
csrf = csrfToken ctx page_title = "Manage Prior Permalinks"
csrf = ctx.CsrfTokenSet
model = ManagePermalinksModel.fromPost post model = ManagePermalinksModel.fromPost post
page_title = $"Manage Prior Permalinks"
|} |}
|> viewForTheme "admin" "permalinks" next ctx |> viewForTheme "admin" "permalinks" next ctx
| None -> return! Error.notFound 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 match! ctx.Data.Post.updatePriorPermalinks (PostId model.id) webLog.id links with
| true -> | true ->
do! addMessage ctx { UserMessage.success with message = "Post permalinks saved successfully" } 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 | 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 match! ctx.Data.Post.delete (PostId postId) webLog.id with
| true -> do! addMessage ctx { UserMessage.success with message = "Post deleted successfully" } | true -> do! addMessage ctx { UserMessage.success with message = "Post deleted successfully" }
| false -> do! addMessage ctx { UserMessage.error with message = "Post not found; nothing deleted" } | 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" #nowarn "3511"
@ -306,7 +306,7 @@ let save : HttpHandler = fun next ctx -> task {
{ Post.empty with { Post.empty with
id = PostId.create () id = PostId.create ()
webLogId = webLog.id webLogId = webLog.id
authorId = userId ctx authorId = ctx.UserId
} }
| postId -> return! data.Post.findFullById (PostId postId) webLog.id | postId -> return! data.Post.findFullById (PostId postId) webLog.id
} }
@ -323,7 +323,7 @@ let save : HttpHandler = fun next ctx -> task {
let post = let post =
match model.setPublished with match model.setPublished with
| true -> | true ->
let dt = WebLog.utcTime webLog model.pubOverride.Value let dt = parseToUtc (model.pubOverride.Value.ToString "o")
match model.setUpdated with match model.setUpdated with
| true -> | true ->
{ post with { post with
@ -342,7 +342,6 @@ let save : HttpHandler = fun next ctx -> task {
|> List.length = List.length pst.Value.categoryIds) then |> List.length = List.length pst.Value.categoryIds) then
do! CategoryCache.update ctx do! CategoryCache.update ctx
do! addMessage ctx { UserMessage.success with message = "Post saved successfully" } do! addMessage ctx { UserMessage.success with message = "Post saved successfully" }
return! return! redirectToGet $"admin/post/{PostId.toString post.id}/edit" next ctx
redirectToGet (WebLog.relativeUrl webLog (Permalink $"admin/post/{PostId.toString post.id}/edit")) next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }

View File

@ -40,9 +40,9 @@ module CatchAll =
debug (fun () -> $"Found page by permalink") debug (fun () -> $"Found page by permalink")
yield fun next ctx -> yield fun next ctx ->
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
page_title = page.title
page = DisplayPage.fromPage webLog page page = DisplayPage.fromPage webLog page
categories = CategoryCache.get ctx categories = CategoryCache.get ctx
page_title = page.title
is_page = true is_page = true
|} |}
|> themedView (defaultArg page.template "single-page") next ctx |> themedView (defaultArg page.template "single-page") next ctx
@ -119,10 +119,12 @@ let router : HttpHandler = choose [
]) ])
route "/dashboard" >=> Admin.dashboard route "/dashboard" >=> Admin.dashboard
subRoute "/page" (choose [ subRoute "/page" (choose [
route "s" >=> Admin.listPages 1 route "s" >=> Admin.listPages 1
routef "s/page/%i" Admin.listPages routef "s/page/%i" Admin.listPages
routef "/%s/edit" Admin.editPage routef "/%s/edit" Admin.editPage
routef "/%s/permalinks" Admin.editPagePermalinks routef "/%s/permalinks" Admin.editPagePermalinks
routef "/%s/revision/%s/preview" Admin.previewPageRevision
routef "/%s/revisions" Admin.editPageRevisions
]) ])
subRoute "/post" (choose [ subRoute "/post" (choose [
route "s" >=> Post.all 1 route "s" >=> Post.all 1
@ -155,9 +157,12 @@ let router : HttpHandler = choose [
routef "/%s/delete" Admin.deleteCategory routef "/%s/delete" Admin.deleteCategory
]) ])
subRoute "/page" (choose [ subRoute "/page" (choose [
route "/save" >=> Admin.savePage route "/save" >=> Admin.savePage
route "/permalinks" >=> Admin.savePagePermalinks route "/permalinks" >=> Admin.savePagePermalinks
routef "/%s/delete" Admin.deletePage 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 [ subRoute "/post" (choose [
route "/save" >=> Post.save route "/save" >=> Post.save

View File

@ -118,8 +118,8 @@ let list : HttpHandler = fun next ctx -> task {
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
csrf = csrfToken ctx
page_title = "Uploaded Files" page_title = "Uploaded Files"
csrf = ctx.CsrfTokenSet
files = allFiles files = allFiles
|} |}
|> viewForTheme "admin" "upload-list" next ctx |> viewForTheme "admin" "upload-list" next ctx
@ -129,17 +129,16 @@ let list : HttpHandler = fun next ctx -> task {
let showNew : HttpHandler = fun next ctx -> task { let showNew : HttpHandler = fun next ctx -> task {
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
csrf = csrfToken ctx
destination = UploadDestination.toString ctx.WebLog.uploads
page_title = "Upload a File" page_title = "Upload a File"
csrf = ctx.CsrfTokenSet
destination = UploadDestination.toString ctx.WebLog.uploads
|} |}
|> viewForTheme "admin" "upload-new" next ctx |> viewForTheme "admin" "upload-new" next ctx
} }
/// Redirect to the upload list /// Redirect to the upload list
let showUploads : HttpHandler = fun next ctx -> task { let showUploads : HttpHandler =
return! redirectToGet (WebLog.relativeUrl ctx.WebLog (Permalink "admin/uploads")) next ctx redirectToGet "admin/uploads"
}
// POST /admin/upload/save // POST /admin/upload/save
let save : HttpHandler = fun next ctx -> task { let save : HttpHandler = fun next ctx -> task {

View File

@ -13,6 +13,7 @@ let hashedPassword (plainText : string) (email : string) (salt : Guid) =
open DotLiquid open DotLiquid
open Giraffe open Giraffe
open MyWebLog
open MyWebLog.ViewModels open MyWebLog.ViewModels
// GET /user/log-on // GET /user/log-on
@ -26,9 +27,9 @@ let logOn returnUrl : HttpHandler = fun next ctx -> task {
| false -> None | false -> None
return! return!
Hash.FromAnonymousObject {| Hash.FromAnonymousObject {|
model = { LogOnModel.empty with returnTo = returnTo }
page_title = "Log On" page_title = "Log On"
csrf = csrfToken ctx csrf = ctx.CsrfTokenSet
model = { LogOnModel.empty with returnTo = returnTo }
|} |}
|> viewForTheme "admin" "log-on" next ctx |> viewForTheme "admin" "log-on" next ctx
} }
@ -36,7 +37,6 @@ let logOn returnUrl : HttpHandler = fun next ctx -> task {
open System.Security.Claims open System.Security.Claims
open Microsoft.AspNetCore.Authentication open Microsoft.AspNetCore.Authentication
open Microsoft.AspNetCore.Authentication.Cookies open Microsoft.AspNetCore.Authentication.Cookies
open MyWebLog
// POST /user/log-on // POST /user/log-on
let doLogOn : HttpHandler = fun next ctx -> task { let doLogOn : HttpHandler = fun next ctx -> task {
@ -56,8 +56,7 @@ let doLogOn : HttpHandler = fun next ctx -> task {
AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow)) AuthenticationProperties (IssuedUtc = DateTimeOffset.UtcNow))
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with message = $"Logged on successfully | Welcome to {webLog.name}!" } { UserMessage.success with message = $"Logged on successfully | Welcome to {webLog.name}!" }
return! redirectToGet (defaultArg model.returnTo (WebLog.relativeUrl webLog (Permalink "admin/dashboard"))) return! redirectToGet (defaultArg model.returnTo "admin/dashboard") next ctx
next ctx
| _ -> | _ ->
do! addMessage ctx { UserMessage.error with message = "Log on attempt unsuccessful" } do! addMessage ctx { UserMessage.error with message = "Log on attempt unsuccessful" }
return! logOn model.returnTo next ctx return! logOn model.returnTo next ctx
@ -67,19 +66,19 @@ let doLogOn : HttpHandler = fun next ctx -> task {
let logOff : HttpHandler = fun next ctx -> task { let logOff : HttpHandler = fun next ctx -> task {
do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme do! ctx.SignOutAsync CookieAuthenticationDefaults.AuthenticationScheme
do! addMessage ctx { UserMessage.info with message = "Log off successful" } 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 /// Display the user edit page, with information possibly filled in
let private showEdit (hash : Hash) : HttpHandler = fun next ctx -> task { let private showEdit (hash : Hash) : HttpHandler = fun next ctx -> task {
hash.Add ("page_title", "Edit Your Information") 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 return! viewForTheme "admin" "user-edit" next ctx hash
} }
// GET /admin/user/edit // GET /admin/user/edit
let edit : HttpHandler = fun next ctx -> task { 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 | Some user -> return! showEdit (Hash.FromAnonymousObject {| model = EditUserModel.fromUser user |}) next ctx
| None -> return! Error.notFound 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<EditUserModel> () let! model = ctx.BindFormAsync<EditUserModel> ()
if model.newPassword = model.newPasswordConfirm then if model.newPassword = model.newPasswordConfirm then
let data = ctx.Data 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 -> | Some user ->
let pw, salt = let pw, salt =
if model.newPassword = "" then if model.newPassword = "" then
@ -108,7 +107,7 @@ let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
do! data.WebLogUser.update user do! data.WebLogUser.update user
let pwMsg = if model.newPassword = "" then "" else " and updated your password" let pwMsg = if model.newPassword = "" then "" else " and updated your password"
do! addMessage ctx { UserMessage.success with message = $"Saved your information{pwMsg} successfully" } 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 | None -> return! Error.notFound next ctx
else else
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" }

View File

@ -16,8 +16,15 @@
value="{{ model.permalink }}"> value="{{ model.permalink }}">
<label for="permalink">Permalink</label> <label for="permalink">Permalink</label>
{%- if model.page_id != "new" %} {%- if model.page_id != "new" %}
{%- capture perm_edit %}admin/page/{{ model.page_id }}/permalinks{% endcapture -%} <span class="form-text">
<span class="form-text"><a href="{{ perm_edit | relative_link }}">Manage Permalinks</a></span> <a href="{{ "admin/page/" | append: model.page_id | append: "/permalinks" | relative_link }}">
Manage Permalinks
</a>
<span class="text-muted"> &bull; </span>
<a href="{{ "admin/page/" | append: model.page_id | append: "/revisions" | relative_link }}">
Manage Revisions
</a>
</span>
{% endif -%} {% endif -%}
</div> </div>
<div class="mb-2"> <div class="mb-2">

View File

@ -1,6 +1,6 @@
<h2 class="my-3">{{ page_title }}</h2> <h2 class="my-3">{{ page_title }}</h2>
<article> <article>
<form method="post"> <form method="post" hx-target="body">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<input type="hidden" name="id" value="{{ model.id }}"> <input type="hidden" name="id" value="{{ model.id }}">
<div class="container"> <div class="container">
@ -9,19 +9,20 @@
<p style="line-height:1.2rem;"> <p style="line-height:1.2rem;">
<strong>{{ model.current_title }}</strong><br> <strong>{{ model.current_title }}</strong><br>
<small class="text-muted"> <small class="text-muted">
{%- capture back_link %}admin/{{ model.entity }}/{{ model.id }}/edit{% endcapture -%} <a href="{{ "admin/" | append: model.entity | append: "/" | append: model.id | append: "/edit" | relative_link }}">
<a href="{{ back_link | relative_link }}">&laquo; Back to Edit {{ model.entity | capitalize }}</a> &laquo; Back to Edit {{ model.entity | capitalize }}
</a>
</small> </small>
</p> </p>
</div> </div>
</div> </div>
{%- assign revision_count = model.revisions | size -%} {%- 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 %} {%- if revision_count > 1 %}
{% capture delete_all %}{{ rev_url_base }}s/purge{% endcapture %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">
<button type="button" class="btn btn-sm btn-danger" hx-post="{{ delete_all | relative_link }}" <button type="button" class="btn btn-sm btn-danger"
hx-post="{{ rev_url_base | append: "s/purge" | relative_link }}"
hx-confirm="This will remove all revisions but the current one; are you sure this is what you wish to do?"> hx-confirm="This will remove all revisions but the current one; are you sure this is what you wish to do?">
Delete All Prior Revisions Delete All Prior Revisions
</button> </button>
@ -29,28 +30,40 @@
</div> </div>
{%- endif %} {%- endif %}
{% for rev in model.revisions %} {% for rev in model.revisions %}
<div class="row mb-3"> {%- assign as_of_string = rev.as_of | date: "o" -%}
<div class="col"> {%- 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:mmaa" | downcase }} <div id="{{ as_of_id }}" class="row mb-3">
<span class="badge bg-secondary text-uppercase">{{ ref.format }}</span> <div class="col-12 mb-3">
{{ rev.as_of_local | date: "MMMM d, yyyy" }} at {{ rev.as_of_local | date: "h:mmtt" | downcase }}
<span class="badge bg-secondary text-uppercase ms-2">{{ rev.format }}</span>
{%- if forloop.first %} {%- if forloop.first %}
<span class="badge bg-primary text-uppercase ms-2">Current Revision</span> <span class="badge bg-primary text-uppercase ms-2">Current Revision</span>
{%- endif %}<br> {%- endif %}<br>
{% unless forloop.first %} {% 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 -%}
<small> <small>
<a href="TODO">Preview</a> <a href="{{ rev_url_prefix | append: "/preview" | relative_link }}" hx-target="#{{ as_of_id }}_preview">
Preview
</a>
<span class="text-muted"> &bull; </span> <span class="text-muted"> &bull; </span>
{%- capture rev_restore %}{{ rev_url_prefix }}/restore{% endcapture -%} <a href="{{ rev_restore }}" hx-post="{{ rev_restore }}">Restore as Current</a>
{%- capture rev_restore_link %}{{ rev_restore | relative_link }}{% endcapture -%}
<a href="{{ rev_restore_link }}" hx-post="{{ rev_restore_link }}">Restore as Current</a>
<span class="text-muted"> &bull; </span> <span class="text-muted"> &bull; </span>
{%- capture rev_del %}{{ rev_url_prefix }}/delete{% endcapture -%} <a href="{{ rev_delete }}" hx-post="{{ rev_delete }}" hx-target="#{{ as_of_id }}" hx-swap="outerHtml"
{%- capture rev_del_link %}{{ rev_del | relative_link }}{% endcapture -%} class="text-danger">
<a href="{{ rev_del_link }}" hx-post="{{ rev_del_link }}" class="text-danger">Delete</a> Delete
</a>
</small> </small>
{% endunless %} {% endunless %}
</div> </div>
{% unless forloop.first %}
<div class="col-12 mb-3">
<div id="{{ as_of_id }}_preview" class="mwl-revision-preview">
<span class="text-muted fst-italic">preview not loaded</span>
</div>
</div>
{% endunless %}
</div> </div>
{% endfor %} {% endfor %}
</div> </div>

View File

@ -85,3 +85,6 @@ a.text-danger:link:hover, a.text-danger:visited:hover {
background-color: var(--light-accent); background-color: var(--light-accent);
color: var(--dark-gray); color: var(--dark-gray);
} }
.mwl-revision-preview {
max-height: 90vh;
}