Version 2.1 #41

Merged
danieljsummers merged 123 commits from version-2.1 into main 2024-03-27 00:13:28 +00:00
8 changed files with 453 additions and 476 deletions
Showing only changes of commit 8ec84e8680 - Show all commits

File diff suppressed because it is too large Load Diff

View File

@ -44,7 +44,7 @@ module Dashboard =
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash "themes" ( |> addToHash "themes" (
themes themes
|> List.map (DisplayTheme.fromTheme WebLogCache.isThemeInUse) |> List.map (DisplayTheme.FromTheme WebLogCache.isThemeInUse)
|> Array.ofList) |> Array.ofList)
|> addToHash "cached_themes" ( |> addToHash "cached_themes" (
themes themes
@ -87,7 +87,7 @@ module Cache =
do! PageListCache.refresh webLog data do! PageListCache.refresh webLog data
do! CategoryCache.refresh webLog.Id data do! CategoryCache.refresh webLog.Id data
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with Message = "Successfully refresh web log cache for all web logs" } { UserMessage.Success with Message = "Successfully refresh web log cache for all web logs" }
else else
match! data.WebLog.FindById (WebLogId webLogId) with match! data.WebLog.FindById (WebLogId webLogId) with
| Some webLog -> | Some webLog ->
@ -95,9 +95,9 @@ module Cache =
do! PageListCache.refresh webLog data do! PageListCache.refresh webLog data
do! CategoryCache.refresh webLog.Id data do! CategoryCache.refresh webLog.Id data
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with Message = $"Successfully refreshed web log cache for {webLog.Name}" } { UserMessage.Success with Message = $"Successfully refreshed web log cache for {webLog.Name}" }
| None -> | None ->
do! addMessage ctx { UserMessage.error with Message = $"No web log exists with ID {webLogId}" } do! addMessage ctx { UserMessage.Error with Message = $"No web log exists with ID {webLogId}" }
return! toAdminDashboard next ctx return! toAdminDashboard next ctx
} }
@ -108,7 +108,7 @@ module Cache =
TemplateCache.empty () TemplateCache.empty ()
do! ThemeAssetCache.fill data do! ThemeAssetCache.fill data
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with { UserMessage.Success with
Message = "Successfully cleared template cache and refreshed theme asset cache" Message = "Successfully cleared template cache and refreshed theme asset cache"
} }
else else
@ -117,11 +117,11 @@ module Cache =
TemplateCache.invalidateTheme theme.Id TemplateCache.invalidateTheme theme.Id
do! ThemeAssetCache.refreshTheme theme.Id data do! ThemeAssetCache.refreshTheme theme.Id data
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with { UserMessage.Success with
Message = $"Successfully cleared template cache and refreshed theme asset cache for {theme.Name}" Message = $"Successfully cleared template cache and refreshed theme asset cache for {theme.Name}"
} }
| None -> | None ->
do! addMessage ctx { UserMessage.error with Message = $"No theme exists with ID {themeId}" } do! addMessage ctx { UserMessage.Error with Message = $"No theme exists with ID {themeId}" }
return! toAdminDashboard next ctx return! toAdminDashboard next ctx
} }
@ -167,7 +167,7 @@ module Category =
return! return!
hashForPage title hashForPage title
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash ViewContext.Model (EditCategoryModel.fromCategory cat) |> addToHash ViewContext.Model (EditCategoryModel.FromCategory cat)
|> adminBareView "category-edit" next ctx |> adminBareView "category-edit" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -190,7 +190,7 @@ module Category =
} }
do! (if model.IsNew then data.Category.Add else data.Category.Update) updatedCat do! (if model.IsNew then data.Category.Add else data.Category.Update) updatedCat
do! CategoryCache.update ctx do! CategoryCache.update ctx
do! addMessage ctx { UserMessage.success with Message = "Category saved successfully" } do! addMessage ctx { UserMessage.Success with Message = "Category saved successfully" }
return! bare next ctx return! bare next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -207,9 +207,9 @@ module Category =
| ReassignedChildCategories -> | ReassignedChildCategories ->
Some "<em>(Its child categories were reassigned to its parent category)</em>" Some "<em>(Its child categories were reassigned to its parent category)</em>"
| _ -> None | _ -> None
do! addMessage ctx { UserMessage.success with Message = "Category deleted successfully"; Detail = detail } do! addMessage ctx { UserMessage.Success with Message = "Category deleted successfully"; Detail = detail }
| CategoryNotFound -> | CategoryNotFound ->
do! addMessage ctx { UserMessage.error with Message = "Category not found; cannot delete" } do! addMessage ctx { UserMessage.Error with Message = "Category not found; cannot delete" }
return! bare next ctx return! bare next ctx
} }
@ -233,7 +233,7 @@ module RedirectRules =
if idx = -1 then if idx = -1 then
return! return!
hashForPage "Add Redirect Rule" hashForPage "Add Redirect Rule"
|> addToHash "model" (EditRedirectRuleModel.fromRule -1 RedirectRule.Empty) |> addToHash "model" (EditRedirectRuleModel.FromRule -1 RedirectRule.Empty)
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> adminBareView "redirect-edit" next ctx |> adminBareView "redirect-edit" next ctx
else else
@ -243,7 +243,7 @@ module RedirectRules =
else else
return! return!
hashForPage "Edit Redirect Rule" hashForPage "Edit Redirect Rule"
|> addToHash "model" (EditRedirectRuleModel.fromRule idx (List.item idx rules)) |> addToHash "model" (EditRedirectRuleModel.FromRule idx (List.item idx rules))
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> adminBareView "redirect-edit" next ctx |> adminBareView "redirect-edit" next ctx
} }
@ -257,17 +257,17 @@ module RedirectRules =
// POST /admin/settings/redirect-rules/[index] // POST /admin/settings/redirect-rules/[index]
let save idx : HttpHandler = fun next ctx -> task { let save idx : HttpHandler = fun next ctx -> task {
let! model = ctx.BindFormAsync<EditRedirectRuleModel> () let! model = ctx.BindFormAsync<EditRedirectRuleModel>()
let isNew = idx = -1 let isNew = idx = -1
let rules = ctx.WebLog.RedirectRules let rules = ctx.WebLog.RedirectRules
let rule = model.UpdateRule (if isNew then RedirectRule.Empty else List.item idx rules) let rule = model.ToRule()
let newRules = let newRules =
match isNew with match isNew with
| true when model.InsertAtTop -> List.insertAt 0 rule rules | true when model.InsertAtTop -> List.insertAt 0 rule rules
| true -> List.insertAt (rules.Length) rule rules | true -> List.insertAt rules.Length rule rules
| false -> rules |> List.removeAt idx |> List.insertAt idx rule | false -> rules |> List.removeAt idx |> List.insertAt idx rule
do! updateRedirectRules ctx { ctx.WebLog with RedirectRules = newRules } do! updateRedirectRules ctx { ctx.WebLog with RedirectRules = newRules }
do! addMessage ctx { UserMessage.success with Message = "Redirect rule saved successfully" } do! addMessage ctx { UserMessage.Success with Message = "Redirect rule saved successfully" }
return! all next ctx return! all next ctx
} }
@ -300,7 +300,7 @@ module RedirectRules =
else else
let rules = ctx.WebLog.RedirectRules |> List.removeAt idx let rules = ctx.WebLog.RedirectRules |> List.removeAt idx
do! updateRedirectRules ctx { ctx.WebLog with RedirectRules = rules } do! updateRedirectRules ctx { ctx.WebLog with RedirectRules = rules }
do! addMessage ctx { UserMessage.success with Message = "Redirect rule deleted successfully" } do! addMessage ctx { UserMessage.Success with Message = "Redirect rule deleted successfully" }
return! all next ctx return! all next ctx
} }
@ -340,7 +340,7 @@ module TagMapping =
return! return!
hashForPage (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")
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash ViewContext.Model (EditTagMapModel.fromMapping tm) |> addToHash ViewContext.Model (EditTagMapModel.FromMapping tm)
|> adminBareView "tag-mapping-edit" next ctx |> adminBareView "tag-mapping-edit" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -355,7 +355,7 @@ module TagMapping =
match! tagMap with match! tagMap with
| Some tm -> | Some tm ->
do! data.TagMap.Save { tm with Tag = model.Tag.ToLower(); UrlValue = model.UrlValue.ToLower() } do! data.TagMap.Save { tm with Tag = model.Tag.ToLower(); UrlValue = model.UrlValue.ToLower() }
do! addMessage ctx { UserMessage.success with Message = "Tag mapping saved successfully" } do! addMessage ctx { UserMessage.Success with Message = "Tag mapping saved successfully" }
return! all next ctx return! all next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -363,8 +363,8 @@ module TagMapping =
// POST /admin/settings/tag-mapping/{id}/delete // POST /admin/settings/tag-mapping/{id}/delete
let delete tagMapId : HttpHandler = fun next ctx -> task { let delete tagMapId : HttpHandler = fun next ctx -> task {
match! ctx.Data.TagMap.Delete (TagMapId tagMapId) ctx.WebLog.Id with match! ctx.Data.TagMap.Delete (TagMapId tagMapId) ctx.WebLog.Id with
| true -> do! addMessage ctx { UserMessage.success with Message = "Tag mapping deleted successfully" } | true -> do! addMessage ctx { UserMessage.Success with Message = "Tag mapping deleted successfully" }
| false -> do! addMessage ctx { UserMessage.error with Message = "Tag mapping not found; nothing deleted" } | false -> do! addMessage ctx { UserMessage.Error with Message = "Tag mapping not found; nothing deleted" }
return! all next ctx return! all next ctx
} }
@ -384,7 +384,7 @@ module Theme =
return! return!
hashForPage "Themes" hashForPage "Themes"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash "themes" (themes |> List.map (DisplayTheme.fromTheme WebLogCache.isThemeInUse) |> Array.ofList) |> addToHash "themes" (themes |> List.map (DisplayTheme.FromTheme WebLogCache.isThemeInUse) |> Array.ofList)
|> adminBareView "theme-list-body" next ctx |> adminBareView "theme-list-body" next ctx
} }
@ -488,21 +488,21 @@ module Theme =
use file = new FileStream($"{themeId}-theme.zip", FileMode.Create) use file = new FileStream($"{themeId}-theme.zip", FileMode.Create)
do! themeFile.CopyToAsync file do! themeFile.CopyToAsync file
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with { UserMessage.Success with
Message = $"""Theme {if isNew then "add" else "updat"}ed successfully""" Message = $"""Theme {if isNew then "add" else "updat"}ed successfully"""
} }
return! toAdminDashboard next ctx return! toAdminDashboard next ctx
else else
do! addMessage ctx do! addMessage ctx
{ UserMessage.error with { UserMessage.Error with
Message = "Theme exists and overwriting was not requested; nothing saved" Message = "Theme exists and overwriting was not requested; nothing saved"
} }
return! toAdminDashboard next ctx return! toAdminDashboard 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! toAdminDashboard next ctx return! toAdminDashboard next ctx
| Error message -> | Error message ->
do! addMessage ctx { UserMessage.error with Message = message } do! addMessage ctx { UserMessage.Error with Message = message }
return! toAdminDashboard next ctx return! toAdminDashboard next ctx
else return! RequestErrors.BAD_REQUEST "Bad request" next ctx else return! RequestErrors.BAD_REQUEST "Bad request" next ctx
} }
@ -512,11 +512,11 @@ module Theme =
let data = ctx.Data let data = ctx.Data
match themeId with match themeId with
| "admin" | "default" -> | "admin" | "default" ->
do! addMessage ctx { UserMessage.error with Message = $"You may not delete the {themeId} theme" } do! addMessage ctx { UserMessage.Error with Message = $"You may not delete the {themeId} theme" }
return! all next ctx return! all next ctx
| it when WebLogCache.isThemeInUse (ThemeId it) -> | it when WebLogCache.isThemeInUse (ThemeId it) ->
do! addMessage ctx do! addMessage ctx
{ UserMessage.error with { UserMessage.Error with
Message = $"You may not delete the {themeId} theme, as it is currently in use" Message = $"You may not delete the {themeId} theme, as it is currently in use"
} }
return! all next ctx return! all next ctx
@ -525,7 +525,7 @@ module Theme =
| true -> | true ->
let zippedTheme = $"{themeId}-theme.zip" let zippedTheme = $"{themeId}-theme.zip"
if File.Exists zippedTheme then File.Delete zippedTheme if File.Exists zippedTheme then File.Delete zippedTheme
do! addMessage ctx { UserMessage.success with Message = $"Theme ID {themeId} deleted successfully" } do! addMessage ctx { UserMessage.Success with Message = $"Theme ID {themeId} deleted successfully" }
return! all next ctx return! all next ctx
| false -> return! Error.notFound next ctx | false -> return! Error.notFound next ctx
} }
@ -550,7 +550,7 @@ module WebLog =
let! hash = let! hash =
hashForPage "Web Log Settings" hashForPage "Web Log Settings"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash ViewContext.Model (SettingsModel.fromWebLog ctx.WebLog) |> addToHash ViewContext.Model (SettingsModel.FromWebLog ctx.WebLog)
|> addToHash "pages" ( |> addToHash "pages" (
seq { seq {
KeyValuePair.Create("posts", "- First Page of Posts -") KeyValuePair.Create("posts", "- First Page of Posts -")
@ -569,11 +569,11 @@ module WebLog =
KeyValuePair.Create(string Database, "Database") KeyValuePair.Create(string Database, "Database")
KeyValuePair.Create(string Disk, "Disk") KeyValuePair.Create(string Disk, "Disk")
|] |]
|> addToHash "users" (users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList) |> addToHash "users" (users |> List.map (DisplayUser.FromUser ctx.WebLog) |> Array.ofList)
|> addToHash "rss_model" (EditRssModel.fromRssOptions ctx.WebLog.Rss) |> addToHash "rss_model" (EditRssModel.FromRssOptions ctx.WebLog.Rss)
|> addToHash "custom_feeds" ( |> 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)
|> addViewContext ctx |> addViewContext ctx
let! hash' = TagMapping.withTagMappings ctx hash let! hash' = TagMapping.withTagMappings ctx hash
@ -592,7 +592,7 @@ module WebLog =
match! data.WebLog.FindById ctx.WebLog.Id with match! data.WebLog.FindById ctx.WebLog.Id with
| Some webLog -> | Some webLog ->
let oldSlug = webLog.Slug let oldSlug = webLog.Slug
let webLog = model.update webLog let webLog = model.Update webLog
do! data.WebLog.UpdateSettings webLog do! data.WebLog.UpdateSettings webLog
// Update cache // Update cache
@ -604,7 +604,7 @@ module WebLog =
let oldDir = Path.Combine (uploadRoot, oldSlug) let oldDir = Path.Combine (uploadRoot, oldSlug)
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 "admin/settings" next ctx return! redirectToGet "admin/settings" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }

View File

@ -418,7 +418,7 @@ let saveSettings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> t
let webLog = { webLog with Rss = model.UpdateOptions webLog.Rss } let webLog = { webLog with Rss = model.UpdateOptions webLog.Rss }
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 "admin/settings#rss-settings" next ctx return! redirectToGet "admin/settings#rss-settings" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -433,7 +433,7 @@ let editCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun next
| Some f -> | Some f ->
hashForPage $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed""" hashForPage $"""{if feedId = "new" then "Add" else "Edit"} Custom RSS Feed"""
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash ViewContext.Model (EditCustomFeedModel.fromFeed f) |> addToHash ViewContext.Model (EditCustomFeedModel.FromFeed f)
|> addToHash "medium_values" [| |> addToHash "medium_values" [|
KeyValuePair.Create("", "&ndash; Unspecified &ndash;") KeyValuePair.Create("", "&ndash; Unspecified &ndash;")
KeyValuePair.Create(string Podcast, "Podcast") KeyValuePair.Create(string Podcast, "Podcast")
@ -464,7 +464,7 @@ let saveCustomFeed : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx ->
do! data.WebLog.UpdateRssOptions webLog do! data.WebLog.UpdateRssOptions webLog
WebLogCache.set webLog WebLogCache.set webLog
do! addMessage ctx { do! addMessage ctx {
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"""
} }
return! redirectToGet $"admin/settings/rss/{feed.Id}/edit" next ctx return! redirectToGet $"admin/settings/rss/{feed.Id}/edit" next ctx
@ -488,9 +488,9 @@ let deleteCustomFeed feedId : HttpHandler = requireAccess WebLogAdmin >=> fun ne
} }
do! data.WebLog.UpdateRssOptions webLog do! data.WebLog.UpdateRssOptions webLog
WebLogCache.set webLog WebLogCache.set webLog
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 "admin/settings#rss-settings" next ctx return! redirectToGet "admin/settings#rss-settings" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }

View File

@ -252,7 +252,7 @@ module Error =
else else
if isHtmx ctx then if isHtmx ctx then
let messages = [| let messages = [|
{ UserMessage.error with { UserMessage.Error with
Message = $"You are not authorized to access the URL {ctx.Request.Path.Value}" Message = $"You are not authorized to access the URL {ctx.Request.Path.Value}"
} }
|] |]
@ -264,7 +264,7 @@ module Error =
handleContext (fun ctx -> handleContext (fun ctx ->
if isHtmx ctx then if isHtmx ctx then
let messages = [| let messages = [|
{ UserMessage.error with Message = $"The URL {ctx.Request.Path.Value} was not found" } { UserMessage.Error with Message = $"The URL {ctx.Request.Path.Value} was not found" }
|] |]
RequestErrors.notFound (messagesToHeaders messages) earlyReturn ctx RequestErrors.notFound (messagesToHeaders messages) earlyReturn ctx
else RequestErrors.NOT_FOUND "Not found" earlyReturn ctx) else RequestErrors.NOT_FOUND "Not found" earlyReturn ctx)
@ -272,7 +272,7 @@ module Error =
let server message : HttpHandler = let server message : HttpHandler =
handleContext (fun ctx -> handleContext (fun ctx ->
if isHtmx ctx then if isHtmx ctx then
let messages = [| { UserMessage.error with Message = message } |] let messages = [| { UserMessage.Error with Message = message } |]
ServerErrors.internalError (messagesToHeaders messages) earlyReturn ctx ServerErrors.internalError (messagesToHeaders messages) earlyReturn ctx
else ServerErrors.INTERNAL_ERROR message earlyReturn ctx) else ServerErrors.INTERNAL_ERROR message earlyReturn ctx)
@ -351,14 +351,14 @@ let requireAccess level : HttpHandler = fun next ctx -> task {
| Some userLevel when userLevel.HasAccess level -> return! next ctx | Some userLevel when userLevel.HasAccess level -> return! next ctx
| Some userLevel -> | Some userLevel ->
do! addMessage ctx do! addMessage ctx
{ UserMessage.warning with { UserMessage.Warning with
Message = $"The page you tried to access requires {level} privileges" Message = $"The page you tried to access requires {level} privileges"
Detail = Some $"Your account only has {userLevel} privileges" Detail = Some $"Your account only has {userLevel} privileges"
} }
return! Error.notAuthorized next ctx return! Error.notAuthorized next ctx
| None -> | None ->
do! addMessage ctx do! addMessage ctx
{ UserMessage.warning with Message = "The page you tried to access required you to be logged on" } { UserMessage.Warning with Message = "The page you tried to access required you to be logged on" }
return! Error.notAuthorized next ctx return! Error.notAuthorized next ctx
} }

View File

@ -36,7 +36,7 @@ let edit pgId : HttpHandler = requireAccess Author >=> fun next ctx -> task {
} }
match result with match result with
| 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!
hashForPage title hashForPage title
@ -56,8 +56,8 @@ let delete pgId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> ta
match! ctx.Data.Page.Delete (PageId pgId) ctx.WebLog.Id with match! ctx.Data.Page.Delete (PageId pgId) ctx.WebLog.Id with
| true -> | true ->
do! PageListCache.update ctx do! PageListCache.update ctx
do! addMessage ctx { UserMessage.success with Message = "Page deleted successfully" } do! addMessage ctx { UserMessage.Success with Message = "Page deleted successfully" }
| false -> do! addMessage ctx { UserMessage.error with Message = "Page not found; nothing deleted" } | false -> do! addMessage ctx { UserMessage.Error with Message = "Page not found; nothing deleted" }
return! redirectToGet "admin/pages" next ctx return! redirectToGet "admin/pages" next ctx
} }
@ -68,7 +68,7 @@ let editPermalinks pgId : HttpHandler = requireAccess Author >=> fun next ctx ->
return! return!
hashForPage "Manage Prior Permalinks" hashForPage "Manage Prior Permalinks"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash ViewContext.Model (ManagePermalinksModel.fromPage pg) |> addToHash ViewContext.Model (ManagePermalinksModel.FromPage pg)
|> adminView "permalinks" next ctx |> 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
@ -83,7 +83,7 @@ let savePermalinks : HttpHandler = requireAccess Author >=> fun next ctx -> task
let links = model.Prior |> Array.map Permalink |> List.ofArray let links = model.Prior |> Array.map Permalink |> List.ofArray
match! ctx.Data.Page.UpdatePriorPermalinks pageId ctx.WebLog.Id links with match! ctx.Data.Page.UpdatePriorPermalinks pageId ctx.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 $"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
| Some _ -> return! Error.notAuthorized next ctx | Some _ -> return! Error.notAuthorized next ctx
@ -97,7 +97,7 @@ let editRevisions pgId : HttpHandler = requireAccess Author >=> fun next ctx ->
return! return!
hashForPage "Manage Page Revisions" hashForPage "Manage Page Revisions"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash ViewContext.Model (ManageRevisionsModel.fromPage ctx.WebLog pg) |> addToHash ViewContext.Model (ManageRevisionsModel.FromPage ctx.WebLog pg)
|> adminView "revisions" next ctx |> 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
@ -109,7 +109,7 @@ let purgeRevisions pgId : HttpHandler = requireAccess Author >=> fun next ctx ->
match! data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with match! data.Page.FindFullById (PageId pgId) ctx.WebLog.Id with
| Some pg -> | Some pg ->
do! data.Page.Update { pg with Revisions = [ List.head pg.Revisions ] } do! data.Page.Update { pg with Revisions = [ List.head pg.Revisions ] }
do! addMessage ctx { UserMessage.success with Message = "Prior revisions purged successfully" } do! addMessage ctx { UserMessage.Success with Message = "Prior revisions purged successfully" }
return! redirectToGet $"admin/page/{pgId}/revisions" next ctx return! redirectToGet $"admin/page/{pgId}/revisions" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -152,7 +152,7 @@ let restoreRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun
Revisions = { rev with AsOf = Noda.now () } Revisions = { rev with AsOf = Noda.now () }
:: (pg.Revisions |> List.filter (fun r -> r.AsOf <> rev.AsOf)) :: (pg.Revisions |> List.filter (fun r -> r.AsOf <> rev.AsOf))
} }
do! addMessage ctx { UserMessage.success with Message = "Revision restored successfully" } do! addMessage ctx { UserMessage.Success with Message = "Revision restored successfully" }
return! redirectToGet $"admin/page/{pgId}/revisions" next ctx return! redirectToGet $"admin/page/{pgId}/revisions" next ctx
| Some _, Some _ -> return! Error.notAuthorized next ctx | Some _, Some _ -> return! Error.notAuthorized next ctx
| None, _ | None, _
@ -164,7 +164,7 @@ let deleteRevision (pgId, revDate) : HttpHandler = requireAccess Author >=> fun
match! findPageRevision pgId revDate ctx with match! findPageRevision pgId revDate ctx with
| Some pg, Some rev when canEdit pg.AuthorId ctx -> | Some pg, Some rev when canEdit pg.AuthorId ctx ->
do! ctx.Data.Page.Update { pg with Revisions = pg.Revisions |> List.filter (fun r -> r.AsOf <> rev.AsOf) } 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" } do! addMessage ctx { UserMessage.Success with Message = "Revision deleted successfully" }
return! adminBareView "" next ctx (makeHash {| content = "" |}) return! adminBareView "" next ctx (makeHash {| content = "" |})
| Some _, Some _ -> return! Error.notAuthorized next ctx | Some _, Some _ -> return! Error.notAuthorized next ctx
| None, _ | None, _
@ -191,7 +191,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let updatedPage = model.UpdatePage page now let updatedPage = model.UpdatePage page now
do! (if model.IsNew then data.Page.Add else data.Page.Update) updatedPage do! (if model.IsNew then data.Page.Add else data.Page.Update) updatedPage
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! redirectToGet $"admin/page/{page.Id}/edit" next ctx return! redirectToGet $"admin/page/{page.Id}/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

View File

@ -47,7 +47,7 @@ let preparePostList webLog posts listType (url: string) pageNbr perPage (data: I
posts posts
|> Seq.ofList |> Seq.ofList
|> Seq.truncate perPage |> Seq.truncate perPage
|> Seq.map (PostListItem.fromPost webLog) |> Seq.map (PostListItem.FromPost webLog)
|> Array.ofSeq |> Array.ofSeq
let! olderPost, newerPost = let! olderPost, newerPost =
match listType with match listType with
@ -232,7 +232,7 @@ 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! 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!
hashForPage title hashForPage title
|> withAntiCsrf ctx |> withAntiCsrf ctx
@ -255,8 +255,8 @@ let edit postId : HttpHandler = requireAccess Author >=> fun next ctx -> task {
// POST /admin/post/{id}/delete // POST /admin/post/{id}/delete
let delete postId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let delete postId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
match! ctx.Data.Post.Delete (PostId postId) ctx.WebLog.Id with match! ctx.Data.Post.Delete (PostId postId) ctx.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 "admin/posts" next ctx return! redirectToGet "admin/posts" next ctx
} }
@ -267,7 +267,7 @@ let editPermalinks postId : HttpHandler = requireAccess Author >=> fun next ctx
return! return!
hashForPage "Manage Prior Permalinks" hashForPage "Manage Prior Permalinks"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash ViewContext.Model (ManagePermalinksModel.fromPost post) |> addToHash ViewContext.Model (ManagePermalinksModel.FromPost post)
|> adminView "permalinks" next ctx |> 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
@ -282,7 +282,7 @@ let savePermalinks : HttpHandler = requireAccess Author >=> fun next ctx -> task
let links = model.Prior |> Array.map Permalink |> List.ofArray let links = model.Prior |> Array.map Permalink |> List.ofArray
match! ctx.Data.Post.UpdatePriorPermalinks postId ctx.WebLog.Id links with match! ctx.Data.Post.UpdatePriorPermalinks postId ctx.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 $"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
| Some _ -> return! Error.notAuthorized next ctx | Some _ -> return! Error.notAuthorized next ctx
@ -296,7 +296,7 @@ let editRevisions postId : HttpHandler = requireAccess Author >=> fun next ctx -
return! return!
hashForPage "Manage Post Revisions" hashForPage "Manage Post Revisions"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash ViewContext.Model (ManageRevisionsModel.fromPost ctx.WebLog post) |> addToHash ViewContext.Model (ManageRevisionsModel.FromPost ctx.WebLog post)
|> adminView "revisions" next ctx |> 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
@ -308,7 +308,7 @@ let purgeRevisions postId : HttpHandler = requireAccess Author >=> fun next ctx
match! data.Post.FindFullById (PostId postId) ctx.WebLog.Id with match! data.Post.FindFullById (PostId postId) ctx.WebLog.Id with
| Some post when canEdit post.AuthorId ctx -> | Some post when canEdit post.AuthorId ctx ->
do! data.Post.Update { post with Revisions = [ List.head post.Revisions ] } do! data.Post.Update { post with Revisions = [ List.head post.Revisions ] }
do! addMessage ctx { UserMessage.success with Message = "Prior revisions purged successfully" } do! addMessage ctx { UserMessage.Success with Message = "Prior revisions purged successfully" }
return! redirectToGet $"admin/post/{postId}/revisions" next ctx return! redirectToGet $"admin/post/{postId}/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
@ -352,7 +352,7 @@ let restoreRevision (postId, revDate) : HttpHandler = requireAccess Author >=> f
Revisions = { rev with AsOf = Noda.now () } Revisions = { rev with AsOf = Noda.now () }
:: (post.Revisions |> List.filter (fun r -> r.AsOf <> rev.AsOf)) :: (post.Revisions |> List.filter (fun r -> r.AsOf <> rev.AsOf))
} }
do! addMessage ctx { UserMessage.success with Message = "Revision restored successfully" } do! addMessage ctx { UserMessage.Success with Message = "Revision restored successfully" }
return! redirectToGet $"admin/post/{postId}/revisions" next ctx return! redirectToGet $"admin/post/{postId}/revisions" next ctx
| Some _, Some _ -> return! Error.notAuthorized next ctx | Some _, Some _ -> return! Error.notAuthorized next ctx
| None, _ | None, _
@ -364,7 +364,7 @@ let deleteRevision (postId, revDate) : HttpHandler = requireAccess Author >=> fu
match! findPostRevision postId revDate ctx with match! findPostRevision postId revDate ctx with
| Some post, Some rev when canEdit post.AuthorId ctx -> | Some post, Some rev when canEdit post.AuthorId ctx ->
do! ctx.Data.Post.Update { post with Revisions = post.Revisions |> List.filter (fun r -> r.AsOf <> rev.AsOf) } do! ctx.Data.Post.Update { post with Revisions = post.Revisions |> List.filter (fun r -> r.AsOf <> rev.AsOf) }
do! addMessage ctx { UserMessage.success with Message = "Revision deleted successfully" } do! addMessage ctx { UserMessage.Success with Message = "Revision deleted successfully" }
return! adminBareView "" next ctx (makeHash {| content = "" |}) return! adminBareView "" next ctx (makeHash {| content = "" |})
| Some _, Some _ -> return! Error.notAuthorized next ctx | Some _, Some _ -> return! Error.notAuthorized next ctx
| None, _ | None, _
@ -408,7 +408,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
|> List.distinct |> List.distinct
|> List.length = List.length priorCats) then |> List.length = List.length priorCats) 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! redirectToGet $"admin/post/{post.Id}/edit" next ctx return! redirectToGet $"admin/post/{post.Id}/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

View File

@ -117,7 +117,7 @@ let list : HttpHandler = requireAccess Author >=> fun next ctx -> task {
[] []
let allFiles = let allFiles =
dbUploads dbUploads
|> 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!
@ -169,7 +169,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
use stream = new FileStream(Path.Combine(fullPath, fileName), FileMode.Create) use stream = new FileStream(Path.Combine(fullPath, fileName), FileMode.Create)
do! upload.CopyToAsync stream do! upload.CopyToAsync stream
do! addMessage ctx { UserMessage.success with Message = $"File uploaded to {form.Destination} successfully" } do! addMessage ctx { UserMessage.Success with Message = $"File uploaded to {form.Destination} successfully" }
return! showUploads next ctx return! showUploads next ctx
else else
return! RequestErrors.BAD_REQUEST "Bad request; no file present" next ctx return! RequestErrors.BAD_REQUEST "Bad request; no file present" next ctx
@ -179,7 +179,7 @@ let save : HttpHandler = requireAccess Author >=> fun next ctx -> task {
let deleteFromDb upId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let deleteFromDb upId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
match! ctx.Data.Upload.Delete (UploadId upId) ctx.WebLog.Id with match! ctx.Data.Upload.Delete (UploadId upId) ctx.WebLog.Id with
| Ok fileName -> | Ok fileName ->
do! addMessage ctx { UserMessage.success with Message = $"{fileName} deleted successfully" } do! addMessage ctx { UserMessage.Success with Message = $"{fileName} deleted successfully" }
return! showUploads next ctx return! showUploads next ctx
| Error _ -> return! Error.notFound next ctx | Error _ -> return! Error.notFound next ctx
} }
@ -202,7 +202,7 @@ let deleteFromDisk urlParts : HttpHandler = requireAccess WebLogAdmin >=> fun ne
if File.Exists path then if File.Exists path then
File.Delete path File.Delete path
removeEmptyDirectories ctx.WebLog filePath removeEmptyDirectories ctx.WebLog filePath
do! addMessage ctx { UserMessage.success with Message = $"{filePath} deleted successfully" } do! addMessage ctx { UserMessage.Success with Message = $"{filePath} deleted successfully" }
return! showUploads next ctx return! showUploads next ctx
else return! Error.notFound next ctx else return! Error.notFound next ctx
} }

View File

@ -38,7 +38,7 @@ let logOn returnUrl : HttpHandler = fun next ctx ->
| 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
hashForPage "Log On" hashForPage "Log On"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash ViewContext.Model { LogOnModel.empty with ReturnTo = returnTo } |> addToHash ViewContext.Model { LogOnModel.Empty with ReturnTo = returnTo }
|> adminView "log-on" next ctx |> adminView "log-on" next ctx
@ -66,7 +66,7 @@ let doLogOn : HttpHandler = fun next ctx -> task {
AuthenticationProperties(IssuedUtc = DateTimeOffset.UtcNow)) AuthenticationProperties(IssuedUtc = DateTimeOffset.UtcNow))
do! data.WebLogUser.SetLastSeen user.Id user.WebLogId do! data.WebLogUser.SetLastSeen user.Id user.WebLogId
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with { UserMessage.Success with
Message = "Log on successful" Message = "Log on successful"
Detail = Some $"Welcome to {ctx.WebLog.Name}!" Detail = Some $"Welcome to {ctx.WebLog.Name}!"
} }
@ -75,14 +75,14 @@ let doLogOn : HttpHandler = fun next ctx -> task {
| Some url -> redirectTo false url next ctx | Some url -> redirectTo false url next ctx
| None -> redirectToGet "admin/dashboard" next ctx | None -> redirectToGet "admin/dashboard" next ctx
| Error msg -> | Error msg ->
do! addMessage ctx { UserMessage.error with Message = msg } do! addMessage ctx { UserMessage.Error with Message = msg }
return! logOn model.ReturnTo next ctx return! logOn model.ReturnTo next ctx
} }
// GET /user/log-off // GET /user/log-off
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 "" next ctx return! redirectToGet "" next ctx
} }
@ -100,7 +100,7 @@ let all : HttpHandler = fun next ctx -> task {
return! return!
hashForPage "User Administration" hashForPage "User Administration"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> addToHash "users" (users |> List.map (DisplayUser.fromUser ctx.WebLog) |> Array.ofList) |> addToHash "users" (users |> List.map (DisplayUser.FromUser ctx.WebLog) |> Array.ofList)
|> adminBareView "user-list-body" next ctx |> adminBareView "user-list-body" next ctx
} }
@ -125,7 +125,7 @@ let edit usrId : HttpHandler = fun next ctx -> task {
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 (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
} }
@ -140,13 +140,13 @@ let delete userId : HttpHandler = fun next ctx -> task {
match! data.WebLogUser.Delete user.Id user.WebLogId with match! data.WebLogUser.Delete user.Id user.WebLogId with
| Ok _ -> | Ok _ ->
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with { UserMessage.Success with
Message = $"User {user.DisplayName} deleted successfully" Message = $"User {user.DisplayName} deleted successfully"
} }
return! all next ctx return! all next ctx
| Error msg -> | Error msg ->
do! addMessage ctx do! addMessage ctx
{ UserMessage.error with { UserMessage.Error with
Message = $"User {user.DisplayName} was not deleted" Message = $"User {user.DisplayName} was not deleted"
Detail = Some msg Detail = Some msg
} }
@ -168,7 +168,7 @@ let private showMyInfo (model: EditMyInfoModel) (user: WebLogUser) : HttpHandler
// GET /admin/my-info // GET /admin/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 (EditMyInfoModel.fromUser user) 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
} }
@ -188,10 +188,10 @@ let saveMyInfo : HttpHandler = requireAccess Author >=> 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 "admin/my-info" next ctx return! redirectToGet "admin/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 { model with NewPassword = ""; NewPasswordConfirm = "" } user next ctx return! showMyInfo { model with NewPassword = ""; NewPasswordConfirm = "" } user next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -222,12 +222,12 @@ let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task {
else { updatedUser with PasswordHash = createPasswordHash updatedUser model.Password } else { updatedUser with PasswordHash = createPasswordHash updatedUser model.Password }
do! (if model.IsNew then data.WebLogUser.Add else data.WebLogUser.Update) toUpdate do! (if model.IsNew then data.WebLogUser.Add else data.WebLogUser.Update) toUpdate
do! addMessage ctx do! addMessage ctx
{ UserMessage.success with { UserMessage.Success with
Message = $"""{if model.IsNew then "Add" else "Updat"}ed user successfully""" Message = $"""{if model.IsNew then "Add" else "Updat"}ed user successfully"""
} }
return! all next ctx return! all next ctx
| 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}" >=> showEdit { model with Password = ""; PasswordConfirm = "" }) (withHxRetarget $"#user_{model.Id}" >=> showEdit { model with Password = ""; PasswordConfirm = "" })
next ctx next ctx