From be094fb07e7bcab59bb3fb58e25774b0294cfd4e Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Wed, 1 Jul 2026 23:43:48 -0400 Subject: [PATCH] WIP on endpoint routing conversion (#59) --- src/Directory.Packages.props | 4 +- src/MyWebLog/Handlers/Admin.fs | 40 ++++---- src/MyWebLog/Handlers/Post.fs | 33 +++--- src/MyWebLog/Handlers/Routes.fs | 171 ++++++++++++-------------------- src/MyWebLog/Handlers/User.fs | 8 +- src/MyWebLog/Program.fs | 5 +- src/MyWebLog/Template.fs | 3 +- src/MyWebLog/Views/Helpers.fs | 7 +- 8 files changed, 118 insertions(+), 153 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 632ceeb..c585bd5 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -11,8 +11,8 @@ - - + + diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index 915b835..ea4a2c7 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -10,7 +10,7 @@ open NodaTime /// ~~~ DASHBOARDS ~~~ module Dashboard = - + // GET /admin/dashboard let user : HttpHandler = requireAccess Author >=> fun next ctx -> task { let getCount (f: WebLogId -> Task) = f ctx.WebLog.Id @@ -43,7 +43,7 @@ let toAdminDashboard : HttpHandler = redirectToGet "admin/administration" /// ~~~ CACHES ~~~ module Cache = - + // POST /admin/cache/web-log/{id}/refresh let refreshWebLog webLogId : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { let data = ctx.Data @@ -92,17 +92,17 @@ module Cache = /// ~~~ CATEGORIES ~~~ module Category = - + open MyWebLog.Data // GET /admin/categories - let all : HttpHandler = fun next ctx -> + let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> let response = fun next ctx -> adminPage "Categories" next ctx (Views.WebLog.categoryList (ctx.Request.Query.ContainsKey "new")) (withHxPushUrl (ctx.WebLog.RelativeUrl (Permalink "admin/categories")) >=> response) next ctx // GET /admin/category/{id}/edit - let edit catId : HttpHandler = fun next ctx -> task { + let edit catId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let! result = task { match catId with | "new" -> return Some ("Add a New Category", { Category.Empty with Id = CategoryId "new" }) @@ -166,15 +166,15 @@ module RedirectRules = open Microsoft.AspNetCore.Http // GET /admin/settings/redirect-rules - let all : HttpHandler = fun next ctx -> + let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> adminPage "Redirect Rules" next ctx (Views.WebLog.redirectList ctx.WebLog.RedirectRules) // GET /admin/settings/redirect-rules/[index] - let edit idx : HttpHandler = fun next ctx -> + let edit idx : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> let titleAndView = if idx = -1 then Some ("Add", Views.WebLog.redirectEdit (EditRedirectRuleModel.FromRule -1 RedirectRule.Empty)) - else + else let rules = ctx.WebLog.RedirectRules if rules.Length < idx || idx < 0 then None @@ -184,7 +184,7 @@ module RedirectRules = match titleAndView with | Some (title, view) -> adminBarePage $"{title} Redirect Rule" next ctx view | None -> Error.notFound next ctx - + /// Update the web log's redirect rules in the database, the request web log, and the web log cache let private updateRedirectRules (ctx: HttpContext) webLog = backgroundTask { do! ctx.Data.WebLog.UpdateRedirectRules webLog @@ -243,15 +243,15 @@ module RedirectRules = /// ~~~ TAG MAPPINGS ~~~ module TagMapping = - + // GET /admin/settings/tag-mappings - let all : HttpHandler = fun next ctx -> task { + let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let! mappings = ctx.Data.TagMap.FindByWebLog ctx.WebLog.Id return! adminBarePage "Tag Mapping List" next ctx (Views.WebLog.tagMapList mappings) } // GET /admin/settings/tag-mapping/{id}/edit - let edit tagMapId : HttpHandler = fun next ctx -> task { + let edit tagMapId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let isNew = tagMapId = "new" let tagMap = if isNew then someTask { TagMap.Empty with Id = TagMapId "new" } @@ -290,7 +290,7 @@ module TagMapping = /// ~~~ THEMES ~~~ module Theme = - + open System open System.IO open System.IO.Compression @@ -298,7 +298,7 @@ module Theme = open MyWebLog.Data // GET /admin/theme/list - let all : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { + let all : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { let! themes = ctx.Data.Theme.All () return! Views.Admin.themeList (List.map (DisplayTheme.FromTheme WebLogCache.isThemeInUse) themes) @@ -378,7 +378,7 @@ module Theme = let! theme = updateTemplates { theme with Templates = [] } zip do! data.Theme.Save theme do! updateAssets themeId zip data - + return theme } @@ -392,7 +392,7 @@ module Theme = let! exists = data.Theme.Exists themeId let isNew = not exists let! model = ctx.BindFormAsync() - if isNew || model.DoOverwrite then + if isNew || model.DoOverwrite then // Load the theme to the database use stream = new MemoryStream() do! themeFile.CopyToAsync stream @@ -448,11 +448,11 @@ module Theme = /// ~~~ WEB LOG SETTINGS ~~~ module WebLog = - + open System.IO // GET /admin/settings - let settings : HttpHandler = fun next ctx -> task { + let settings : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let data = ctx.Data let! allPages = data.Page.All ctx.WebLog.Id let pages = @@ -479,13 +479,13 @@ module WebLog = // Update cache WebLogCache.set webLog - + if oldSlug <> webLog.Slug then // Rename disk directory if it exists let uploadRoot = Path.Combine("wwwroot", "upload") let oldDir = Path.Combine(uploadRoot, oldSlug) 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 "admin/settings" next ctx | None -> return! Error.notFound next ctx diff --git a/src/MyWebLog/Handlers/Post.fs b/src/MyWebLog/Handlers/Post.fs index d6c9039..d48cb28 100644 --- a/src/MyWebLog/Handlers/Post.fs +++ b/src/MyWebLog/Handlers/Post.fs @@ -96,22 +96,25 @@ let preparePostList webLog posts listType (url: string) pageNbr perPage (data: I open Giraffe -// GET /page/{pageNbr} +// GET /page/{pageNbr}[/] let pageOfPosts pageNbr : HttpHandler = fun next ctx -> task { - let count = ctx.WebLog.PostsPerPage - let data = ctx.Data - let! posts = data.Post.FindPageOfPublishedPosts ctx.WebLog.Id pageNbr count - let! viewCtx = preparePostList ctx.WebLog posts PostList "" pageNbr count data - let title = - match pageNbr, ctx.WebLog.DefaultPage with - | 1, "posts" -> None - | _, "posts" -> Some $"Page {pageNbr}" - | _, _ -> Some $"Page {pageNbr} « Posts" - return! - { viewCtx with - PageTitle = defaultArg title viewCtx.PageTitle - IsHome = pageNbr = 1 && ctx.WebLog.DefaultPage = "posts" } - |> themedView "index" next ctx + if ctx.Request.Path.Value.EndsWith "/" then + return! redirectTo true (ctx.WebLog.RelativeUrl(Permalink $"page/{pageNbr}")) next ctx + else + let count = ctx.WebLog.PostsPerPage + let data = ctx.Data + let! posts = data.Post.FindPageOfPublishedPosts ctx.WebLog.Id pageNbr count + let! viewCtx = preparePostList ctx.WebLog posts PostList "" pageNbr count data + let title = + match pageNbr, ctx.WebLog.DefaultPage with + | 1, "posts" -> None + | _, "posts" -> Some $"Page {pageNbr}" + | _, _ -> Some $"Page {pageNbr} « Posts" + return! + { viewCtx with + PageTitle = defaultArg title viewCtx.PageTitle + IsHome = pageNbr = 1 && ctx.WebLog.DefaultPage = "posts" } + |> themedView "index" next ctx } // GET /page/{pageNbr}/ diff --git a/src/MyWebLog/Handlers/Routes.fs b/src/MyWebLog/Handlers/Routes.fs index 82d7089..190e426 100644 --- a/src/MyWebLog/Handlers/Routes.fs +++ b/src/MyWebLog/Handlers/Routes.fs @@ -1,10 +1,8 @@ /// Routes for this application module MyWebLog.Handlers.Routes -open System.Reflection open Giraffe open Microsoft.AspNetCore.Http -open Microsoft.Extensions.FileProviders open MyWebLog /// Module to resolve routes that do not match any other known route (web blog content) @@ -13,18 +11,12 @@ module CatchAll = open System.IO open MyWebLog.ViewModels - /// URLs for the embedded htmx scripts - let htmxUrls = [ "giraffe.htmx.common/htmx.min.js"; "giraffe.htmx.common/htmax.min.js" ] - /// Sequence where the first returned value is the proper handler for the link let private deriveAction (ctx: HttpContext) : HttpHandler seq = let webLog = ctx.WebLog let data = ctx.Data let debug = debug "Routes.CatchAll" ctx - let textLink = - let extra = webLog.ExtraPath - let url = string ctx.Request.Path - (if extra = "" then url else url[extra.Length..]).ToLowerInvariant() + let textLink = string ctx.Request.Path let await it = (Async.AwaitTask >> Async.RunSynchronously) it seq { // Static file @@ -41,32 +33,6 @@ module CatchAll = |> System.DateTimeOffset |> Some |> streamFile true staticFileName None - // Embedded htmx library - if htmxUrls |> List.exists textLink.EndsWith then - debug (fun () -> "Embedded htmx script") - let asm = Assembly.Load "Giraffe.Htmx.Common" - asm.GetManifestResourceNames() |> Array.iter (fun it -> debug (fun () -> $"Resource {it}")) - let name = textLink.Split("/") |> Array.last |> sprintf "Giraffe.Htmx.Common/%s" - use fs = asm.GetManifestResourceStream name - if isNull fs then - debug (fun () -> $"No stream found for {name}") - else - debug (fun () -> $"Found {name}") - yield streamData true fs None None - // let files = EmbeddedFileProvider(Assembly.Load "Giraffe.Htmx.Common") - // let cont = files.GetDirectoryContents "." - // if cont.Exists then - // debug (fun () -> "Contents found in file provider, here comes the list") - // debug (fun () -> $"%A{cont}") - // cont |> Seq.iter (fun it -> debug (fun () -> "Path = " + it.PhysicalPath)) - // else - // debug (fun () -> "No contents found in file provider") - // let file = files.GetFileInfo (textLink.Replace("giraffe.htmx.common", "Giraffe.Htmx.Common")) - // debug (fun () -> $"File info created; {textLink} exists = {file.Exists}") - // if file.Exists then - // use contents = file.CreateReadStream() - // yield streamData true contents None None - //yield None // Home page directory without the directory slash if textLink = "" then yield redirectTo true (webLog.RelativeUrl Permalink.Empty) let permalink = Permalink textLink[1..] @@ -149,61 +115,7 @@ module Asset = /// The primary myWebLog router let router : HttpHandler = choose [ - GET_HEAD >=> choose [ - route "/" >=> Post.home - ] subRoute "/admin" (requireUser >=> choose [ - GET_HEAD >=> choose [ - route "/administration" >=> Admin.Dashboard.admin - subRoute "/categor" (requireAccess WebLogAdmin >=> choose [ - route "ies" >=> Admin.Category.all - routef "y/%s/edit" Admin.Category.edit - ]) - route "/dashboard" >=> Admin.Dashboard.user - route "/my-info" >=> User.myInfo - subRoute "/page" (choose [ - route "s" >=> Page.all 1 - routef "s/page/%i" Page.all - routef "/%s/edit" Page.edit - routef "/%s/permalinks" Page.editPermalinks - routef "/%s/revision/%s/preview" Page.previewRevision - routef "/%s/revisions" Page.editRevisions - ]) - subRoute "/post" (choose [ - route "s" >=> Post.all 1 - routef "s/page/%i" Post.all - routef "/%s/edit" Post.edit - routef "/%s/permalinks" Post.editPermalinks - routef "/%s/revision/%s/preview" Post.previewRevision - routef "/%s/revisions" Post.editRevisions - routef "/%s/chapter/%i" Post.editChapter - routef "/%s/chapters" Post.manageChapters - ]) - subRoute "/settings" (requireAccess WebLogAdmin >=> choose [ - route "" >=> Admin.WebLog.settings - routef "/rss/%s/edit" Feed.editCustomFeed - subRoute "/redirect-rules" (choose [ - route "" >=> Admin.RedirectRules.all - routef "/%i" Admin.RedirectRules.edit - ]) - subRoute "/tag-mapping" (choose [ - route "s" >=> Admin.TagMapping.all - routef "/%s/edit" Admin.TagMapping.edit - ]) - subRoute "/user" (choose [ - route "s" >=> User.all - routef "/%s/edit" User.edit - ]) - ]) - subRoute "/theme" (choose [ - route "/list" >=> Admin.Theme.all - route "/new" >=> Admin.Theme.add - ]) - subRoute "/upload" (choose [ - route "s" >=> Upload.list - route "/new" >=> Upload.showNew - ]) - ] POST >=> validateCsrf >=> choose [ subRoute "/cache" (choose [ routef "/theme/%s/refresh" Admin.Cache.refreshTheme @@ -271,16 +183,10 @@ let router : HttpHandler = choose [ ] ]) GET_HEAD >=> routexp "/category/(.*)" Post.pageOfCategorizedPosts - GET_HEAD >=> routef "/page/%i" Post.pageOfPosts - GET_HEAD >=> routef "/page/%i/" Post.redirectToPageOfPosts GET_HEAD >=> routexp "/tag/(.*)" Post.pageOfTaggedPosts GET_HEAD >=> routexp "/themes/(.*)" Asset.serve GET_HEAD >=> routexp "/upload/(.*)" Upload.serve subRoute "/user" (choose [ - GET_HEAD >=> choose [ - route "/log-on" >=> User.logOn None - route "/log-off" >=> User.logOff - ] POST >=> validateCsrf >=> choose [ route "/log-on" >=> User.doLogOn ] @@ -289,17 +195,70 @@ let router : HttpHandler = choose [ //Error.notFound ] -/// Wrap a router in a sub-route -let routerWithPath extraPath : HttpHandler = - subRoute extraPath router - -/// Handler to apply Giraffe routing with a possible sub-route -let handleRoute : HttpHandler = fun next ctx -> - let extraPath = ctx.WebLog.ExtraPath - (if extraPath = "" then router else routerWithPath extraPath) next ctx - - open Giraffe.EndpointRouting /// Endpoint-routed handler to deal with sub-routes -let endpoint = [ route "{**url}" handleRoute ] +let endpoints = [ + GET_HEAD [ route "/" Post.home ] + subRoute "/admin" [ + GET_HEAD [ + route "/administration" Admin.Dashboard.admin + subRoute "/categor" [ + route "ies" Admin.Category.all + routef "y/%s/edit" Admin.Category.edit + ] + route "/dashboard" Admin.Dashboard.user + route "/my-info" User.myInfo + subRoute "/page" [ + route "s" (Page.all 1) + routef "s/page/%i" Page.all + routef "/%s/edit" Page.edit + routef "/%s/permalinks" Page.editPermalinks + routef "/%s/revision/%s/preview" Page.previewRevision + routef "/%s/revisions" Page.editRevisions + ] + subRoute "/post" [ + route "s" (Post.all 1) + routef "s/page/%i" Post.all + routef "/%s/edit" Post.edit + routef "/%s/permalinks" Post.editPermalinks + routef "/%s/revision/%s/preview" Post.previewRevision + routef "/%s/revisions" Post.editRevisions + routef "/%s/chapter/%i" Post.editChapter + routef "/%s/chapters" Post.manageChapters + ] + subRoute "/settings" [ + route "" Admin.WebLog.settings + routef "/rss/%s/edit" Feed.editCustomFeed + subRoute "/redirect-rules" [ + route "" Admin.RedirectRules.all + routef "/%i" Admin.RedirectRules.edit + ] + subRoute "/tag-mapping" [ + route "s" Admin.TagMapping.all + routef "/%s/edit" Admin.TagMapping.edit + ] + subRoute "/user" [ + route "s" User.all + routef "/%s/edit" User.edit + ] + ] + subRoute "/theme" [ + route "/list" Admin.Theme.all + route "/new" Admin.Theme.add + ] + subRoute "/upload" [ + route "s" Upload.list + route "/new" Upload.showNew + ] + ] + ] + subRoute "/user" [ + GET_HEAD [ route "/log-on" (User.logOn None) ] + POST [ + route "/log-off" User.logOff + ] + ] + GET_HEAD [ routef "/page/%i" Post.pageOfPosts ] + route "{**url}" router +] diff --git a/src/MyWebLog/Handlers/User.fs b/src/MyWebLog/Handlers/User.fs index df3bac0..e1152ae 100644 --- a/src/MyWebLog/Handlers/User.fs +++ b/src/MyWebLog/Handlers/User.fs @@ -47,7 +47,7 @@ let doLogOn : HttpHandler = fun next ctx -> task { let! model = ctx.BindFormAsync() let data = ctx.Data let! tryUser = data.WebLogUser.FindByEmail (model.EmailAddress.ToLowerInvariant()) ctx.WebLog.Id - match! verifyPassword tryUser model.Password ctx with + match! verifyPassword tryUser model.Password ctx with | Ok _ -> let user = tryUser.Value let claims = seq { @@ -89,7 +89,7 @@ open Giraffe.Htmx let private goAway : HttpHandler = RequestErrors.BAD_REQUEST "really?" // GET /admin/settings/users -let all : HttpHandler = fun next ctx -> task { +let all : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let! users = ctx.Data.WebLogUser.FindByWebLog ctx.WebLog.Id return! adminBarePage "User Administration" next ctx (Views.User.userList users) } @@ -97,9 +97,9 @@ let all : HttpHandler = fun next ctx -> task { /// Show the edit user page let private showEdit (model: EditUserModel) : HttpHandler = fun next ctx -> adminBarePage (if model.IsNew then "Add a New User" else "Edit User") next ctx (Views.User.edit model) - + // GET /admin/settings/user/{id}/edit -let edit usrId : HttpHandler = fun next ctx -> task { +let edit usrId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let isNew = usrId = "new" let userId = WebLogUserId usrId let tryUser = diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index a72dafd..74163bb 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -20,6 +20,9 @@ type WebLogMiddleware(next: RequestDelegate, log: ILogger) = ctx.Items["webLog"] <- webLog if PageListCache.exists ctx then () else do! PageListCache.update ctx if CategoryCache.exists ctx then () else do! CategoryCache.update ctx + if webLog.ExtraPath <> "" then + ctx.Request.PathBase <- PathString(webLog.ExtraPath) + ctx.Request.Path <- PathString(ctx.Request.Path.Value[webLog.ExtraPath.Length ..]) return! next.Invoke ctx | None -> if isDebug then log.LogDebug $"No resolved web log for {path}" @@ -242,7 +245,7 @@ let main args = let _ = app.UseStaticFiles() let _ = app.UseRouting() let _ = app.UseSession() - let _ = app.UseGiraffe Handlers.Routes.endpoint + let _ = app.UseGiraffe Handlers.Routes.endpoints app.Run() } diff --git a/src/MyWebLog/Template.fs b/src/MyWebLog/Template.fs index 8f5aae9..e522a5d 100644 --- a/src/MyWebLog/Template.fs +++ b/src/MyWebLog/Template.fs @@ -174,6 +174,7 @@ let options () = it +open Giraffe.Htmx.Common /// Fluid parser customized with myWebLog filters and tags let parser = @@ -255,7 +256,7 @@ let parser = fun writer encoder context -> let webLog = context.App.WebLog if webLog.AutoHtmx then - context.App.WebLog.RelativeUrl(Permalink "htmx.min.js") + context.App.WebLog.RelativeUrl(Permalink StaticAssetUrl.htmx[1..]) |> sprintf "%s" s |> writer.WriteLine if assetExists "script.js" webLog then diff --git a/src/MyWebLog/Views/Helpers.fs b/src/MyWebLog/Views/Helpers.fs index e30d1db..6808c92 100644 --- a/src/MyWebLog/Views/Helpers.fs +++ b/src/MyWebLog/Views/Helpers.fs @@ -2,6 +2,7 @@ [] module MyWebLog.Views.Helpers +open Giraffe.Htmx open Giraffe.ViewEngine open Giraffe.ViewEngine.Accessibility open Giraffe.ViewEngine.Htmx @@ -243,7 +244,7 @@ module Layout = ] if app.IsLoggedOn then li [ _class "nav-item" ] [ - a [ _class "nav-link"; _href (relUrl app "user/log-off"); _hxNoBoost ] [ + a [ _class "nav-link"; _hxPost (relUrl app "user/log-off"); _hxNoBoost ] [ raw "Log Off" ] ] @@ -317,8 +318,7 @@ module Layout = script [ _src "https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js" _integrity "sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p" _crossorigin "anonymous" ] [] - //script [ _src (relUrl app "htmx.min.js") ] [] - Script.local + script [ _src (relUrl app StaticAssetUrl.htmx[1..]) ] [] script [ _src (relUrl app "themes/admin/admin.js") ] [] ] ] @@ -335,7 +335,6 @@ module Layout = // ~~ SHARED TEMPLATES BETWEEN POSTS AND PAGES -open Giraffe.Htmx.Common /// The round-trip instant pattern let roundTrip = InstantPattern.CreateWithInvariantCulture "uuuu'-'MM'-'dd'T'HH':'mm':'ss'.'fffffff"