diff --git a/build.fsx b/build.fsx index 3aa6501..c3dfbbd 100644 --- a/build.fsx +++ b/build.fsx @@ -36,7 +36,7 @@ let zipTheme (name : string) (_ : TargetParameter) = !! $"{path}/**/*" |> Zip.filesAsSpecs path //$"src/{name}-theme" |> Seq.filter (fun (_, name) -> not (name.EndsWith ".zip")) - |> Zip.zipSpec $"{releasePath}/{name}.zip" + |> Zip.zipSpec $"{releasePath}/{name}-theme.zip" /// Publish the project for the given runtime ID let publishFor rid (_ : TargetParameter) = @@ -45,11 +45,12 @@ let publishFor rid (_ : TargetParameter) = /// Package published output for the given runtime ID let packageFor (rid : string) (_ : TargetParameter) = let path = $"{projectPath}/bin/Release/net6.0/{rid}/publish" + let prodSettings = $"{path}/appsettings.Production.json" + if File.exists prodSettings then File.delete prodSettings [ !! $"{path}/**/*" |> Zip.filesAsSpecs path - |> Zip.moveToFolder "app" - Seq.singleton ($"{releasePath}/admin.zip", "admin.zip") - Seq.singleton ($"{releasePath}/default.zip", "default.zip") + Seq.singleton ($"{releasePath}/admin-theme.zip", "admin-theme.zip") + Seq.singleton ($"{releasePath}/default-theme.zip", "default-theme.zip") ] |> Seq.concat |> Zip.zipSpec $"{releasePath}/myWebLog-{version}.{rid}.zip" @@ -86,7 +87,7 @@ Target.create "RepackageLinux" (fun _ -> Shell.mkdir workDir Zip.unzip workDir zipArchive Shell.cd workDir - sh "chmod" [ "+x"; "app/MyWebLog" ] + sh "chmod" [ "+x"; "./MyWebLog" ] sh "tar" [ "cfj"; $"../myWebLog-{version}.linux-x64.tar.bz2"; "." ] Shell.cd "../.." Shell.rm zipArchive @@ -96,8 +97,8 @@ Target.create "RepackageLinux" (fun _ -> Target.create "All" ignore Target.create "RemoveThemeArchives" (fun _ -> - Shell.rm $"{releasePath}/admin.zip" - Shell.rm $"{releasePath}/default.zip" + Shell.rm $"{releasePath}/admin-theme.zip" + Shell.rm $"{releasePath}/default-theme.zip" ) Target.create "CI" ignore diff --git a/rethink-case-fix.js b/rethink-case-fix.js deleted file mode 100644 index 7c62dac..0000000 --- a/rethink-case-fix.js +++ /dev/null @@ -1,171 +0,0 @@ - -// Category -r.db('myWebLog').table('Category').map({ - Description: r.row('description'), - Id: r.row('id'), - Name: r.row('name'), - ParentId: r.row('parentId'), - Slug: r.row('slug'), - WebLogId: r.row('webLogId') -}) - -// Page -r.db('myWebLog').table('Page').map({ - AuthorId: r.row('authorId'), - Id: r.row('id'), - Metadata: r.row('metadata').map(function (meta) { - return { Name: meta('name'), Value: meta('value') } - }), - Permalink: r.row('permalink'), - PriorPermalinks: r.row('priorPermalinks'), - PublishedOn: r.row('publishedOn'), - Revisions: r.row('revisions').map(function (rev) { - return { - AsOf: rev('asOf'), - Text: rev('text') - } - }), - IsInPageList: r.row('showInPageList'), - Template: r.row('template'), - Text: r.row('text'), - Title: r.row('title'), - UpdatedOn: r.row('updatedOn'), - WebLogId: r.row('webLogId') -}) - -// Post -r.db('myWebLog').table('Post').map({ - AuthorId: r.row('authorId'), - CategoryIds: r.row('categoryIds'), - Episode: r.branch(r.row.hasFields('episode'), { - Duration: r.row('episode')('duration'), - Length: r.row('episode')('length'), - Media: r.row('episode')('media'), - MediaType: r.row('episode')('mediaType').default(null), - ImageUrl: r.row('episode')('imageUrl').default(null), - Subtitle: r.row('episode')('subtitle').default(null), - Explicit: r.row('episode')('explicit').default(null), - ChapterFile: r.row('episode')('chapterFile').default(null), - ChapterType: r.row('episode')('chapterType').default(null), - TranscriptUrl: r.row('episode')('transcriptUrl').default(null), - TranscriptType: r.row('episode')('transcriptType').default(null), - TranscriptLang: r.row('episode')('transcriptLang').default(null), - TranscriptCaptions: r.row('episode')('transcriptCaptions').default(null), - SeasonNumber: r.row('episode')('seasonNumber').default(null), - SeasonDescription: r.row('episode')('seasonDescription').default(null), - EpisodeNumber: r.row('episode')('episodeNumber').default(null), - EpisodeDescription: r.row('episode')('episodeDescription').default(null) - }, null), - Id: r.row('id'), - Metadata: r.row('metadata').map(function (meta) { - return { Name: meta('name'), Value: meta('value') } - }), - Permalink: r.row('permalink'), - PriorPermalinks: r.row('priorPermalinks'), - PublishedOn: r.row('publishedOn'), - Revisions: r.row('revisions').map(function (rev) { - return { - AsOf: rev('asOf'), - Text: rev('text') - } - }), - Status: r.row('status'), - Tags: r.row('tags'), - Template: r.row('template').default(null), - Text: r.row('text'), - Title: r.row('title'), - UpdatedOn: r.row('updatedOn'), - WebLogId: r.row('webLogId') -}) - -// TagMap -r.db('myWebLog').table('TagMap').map({ - Id: r.row('id'), - Tag: r.row('tag'), - UrlValue: r.row('urlValue'), - WebLogId: r.row('webLogId') -}) - -// Theme -r.db('myWebLog').table('Theme').map({ - Id: r.row('id'), - Name: r.row('name'), - Templates: r.row('templates').map(function (tmpl) { - return { - Name: tmpl('name'), - Text: tmpl('text') - } - }), - Version: r.row('version') -}) - -// ThemeAsset -r.db('myWebLog').table('ThemeAsset').map({ - Data: r.row('data'), - Id: r.row('id'), - UpdatedOn: r.row('updatedOn') -}) - -// WebLog -r.db('myWebLog').table('WebLog').map( - { AutoHtmx: r.row('autoHtmx'), - DefaultPage: r.row('defaultPage'), - Id: r.row('id'), - Name: r.row('name'), - PostsPerPage: r.row('postsPerPage'), - Rss: { - IsCategoryEnabled: r.row('rss')('categoryEnabled'), - Copyright: r.row('rss')('copyright'), - CustomFeeds: r.row('rss')('customFeeds').map(function (feed) { - return { - Id: feed('id'), - Path: feed('path'), - Podcast: { - DefaultMediaType: feed('podcast')('defaultMediaType'), - DisplayedAuthor: feed('podcast')('displayedAuthor'), - Email: feed('podcast')('email'), - Explicit: feed('podcast')('explicit'), - FundingText: feed('podcast')('fundingText'), - FundingUrl: feed('podcast')('fundingUrl'), - PodcastGuid: feed('podcast')('guid'), - AppleCategory: feed('podcast')('iTunesCategory'), - AppleSubcategory: feed('podcast')('iTunesSubcategory'), - ImageUrl: feed('podcast')('imageUrl'), - ItemsInFeed: feed('podcast')('itemsInFeed'), - MediaBaseUrl: feed('podcast')('mediaBaseUrl'), - Medium: feed('podcast')('medium'), - Subtitle: feed('podcast')('subtitle'), - Summary: feed('podcast')('summary'), - Title: feed('podcast')('title') - }, - Source: feed('source') - } - }), - IsFeedEnabled: r.row('rss')('feedEnabled'), - FeedName: r.row('rss')('feedName'), - ItemsInFeed: r.row('rss')('itemsInFeed'), - IsTagEnabled: r.row('rss')('tagEnabled') - }, - Slug: r.row('slug'), - Subtitle: r.row('subtitle'), - ThemeId: r.row('themePath'), - TimeZone: r.row('timeZone'), - Uploads: r.row('uploads'), - UrlBase: r.row('urlBase') - }) - -// WebLogUser -r.db('myWebLog').table('WebLogUser').map({ - AccessLevel: r.row('authorizationLevel'), - FirstName: r.row('firstName'), - Id: r.row('id'), - LastName: r.row('lastName'), - PasswordHash: r.row('passwordHash'), - PreferredName: r.row('preferredName'), - Salt: r.row('salt'), - Url: r.row('url'), - Email: r.row('userName'), - WebLogId: r.row('webLogId'), - CreatedOn: r.branch(r.row.hasFields('createdOn'), r.row('createdOn'), r.expr(new Date(0))), - LastSeenOn: r.row('lastSeenOn').default(null) -}) diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..b50ea6d --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,10 @@ + + + net6.0 + embedded + 2.0.0.0 + 2.0.0.0 + 2.0.0 + rc1 + + diff --git a/src/MyWebLog.Data/MyWebLog.Data.fsproj b/src/MyWebLog.Data/MyWebLog.Data.fsproj index e489f9e..558c1cf 100644 --- a/src/MyWebLog.Data/MyWebLog.Data.fsproj +++ b/src/MyWebLog.Data/MyWebLog.Data.fsproj @@ -1,11 +1,5 @@  - - net6.0 - true - embedded - - diff --git a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj index 5d8c3e0..3414816 100644 --- a/src/MyWebLog.Domain/MyWebLog.Domain.fsproj +++ b/src/MyWebLog.Domain/MyWebLog.Domain.fsproj @@ -1,11 +1,5 @@  - - net6.0 - true - embedded - - diff --git a/src/MyWebLog/Handlers/Admin.fs b/src/MyWebLog/Handlers/Admin.fs index e5167ce..b27d551 100644 --- a/src/MyWebLog/Handlers/Admin.fs +++ b/src/MyWebLog/Handlers/Admin.fs @@ -244,7 +244,10 @@ let private updateAssets themeId (zip : ZipArchive) (data : IData) = backgroundT /// Get the theme name from the file name given let getThemeName (fileName : string) = let themeName = fileName.Split(".").[0].ToLowerInvariant().Replace (" ", "-") - if Regex.IsMatch (themeName, """^[a-z0-9\-]+$""") then Ok themeName else Error $"Theme name {fileName} is invalid" + if themeName.EndsWith "-theme" then + if Regex.IsMatch (themeName, """^[a-z0-9\-]+$""") then Ok (themeName.Substring (0, themeName.Length - 6)) + else Error $"Theme name {fileName} is invalid" + else Error "Theme .zip file name must end in \"-theme.zip\"" /// Load a theme from the given stream, which should contain a ZIP archive let loadThemeFromZip themeName file clean (data : IData) = backgroundTask { @@ -260,6 +263,8 @@ let loadThemeFromZip themeName file clean (data : IData) = backgroundTask { let! theme = updateTemplates theme zip do! data.Theme.Save theme do! updateAssets themeId zip data + + return theme } // POST /admin/theme/update @@ -271,7 +276,7 @@ let updateTheme : HttpHandler = requireAccess Administrator >=> fun next ctx -> let data = ctx.Data use stream = new MemoryStream () do! themeFile.CopyToAsync stream - do! loadThemeFromZip themeName stream true data + let! _ = loadThemeFromZip themeName stream true data do! ThemeAssetCache.refreshTheme (ThemeId themeName) data TemplateCache.invalidateTheme themeName do! addMessage ctx { UserMessage.success with Message = "Theme updated successfully" } diff --git a/src/MyWebLog/Handlers/User.fs b/src/MyWebLog/Handlers/User.fs index 828fab5..9b193b5 100644 --- a/src/MyWebLog/Handlers/User.fs +++ b/src/MyWebLog/Handlers/User.fs @@ -128,43 +128,6 @@ let edit usrId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> tas | None -> return! Error.notFound next ctx } -// POST /admin/user/save -let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { - let! model = ctx.BindFormAsync () - let data = ctx.Data - let tryUser = - if model.IsNew then - { WebLogUser.empty with - Id = WebLogUserId.create () - WebLogId = ctx.WebLog.Id - CreatedOn = DateTime.UtcNow - } |> someTask - else data.WebLogUser.FindById (WebLogUserId model.Id) ctx.WebLog.Id - match! tryUser with - | Some user when model.Password = model.PasswordConfirm -> - let updatedUser = model.UpdateUser user - if updatedUser.AccessLevel = Administrator && not (ctx.HasAccessLevel Administrator) then - return! goAway next ctx - else - let updatedUser = - if model.Password = "" then updatedUser - else - let salt = Guid.NewGuid () - { updatedUser with PasswordHash = hashedPassword model.Password model.Email salt; Salt = salt } - do! (if model.IsNew then data.WebLogUser.Add else data.WebLogUser.Update) updatedUser - do! addMessage ctx - { UserMessage.success with - Message = $"""{if model.IsNew then "Add" else "Updat"}ed user successfully""" - } - return! bare next ctx - | Some _ -> - do! addMessage ctx { UserMessage.error with Message = "The passwords did not match; nothing saved" } - return! - (withHxRetarget $"#user_{model.Id}" >=> showEdit { model with Password = ""; PasswordConfirm = "" }) - next ctx - | None -> return! Error.notFound next ctx -} - // POST /admin/user/{id}/delete let delete userId : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { let data = ctx.Data @@ -237,3 +200,44 @@ let saveMyInfo : HttpHandler = requireAccess Author >=> fun next ctx -> task { return! showMyInfo { model with NewPassword = ""; NewPasswordConfirm = "" } user next ctx | None -> return! Error.notFound next ctx } + +// User save is not statically compilable; not sure why, but we'll revisit it at some point +#nowarn "3511" + +// POST /admin/user/save +let save : HttpHandler = requireAccess WebLogAdmin >=> fun next ctx -> task { + let! model = ctx.BindFormAsync () + let data = ctx.Data + let tryUser = + if model.IsNew then + { WebLogUser.empty with + Id = WebLogUserId.create () + WebLogId = ctx.WebLog.Id + CreatedOn = DateTime.UtcNow + } |> someTask + else data.WebLogUser.FindById (WebLogUserId model.Id) ctx.WebLog.Id + match! tryUser with + | Some user when model.Password = model.PasswordConfirm -> + let updatedUser = model.UpdateUser user + if updatedUser.AccessLevel = Administrator && not (ctx.HasAccessLevel Administrator) then + return! goAway next ctx + else + let toUpdate = + if model.Password = "" then updatedUser + else + let salt = Guid.NewGuid () + { updatedUser with PasswordHash = hashedPassword model.Password model.Email salt; Salt = salt } + do! (if model.IsNew then data.WebLogUser.Add else data.WebLogUser.Update) toUpdate + do! addMessage ctx + { UserMessage.success with + Message = $"""{if model.IsNew then "Add" else "Updat"}ed user successfully""" + } + return! bare next ctx + | Some _ -> + do! addMessage ctx { UserMessage.error with Message = "The passwords did not match; nothing saved" } + return! + (withHxRetarget $"#user_{model.Id}" >=> showEdit { model with Password = ""; PasswordConfirm = "" }) + next ctx + | None -> return! Error.notFound next ctx +} + diff --git a/src/MyWebLog/Maintenance.fs b/src/MyWebLog/Maintenance.fs index 6fe7f57..bbbeede 100644 --- a/src/MyWebLog/Maintenance.fs +++ b/src/MyWebLog/Maintenance.fs @@ -128,6 +128,8 @@ let importLinks args sp = task { // Loading a theme and restoring a backup are not statically compilable; this is OK #nowarn "3511" +open Microsoft.Extensions.Logging + /// Load a theme from the given ZIP file let loadTheme (args : string[]) (sp : IServiceProvider) = task { if args.Length > 1 then @@ -142,8 +144,10 @@ let loadTheme (args : string[]) (sp : IServiceProvider) = task { use stream = File.Open (args[1], FileMode.Open) use copy = new MemoryStream () do! stream.CopyToAsync copy - do! Handlers.Admin.loadThemeFromZip themeName copy clean data - printfn $"Theme {themeName} loaded successfully" + let! theme = Handlers.Admin.loadThemeFromZip themeName copy clean data + let fac = sp.GetRequiredService () + let log = fac.CreateLogger "MyWebLog.Themes" + log.LogInformation $"{theme.Name} v{theme.Version} ({ThemeId.toString theme.Id}) loaded" | Error message -> eprintfn $"{message}" else eprintfn "Usage: MyWebLog load-theme [theme-zip-file-name] [*clean-load]" diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj index bdd7230..1473d53 100644 --- a/src/MyWebLog/MyWebLog.fsproj +++ b/src/MyWebLog/MyWebLog.fsproj @@ -2,11 +2,8 @@ Exe - net6.0 true false - embedded - 3391 diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs index d60d9f4..5eca40c 100644 --- a/src/MyWebLog/Program.fs +++ b/src/MyWebLog/Program.fs @@ -82,6 +82,7 @@ let showHelp () = Task.FromResult () +open System.IO open Giraffe open Giraffe.EndpointRouting open Microsoft.AspNetCore.Authentication.Cookies @@ -135,7 +136,7 @@ let rec main args = |> ignore builder.Services.AddScoped () |> ignore // Use SQLite for caching as well - let cachePath = Option.ofObj (cfg.GetConnectionString "SQLiteCachePath") |> Option.defaultValue "./session.db" + let cachePath = defaultArg (Option.ofObj (cfg.GetConnectionString "SQLiteCachePath")) "./session.db" builder.Services.AddSqliteCache (fun o -> o.CachePath <- cachePath) |> ignore | _ -> () @@ -162,7 +163,11 @@ let rec main args = | Some it -> printfn $"""Unrecognized command "{it}" - valid commands are:""" showHelp () - | None -> + | None -> task { + // Load all themes in the application directory + for themeFile in Directory.EnumerateFiles (".", "*-theme.zip") do + do! Maintenance.loadTheme [| ""; themeFile |] app.Services + let _ = app.UseForwardedHeaders () let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict)) let _ = app.UseMiddleware () @@ -172,7 +177,8 @@ let rec main args = let _ = app.UseSession () let _ = app.UseGiraffe Handlers.Routes.endpoint - Task.FromResult (app.Run ()) + app.Run () + } |> Async.AwaitTask |> Async.RunSynchronously 0 // Exit code diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json index ed42558..6c0b98c 100644 --- a/src/MyWebLog/appsettings.json +++ b/src/MyWebLog/appsettings.json @@ -1,5 +1,5 @@ { - "Generator": "myWebLog 2.0-beta05", + "Generator": "myWebLog 2.0-rc1", "Logging": { "LogLevel": { "MyWebLog.Handlers": "Information" diff --git a/src/admin-theme/version.txt b/src/admin-theme/version.txt index 72b667e..18c98a2 100644 --- a/src/admin-theme/version.txt +++ b/src/admin-theme/version.txt @@ -1,2 +1,2 @@ myWebLog Admin -2.0.0-beta05 \ No newline at end of file +2.0.0-rc1 \ No newline at end of file diff --git a/src/default-theme/index.liquid b/src/default-theme/index.liquid index f59b409..dac2eb5 100644 --- a/src/default-theme/index.liquid +++ b/src/default-theme/index.liquid @@ -1,10 +1,7 @@ {%- if is_category or is_tag %}

{{ page_title }}

- {%- if is_category %} - {%- assign cat = categories | where: "slug", slug | first -%} - {%- if cat.description %}

{{ cat.description.value }}

{% endif -%} - {%- endif %} -{%- endif %} + {%- if subtitle %}

{{ subtitle }}

{% endif -%} +{% endif %}
{% for post in model.posts %}
@@ -27,7 +24,7 @@ {%- if category_count > 0 -%} Categorized under: {% for cat in post.category_ids -%} - {%- assign this_cat = categories | where: "id", cat | first -%} + {%- assign this_cat = categories | where: "Id", cat | first -%} {{ this_cat.name }}{% unless forloop.last %}, {% endunless %} {%- assign cat_names = this_cat.name | concat: cat_names -%} {%- endfor -%} diff --git a/src/default-theme/single-post.liquid b/src/default-theme/single-post.liquid index 45c98e6..77ef861 100644 --- a/src/default-theme/single-post.liquid +++ b/src/default-theme/single-post.liquid @@ -20,7 +20,7 @@

Categorized under {% for cat_id in post.category_ids -%} - {% assign cat = categories | where: "id", cat_id | first %} + {% assign cat = categories | where: "Id", cat_id | first %} {{ cat.name }} diff --git a/src/default-theme/version.txt b/src/default-theme/version.txt index 986b14d..74f4501 100644 --- a/src/default-theme/version.txt +++ b/src/default-theme/version.txt @@ -1,2 +1,2 @@ myWebLog Default Theme -2.0.0-alpha36 \ No newline at end of file +2.0.0-rc1 \ No newline at end of file