WIP on theme upload (#20)

This commit is contained in:
Daniel J. Summers 2022-07-24 16:32:37 -04:00
parent 81fe03b8f3
commit 0a32181e65
6 changed files with 66 additions and 53 deletions

View File

@ -1161,6 +1161,14 @@ type UploadFileModel =
} }
/// View model for uploading a theme
[<CLIMutable; NoComparison; NoEquality>]
type UploadThemeModel =
{ /// Whether the uploaded theme should overwrite an existing theme
DoOverwrite : bool
}
/// A message displayed to the user /// A message displayed to the user
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type UserMessage = type UserMessage =

View File

@ -169,9 +169,10 @@ module TemplateCache =
} }
/// Invalidate all template cache entries for the given theme ID /// Invalidate all template cache entries for the given theme ID
let invalidateTheme (themeId : string) = let invalidateTheme (themeId : ThemeId) =
let keyPrefix = ThemeId.toString themeId
_cache.Keys _cache.Keys
|> Seq.filter (fun key -> key.StartsWith themeId) |> Seq.filter (fun key -> key.StartsWith keyPrefix)
|> List.ofSeq |> List.ofSeq
|> List.iter (fun key -> match _cache.TryRemove key with _, _ -> ()) |> List.iter (fun key -> match _cache.TryRemove key with _, _ -> ())

View File

@ -194,11 +194,11 @@ let listThemes : HttpHandler = requireAccess Administrator >=> fun next ctx -> t
|> adminView "theme-list" next ctx |> adminView "theme-list" next ctx
} }
// GET /admin/theme/update // GET /admin/theme/new
let themeUpdatePage : HttpHandler = requireAccess Administrator >=> fun next ctx -> let addTheme : HttpHandler = requireAccess Administrator >=> fun next ctx ->
hashForPage "Upload Theme" hashForPage "Upload a Theme File"
|> withAntiCsrf ctx |> withAntiCsrf ctx
|> adminView "upload-theme" next ctx |> adminView "theme-upload" next ctx
/// Update the name and version for a theme based on the version.txt file, if present /// Update the name and version for a theme based on the version.txt file, if present
let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = backgroundTask { let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = backgroundTask {
@ -214,14 +214,6 @@ let private updateNameAndVersion (theme : Theme) (zip : ZipArchive) = background
| None -> return { theme with Name = ThemeId.toString theme.Id; Version = now () } | None -> return { theme with Name = ThemeId.toString theme.Id; Version = now () }
} }
/// Delete all theme assets, and remove templates from theme
let private checkForCleanLoad (theme : Theme) cleanLoad (data : IData) = backgroundTask {
if cleanLoad then
do! data.ThemeAsset.DeleteByTheme theme.Id
return { theme with Templates = [] }
else return theme
}
/// Update the theme with all templates from the ZIP archive /// Update the theme with all templates from the ZIP archive
let private updateTemplates (theme : Theme) (zip : ZipArchive) = backgroundTask { let private updateTemplates (theme : Theme) (zip : ZipArchive) = backgroundTask {
let tasks = let tasks =
@ -255,48 +247,62 @@ let private updateAssets themeId (zip : ZipArchive) (data : IData) = backgroundT
} }
/// Get the theme name from the file name given /// Get the theme name from the file name given
let getThemeName (fileName : string) = let getThemeIdFromFileName (fileName : string) =
let themeName = fileName.Split(".").[0].ToLowerInvariant().Replace (" ", "-") let themeName = fileName.Split(".").[0].ToLowerInvariant().Replace (" ", "-")
if themeName.EndsWith "-theme" then if themeName.EndsWith "-theme" then
if Regex.IsMatch (themeName, """^[a-z0-9\-]+$""") then Ok (themeName.Substring (0, themeName.Length - 6)) if Regex.IsMatch (themeName, """^[a-z0-9\-]+$""") then
else Error $"Theme name {fileName} is invalid" Ok (ThemeId (themeName.Substring (0, themeName.Length - 6)))
else Error $"Theme ID {fileName} is invalid"
else Error "Theme .zip file name must end in \"-theme.zip\"" else Error "Theme .zip file name must end in \"-theme.zip\""
/// Load a theme from the given stream, which should contain a ZIP archive /// Load a theme from the given stream, which should contain a ZIP archive
let loadThemeFromZip themeName file clean (data : IData) = backgroundTask { let loadThemeFromZip themeId file (data : IData) = backgroundTask {
use zip = new ZipArchive (file, ZipArchiveMode.Read) let! isNew, theme = backgroundTask {
let themeId = ThemeId themeName
let! theme = backgroundTask {
match! data.Theme.FindById themeId with match! data.Theme.FindById themeId with
| Some t -> return t | Some t -> return false, t
| None -> return { Theme.empty with Id = themeId } | None -> return true, { Theme.empty with Id = themeId }
} }
let! theme = updateNameAndVersion theme zip use zip = new ZipArchive (file, ZipArchiveMode.Read)
let! theme = checkForCleanLoad theme clean data let! theme = updateNameAndVersion theme zip
let! theme = updateTemplates theme zip if not isNew then do! data.ThemeAsset.DeleteByTheme theme.Id
let! theme = updateTemplates { theme with Templates = [] } zip
do! data.Theme.Save theme do! data.Theme.Save theme
do! updateAssets themeId zip data do! updateAssets themeId zip data
return theme return theme
} }
// POST /admin/theme/update // POST /admin/theme/new
let updateTheme : HttpHandler = requireAccess Administrator >=> fun next ctx -> task { let saveTheme : HttpHandler = requireAccess Administrator >=> fun next ctx -> task {
if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then if ctx.Request.HasFormContentType && ctx.Request.Form.Files.Count > 0 then
let themeFile = Seq.head ctx.Request.Form.Files let themeFile = Seq.head ctx.Request.Form.Files
match getThemeName themeFile.FileName with match getThemeIdFromFileName themeFile.FileName with
| Ok themeName when themeName <> "admin" -> | Ok themeId when themeId <> adminTheme ->
let data = ctx.Data let data = ctx.Data
use stream = new MemoryStream () let! theme = data.Theme.FindByIdWithoutText themeId
do! themeFile.CopyToAsync stream let isNew = Option.isNone theme
let! _ = loadThemeFromZip themeName stream true data let! model = ctx.BindFormAsync<UploadThemeModel> ()
do! ThemeAssetCache.refreshTheme (ThemeId themeName) data if isNew || model.DoOverwrite then
TemplateCache.invalidateTheme themeName // Load the theme to the database
do! addMessage ctx { UserMessage.success with Message = "Theme updated successfully" } use stream = new MemoryStream ()
return! redirectToGet "admin/dashboard" next ctx do! themeFile.CopyToAsync stream
let! _ = loadThemeFromZip themeId stream data
do! ThemeAssetCache.refreshTheme themeId data
TemplateCache.invalidateTheme themeId
// Save the .zip file
use file = new FileStream ($"{themeId}-theme.zip", FileMode.Create)
do! stream.CopyToAsync file
do! addMessage ctx { UserMessage.success with Message = "Theme updated successfully" }
return! redirectToGet "admin/dashboard" next ctx
else
do! addMessage ctx
{ UserMessage.error with
Message = "Theme exists and overwriting was not requested; nothing saved"
}
return! redirectToGet "admin/theme/new" 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 "admin/theme/update" next ctx return! redirectToGet "admin/theme/new" next ctx
| Error message -> | Error message ->
do! addMessage ctx { UserMessage.error with Message = message } do! addMessage ctx { UserMessage.error with Message = message }
return! redirectToGet "admin/theme/update" next ctx return! redirectToGet "admin/theme/update" next ctx

View File

@ -141,8 +141,8 @@ let router : HttpHandler = choose [
]) ])
]) ])
subRoute "/theme" (choose [ subRoute "/theme" (choose [
route "s" >=> Admin.listThemes route "s" >=> Admin.listThemes
route "/update" >=> Admin.themeUpdatePage route "/new" >=> Admin.addTheme
]) ])
subRoute "/upload" (choose [ subRoute "/upload" (choose [
route "s" >=> Upload.list route "s" >=> Upload.list
@ -188,7 +188,7 @@ let router : HttpHandler = choose [
routef "/%s/delete" Admin.deleteMapping routef "/%s/delete" Admin.deleteMapping
]) ])
]) ])
route "/theme/update" >=> Admin.updateTheme route "/theme/new" >=> Admin.saveTheme
subRoute "/upload" (choose [ subRoute "/upload" (choose [
route "/save" >=> Upload.save route "/save" >=> Upload.save
routexp "/delete/(.*)" Upload.deleteFromDisk routexp "/delete/(.*)" Upload.deleteFromDisk

View File

@ -132,26 +132,24 @@ open Microsoft.Extensions.Logging
/// Load a theme from the given ZIP file /// Load a theme from the given ZIP file
let loadTheme (args : string[]) (sp : IServiceProvider) = task { let loadTheme (args : string[]) (sp : IServiceProvider) = task {
if args.Length > 1 then if args.Length = 2 then
let fileName = let fileName =
match args[1].LastIndexOf Path.DirectorySeparatorChar with match args[1].LastIndexOf Path.DirectorySeparatorChar with
| -1 -> args[1] | -1 -> args[1]
| it -> args[1][(it + 1)..] | it -> args[1][(it + 1)..]
match Handlers.Admin.getThemeName fileName with match Handlers.Admin.getThemeIdFromFileName fileName with
| Ok themeName -> | Ok themeId ->
let data = sp.GetRequiredService<IData> () let data = sp.GetRequiredService<IData> ()
let clean = if args.Length > 2 then bool.Parse args[2] else true
use stream = File.Open (args[1], FileMode.Open) use stream = File.Open (args[1], FileMode.Open)
use copy = new MemoryStream () use copy = new MemoryStream ()
do! stream.CopyToAsync copy do! stream.CopyToAsync copy
let! theme = Handlers.Admin.loadThemeFromZip themeName copy clean data let! theme = Handlers.Admin.loadThemeFromZip themeId copy data
let fac = sp.GetRequiredService<ILoggerFactory> () let fac = sp.GetRequiredService<ILoggerFactory> ()
let log = fac.CreateLogger "MyWebLog.Themes" let log = fac.CreateLogger "MyWebLog.Themes"
log.LogInformation $"{theme.Name} v{theme.Version} ({ThemeId.toString theme.Id}) loaded" log.LogInformation $"{theme.Name} v{theme.Version} ({ThemeId.toString theme.Id}) loaded"
| Error message -> eprintfn $"{message}" | Error message -> eprintfn $"{message}"
else else
eprintfn "Usage: MyWebLog load-theme [theme-zip-file-name] [*clean-load]" eprintfn "Usage: MyWebLog load-theme [theme-zip-file-name]"
eprintfn " * optional, defaults to true"
} }
/// Back up a web log's data /// Back up a web log's data

View File

@ -1,6 +1,6 @@
<h2>Upload a Theme</h2> <h2>{{ page_title }}</h2>
<article> <article>
<form action="{{ "admin/theme/update" | relative_link }}" <form action="{{ "admin/theme/new" | relative_link }}"
method="post" class="container" enctype="multipart/form-data" hx-boost="false"> method="post" class="container" enctype="multipart/form-data" hx-boost="false">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="row"> <div class="row">
@ -12,8 +12,8 @@
</div> </div>
<div class="col-12 col-sm-6 pb-3"> <div class="col-12 col-sm-6 pb-3">
<div class="form-check form-switch pb-2"> <div class="form-check form-switch pb-2">
<input type="checkbox" name="clean" id="clean" class="form-check-input" value="true"> <input type="checkbox" name="DoOverwrite" id="doOverwrite" class="form-check-input" value="true">
<label for="clean" class="form-check-label">Delete Existing Theme Files</label> <label for="doOverwrite" class="form-check-label">Overwrite an Existing Theme If Required</label>
</div> </div>
</div> </div>
</div> </div>