Add anti-CSRF; add settings page
This commit is contained in:
parent
1897095ff2
commit
8ce2d5a2ed
|
@ -1,13 +1,5 @@
|
||||||
namespace MyWebLog.ViewModels
|
namespace MyWebLog.ViewModels
|
||||||
|
|
||||||
open MyWebLog
|
|
||||||
|
|
||||||
/// Base model class for myWebLog views
|
|
||||||
type MyWebLogModel (webLog : WebLog) =
|
|
||||||
|
|
||||||
/// The details for the web log
|
|
||||||
member val WebLog = webLog with get
|
|
||||||
|
|
||||||
|
|
||||||
/// The model to use to allow a user to log on
|
/// The model to use to allow a user to log on
|
||||||
[<CLIMutable>]
|
[<CLIMutable>]
|
||||||
|
@ -20,18 +12,6 @@ type LogOnModel =
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// The model used to render a single page
|
|
||||||
type SinglePageModel =
|
|
||||||
{ /// The page to be rendered
|
|
||||||
page : Page
|
|
||||||
|
|
||||||
/// The web log to which the page belongs
|
|
||||||
webLog : WebLog
|
|
||||||
}
|
|
||||||
/// Is this the home page?
|
|
||||||
member this.isHome with get () = PageId.toString this.page.id = this.webLog.defaultPage
|
|
||||||
|
|
||||||
|
|
||||||
/// The model used to display the admin dashboard
|
/// The model used to display the admin dashboard
|
||||||
type DashboardModel =
|
type DashboardModel =
|
||||||
{ /// The number of published posts
|
{ /// The number of published posts
|
||||||
|
@ -51,4 +31,24 @@ type DashboardModel =
|
||||||
|
|
||||||
/// The top-level categories
|
/// The top-level categories
|
||||||
topLevelCategories : int
|
topLevelCategories : int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// View model for editing web log settings
|
||||||
|
[<CLIMutable>]
|
||||||
|
type SettingsModel =
|
||||||
|
{ /// The name of the web log
|
||||||
|
name : string
|
||||||
|
|
||||||
|
/// The subtitle of the web log
|
||||||
|
subtitle : string
|
||||||
|
|
||||||
|
/// The default page
|
||||||
|
defaultPage : string
|
||||||
|
|
||||||
|
/// How many posts should appear on index pages
|
||||||
|
postsPerPage : int
|
||||||
|
|
||||||
|
/// The time zone in which dates/times should be displayed
|
||||||
|
timeZone : string
|
||||||
|
}
|
||||||
|
|
|
@ -1,62 +0,0 @@
|
||||||
namespace MyWebLog.Features.Admin
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Authorization
|
|
||||||
open Microsoft.AspNetCore.Mvc
|
|
||||||
open Microsoft.AspNetCore.Mvc.Rendering
|
|
||||||
open MyWebLog
|
|
||||||
open MyWebLog.Features.Shared
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
open System.Threading.Tasks
|
|
||||||
|
|
||||||
/// Controller for admin-specific displays and routes
|
|
||||||
[<Route "/admin">]
|
|
||||||
[<Authorize>]
|
|
||||||
type AdminController () =
|
|
||||||
inherit MyWebLogController ()
|
|
||||||
|
|
||||||
[<HttpGet "">]
|
|
||||||
member this.Index () = task {
|
|
||||||
let getCount (f : WebLogId -> IConnection -> Task<int>) = f this.WebLog.id this.Db
|
|
||||||
let! posts = Data.Post.countByStatus Published |> getCount
|
|
||||||
let! drafts = Data.Post.countByStatus Draft |> getCount
|
|
||||||
let! pages = Data.Page.countAll |> getCount
|
|
||||||
let! listed = Data.Page.countListed |> getCount
|
|
||||||
let! cats = Data.Category.countAll |> getCount
|
|
||||||
let! topCats = Data.Category.countTopLevel |> getCount
|
|
||||||
return this.View (DashboardModel (
|
|
||||||
this.WebLog,
|
|
||||||
Posts = posts,
|
|
||||||
Drafts = drafts,
|
|
||||||
Pages = pages,
|
|
||||||
ListedPages = listed,
|
|
||||||
Categories = cats,
|
|
||||||
TopLevelCategories = topCats
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
[<HttpGet "settings">]
|
|
||||||
member this.Settings() = task {
|
|
||||||
let! allPages = Data.Page.findAll this.WebLog.id this.Db
|
|
||||||
return this.View (SettingsModel (
|
|
||||||
this.WebLog,
|
|
||||||
DefaultPages =
|
|
||||||
(Seq.singleton (SelectListItem ("- {Resources.FirstPageOfPosts} -", "posts"))
|
|
||||||
|> Seq.append (allPages |> Seq.map (fun p -> SelectListItem (p.title, PageId.toString p.id))))
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
[<HttpPost "settings">]
|
|
||||||
member this.SaveSettings (model : SettingsModel) = task {
|
|
||||||
match! Data.WebLog.findByHost this.WebLog.urlBase this.Db with
|
|
||||||
| Some webLog ->
|
|
||||||
let updated = model.UpdateSettings webLog
|
|
||||||
do! Data.WebLog.updateSettings updated this.Db
|
|
||||||
|
|
||||||
// Update cache
|
|
||||||
WebLogCache.set (WebLogCache.hostToDb this.HttpContext) updated
|
|
||||||
|
|
||||||
// TODO: confirmation message
|
|
||||||
|
|
||||||
return this.RedirectToAction (nameof this.Index);
|
|
||||||
| None -> return this.NotFound ()
|
|
||||||
}
|
|
|
@ -1,76 +0,0 @@
|
||||||
namespace MyWebLog.Features.Admin
|
|
||||||
|
|
||||||
open MyWebLog
|
|
||||||
open MyWebLog.Features.Shared
|
|
||||||
|
|
||||||
/// The model used to display the dashboard
|
|
||||||
type DashboardModel (webLog) =
|
|
||||||
inherit MyWebLogModel (webLog)
|
|
||||||
|
|
||||||
/// The number of published posts
|
|
||||||
member val Posts = 0 with get, set
|
|
||||||
|
|
||||||
/// The number of post drafts
|
|
||||||
member val Drafts = 0 with get, set
|
|
||||||
|
|
||||||
/// The number of pages
|
|
||||||
member val Pages = 0 with get, set
|
|
||||||
|
|
||||||
/// The number of pages in the page list
|
|
||||||
member val ListedPages = 0 with get, set
|
|
||||||
|
|
||||||
/// The number of categories
|
|
||||||
member val Categories = 0 with get, set
|
|
||||||
|
|
||||||
/// The top-level categories
|
|
||||||
member val TopLevelCategories = 0 with get, set
|
|
||||||
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Mvc.Rendering
|
|
||||||
open System.ComponentModel.DataAnnotations
|
|
||||||
|
|
||||||
/// View model for editing web log settings
|
|
||||||
type SettingsModel (webLog) =
|
|
||||||
inherit MyWebLogModel (webLog)
|
|
||||||
|
|
||||||
/// Default constructor
|
|
||||||
[<System.Obsolete "Only used for model binding; use the WebLogDetails constructor">]
|
|
||||||
new() = SettingsModel WebLog.empty
|
|
||||||
|
|
||||||
/// The name of the web log
|
|
||||||
[<Required (AllowEmptyStrings = false)>]
|
|
||||||
[<Display ( ResourceType = typeof<Resources>, Name = "Name")>]
|
|
||||||
member val Name = webLog.name with get, set
|
|
||||||
|
|
||||||
/// The subtitle of the web log
|
|
||||||
[<Display(ResourceType = typeof<Resources>, Name = "Subtitle")>]
|
|
||||||
member val Subtitle = (defaultArg webLog.subtitle "") with get, set
|
|
||||||
|
|
||||||
/// The default page
|
|
||||||
[<Required>]
|
|
||||||
[<Display(ResourceType = typeof<Resources>, Name = "DefaultPage")>]
|
|
||||||
member val DefaultPage = webLog.defaultPage with get, set
|
|
||||||
|
|
||||||
/// How many posts should appear on index pages
|
|
||||||
[<Required>]
|
|
||||||
[<Display(ResourceType = typeof<Resources>, Name = "PostsPerPage")>]
|
|
||||||
[<Range(0, 50)>]
|
|
||||||
member val PostsPerPage = webLog.postsPerPage with get, set
|
|
||||||
|
|
||||||
/// The time zone in which dates/times should be displayed
|
|
||||||
[<Required>]
|
|
||||||
[<Display(ResourceType = typeof<Resources>, Name = "TimeZone")>]
|
|
||||||
member val TimeZone = webLog.timeZone with get, set
|
|
||||||
|
|
||||||
/// Possible values for the default page
|
|
||||||
member val DefaultPages = Seq.empty<SelectListItem> with get, set
|
|
||||||
|
|
||||||
/// Update the settings object from the data in this form
|
|
||||||
member this.UpdateSettings (settings : WebLog) =
|
|
||||||
{ settings with
|
|
||||||
name = this.Name
|
|
||||||
subtitle = (match this.Subtitle with "" -> None | sub -> Some sub)
|
|
||||||
defaultPage = this.DefaultPage
|
|
||||||
postsPerPage = this.PostsPerPage
|
|
||||||
timeZone = this.TimeZone
|
|
||||||
}
|
|
|
@ -1,61 +0,0 @@
|
||||||
@model DashboardModel
|
|
||||||
@{
|
|
||||||
Layout = "_AdminLayout";
|
|
||||||
ViewBag.Title = Resources.Dashboard;
|
|
||||||
}
|
|
||||||
<article class="container pt-3">
|
|
||||||
<div class="row">
|
|
||||||
<section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
|
|
||||||
<div class="card">
|
|
||||||
<header class="card-header text-white bg-primary">@Resources.Posts</header>
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-subtitle text-muted pb-3">
|
|
||||||
@Resources.Published <span class="badge rounded-pill bg-secondary">@Model.Posts</span>
|
|
||||||
@Resources.Drafts <span class="badge rounded-pill bg-secondary">@Model.Drafts</span>
|
|
||||||
</h6>
|
|
||||||
<a asp-action="All" asp-controller="Post" class="btn btn-secondary me-2">@Resources.ViewAll</a>
|
|
||||||
<a asp-action="Edit" asp-controller="Post" asp-route-id="new" class="btn btn-primary">
|
|
||||||
@Resources.WriteANewPost
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
<section class="col-lg-5 col-xl-4 pb-3">
|
|
||||||
<div class="card">
|
|
||||||
<header class="card-header text-white bg-primary">@Resources.Pages</header>
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-subtitle text-muted pb-3">
|
|
||||||
@Resources.All <span class="badge rounded-pill bg-secondary">@Model.Pages</span>
|
|
||||||
@Resources.ShownInPageList <span class="badge rounded-pill bg-secondary">@Model.ListedPages</span>
|
|
||||||
</h6>
|
|
||||||
<a asp-action="All" asp-controller="Page" class="btn btn-secondary me-2">@Resources.ViewAll</a>
|
|
||||||
<a asp-action="Edit" asp-controller="Page" asp-route-id="new" class="btn btn-primary">
|
|
||||||
@Resources.CreateANewPage
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
|
|
||||||
<div class="card">
|
|
||||||
<header class="card-header text-white bg-secondary">@Resources.Categories</header>
|
|
||||||
<div class="card-body">
|
|
||||||
<h6 class="card-subtitle text-muted pb-3">
|
|
||||||
@Resources.All <span class="badge rounded-pill bg-secondary">@Model.Categories</span>
|
|
||||||
@Resources.TopLevel <span class="badge rounded-pill bg-secondary">@Model.TopLevelCategories</span>
|
|
||||||
</h6>
|
|
||||||
<a asp-action="All" asp-controller="Category" class="btn btn-secondary me-2">@Resources.ViewAll</a>
|
|
||||||
<a asp-action="Edit" asp-controller="Category" asp-route-id="new" class="btn btn-secondary">
|
|
||||||
@Resources.AddANewCategory
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
<div class="row pb-3">
|
|
||||||
<div class="col text-end">
|
|
||||||
<a asp-action="Settings" class="btn btn-secondary">@Resources.ModifySettings</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
|
@ -1,15 +0,0 @@
|
||||||
namespace MyWebLog.Features.Pages
|
|
||||||
|
|
||||||
open MyWebLog
|
|
||||||
open MyWebLog.Features.Shared
|
|
||||||
|
|
||||||
/// The model used to render a single page
|
|
||||||
type SinglePageModel (page : Page, webLog) =
|
|
||||||
inherit MyWebLogModel (webLog)
|
|
||||||
|
|
||||||
/// The page to be rendered
|
|
||||||
member _.Page with get () = page
|
|
||||||
|
|
||||||
/// Is this the home page?
|
|
||||||
member _.IsHome with get() = PageId.toString page.id = webLog.defaultPage
|
|
||||||
|
|
|
@ -1,65 +0,0 @@
|
||||||
namespace MyWebLog.Features.Posts
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Authorization
|
|
||||||
open Microsoft.AspNetCore.Mvc
|
|
||||||
open MyWebLog
|
|
||||||
open MyWebLog.Features.Pages
|
|
||||||
open MyWebLog.Features.Shared
|
|
||||||
open System
|
|
||||||
open System.Threading.Tasks
|
|
||||||
|
|
||||||
/// Handle post-related requests
|
|
||||||
[<Route "/post">]
|
|
||||||
[<Authorize>]
|
|
||||||
type PostController () =
|
|
||||||
inherit MyWebLogController ()
|
|
||||||
|
|
||||||
[<HttpGet "~/">]
|
|
||||||
[<AllowAnonymous>]
|
|
||||||
member this.Index () = task {
|
|
||||||
match this.WebLog.defaultPage with
|
|
||||||
| "posts" -> return! this.PageOfPosts 1
|
|
||||||
| pageId ->
|
|
||||||
match! Data.Page.findById (PageId pageId) this.WebLog.id this.Db with
|
|
||||||
| Some page ->
|
|
||||||
return this.ThemedView (defaultArg page.template "SinglePage", SinglePageModel (page, this.WebLog))
|
|
||||||
| None -> return this.NotFound ()
|
|
||||||
}
|
|
||||||
|
|
||||||
[<HttpGet "~/page/{pageNbr:int}">]
|
|
||||||
[<AllowAnonymous>]
|
|
||||||
member this.PageOfPosts (pageNbr : int) = task {
|
|
||||||
let! posts = Data.Post.findPageOfPublishedPosts this.WebLog.id pageNbr this.WebLog.postsPerPage this.Db
|
|
||||||
return this.ThemedView ("Index", MultiplePostModel (posts, this.WebLog))
|
|
||||||
}
|
|
||||||
|
|
||||||
[<HttpGet "~/{*link}">]
|
|
||||||
member this.CatchAll (link : string) = task {
|
|
||||||
let permalink = Permalink link
|
|
||||||
match! Data.Post.findByPermalink permalink this.WebLog.id this.Db with
|
|
||||||
| Some post -> return this.NotFound ()
|
|
||||||
// TODO: return via single-post action
|
|
||||||
| None ->
|
|
||||||
match! Data.Page.findByPermalink permalink this.WebLog.id this.Db with
|
|
||||||
| Some page ->
|
|
||||||
return this.ThemedView (defaultArg page.template "SinglePage", SinglePageModel (page, this.WebLog))
|
|
||||||
| None ->
|
|
||||||
|
|
||||||
// TOOD: search prior permalinks for posts and pages
|
|
||||||
|
|
||||||
// We tried, we really tried...
|
|
||||||
Console.Write($"Returning 404 for permalink |{permalink}|");
|
|
||||||
return this.NotFound ()
|
|
||||||
}
|
|
||||||
|
|
||||||
[<HttpGet "all">]
|
|
||||||
member this.All () = task {
|
|
||||||
do! Task.CompletedTask;
|
|
||||||
NotImplementedException () |> raise
|
|
||||||
}
|
|
||||||
|
|
||||||
[<HttpGet "{id}/edit">]
|
|
||||||
member this.Edit(postId : string) = task {
|
|
||||||
do! Task.CompletedTask;
|
|
||||||
NotImplementedException () |> raise
|
|
||||||
}
|
|
|
@ -1,11 +0,0 @@
|
||||||
namespace MyWebLog.Features.Posts
|
|
||||||
|
|
||||||
open MyWebLog
|
|
||||||
open MyWebLog.Features.Shared
|
|
||||||
|
|
||||||
/// The model used to render multiple posts
|
|
||||||
type MultiplePostModel (posts : Post seq, webLog) =
|
|
||||||
inherit MyWebLogModel (webLog)
|
|
||||||
|
|
||||||
/// The posts to be rendered
|
|
||||||
member _.Posts with get () = posts
|
|
|
@ -1,45 +0,0 @@
|
||||||
namespace MyWebLog.Features.Shared
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Mvc
|
|
||||||
open Microsoft.Extensions.DependencyInjection
|
|
||||||
open MyWebLog
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
open System.Security.Claims
|
|
||||||
|
|
||||||
/// Base class for myWebLog controllers
|
|
||||||
type MyWebLogController () =
|
|
||||||
inherit Controller ()
|
|
||||||
|
|
||||||
/// The data context to use to fulfil this request
|
|
||||||
member this.Db with get () = this.HttpContext.RequestServices.GetRequiredService<IConnection> ()
|
|
||||||
|
|
||||||
/// The details for the current web log
|
|
||||||
member this.WebLog with get () = WebLogCache.getByCtx this.HttpContext
|
|
||||||
|
|
||||||
/// The ID of the currently authenticated user
|
|
||||||
member this.UserId with get () =
|
|
||||||
this.User.Claims
|
|
||||||
|> Seq.tryFind (fun c -> c.Type = ClaimTypes.NameIdentifier)
|
|
||||||
|> Option.map (fun c -> c.Value)
|
|
||||||
|> Option.defaultValue ""
|
|
||||||
|
|
||||||
/// Retern a themed view
|
|
||||||
member this.ThemedView (template : string, model : obj) : IActionResult =
|
|
||||||
// TODO: get actual version
|
|
||||||
this.ViewData["Version"] <- "2"
|
|
||||||
this.View (template, model)
|
|
||||||
|
|
||||||
/// Return a 404 response
|
|
||||||
member _.NotFound () : IActionResult =
|
|
||||||
base.NotFound ()
|
|
||||||
|
|
||||||
/// Redirect to an action in this controller
|
|
||||||
member _.RedirectToAction action : IActionResult =
|
|
||||||
base.RedirectToAction action
|
|
||||||
|
|
||||||
|
|
||||||
/// Base model class for myWebLog views
|
|
||||||
type MyWebLogModel (webLog : WebLog) =
|
|
||||||
|
|
||||||
/// The details for the web log
|
|
||||||
member _.WebLog with get () = webLog
|
|
|
@ -1,2 +0,0 @@
|
||||||
module MyWebLog.Handlers
|
|
||||||
|
|
|
@ -1,39 +0,0 @@
|
||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net6.0</TargetFramework>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Include="Handlers.fs" />
|
|
||||||
<Compile Include="WebLogCache.fs" />
|
|
||||||
<Compile Include="Features\Shared\SharedTypes.fs" />
|
|
||||||
<Compile Include="Features\Admin\AdminTypes.fs" />
|
|
||||||
<Compile Include="Features\Admin\AdminController.fs" />
|
|
||||||
<Compile Include="Features\Pages\PageTypes.fs" />
|
|
||||||
<Compile Include="Features\Posts\PostTypes.fs" />
|
|
||||||
<Compile Include="Features\Posts\PostController.fs" />
|
|
||||||
<Compile Include="Program.fs" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\MyWebLog.Data\MyWebLog.Data.fsproj" />
|
|
||||||
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Compile Update="Resources.Designer.fs">
|
|
||||||
<DesignTime>True</DesignTime>
|
|
||||||
<AutoGen>True</AutoGen>
|
|
||||||
<DependentUpon>Resources.resx</DependentUpon>
|
|
||||||
</Compile>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<EmbeddedResource Update="Resources.resx">
|
|
||||||
<Generator>ResXFileCodeGenerator</Generator>
|
|
||||||
<LastGenOutput>Resources.Designer.fs</LastGenOutput>
|
|
||||||
</EmbeddedResource>
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
|
@ -1,175 +0,0 @@
|
||||||
open Microsoft.AspNetCore.Mvc.Razor
|
|
||||||
open System.Reflection
|
|
||||||
|
|
||||||
/// Types to support feature folders
|
|
||||||
module FeatureSupport =
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Mvc.ApplicationModels
|
|
||||||
open System.Collections.Concurrent
|
|
||||||
|
|
||||||
/// A controller model convention that identifies the feature in which a controller exists
|
|
||||||
type FeatureControllerModelConvention () =
|
|
||||||
|
|
||||||
/// A cache of controller types to features
|
|
||||||
static let _features = ConcurrentDictionary<string, string> ()
|
|
||||||
|
|
||||||
/// Derive the feature name from the controller's type
|
|
||||||
static let getFeatureName (typ : TypeInfo) : string option =
|
|
||||||
let cacheKey = Option.ofObj typ.FullName |> Option.defaultValue ""
|
|
||||||
match _features.ContainsKey cacheKey with
|
|
||||||
| true -> Some _features[cacheKey]
|
|
||||||
| false ->
|
|
||||||
let tokens = cacheKey.Split '.'
|
|
||||||
match tokens |> Array.contains "Features" with
|
|
||||||
| true ->
|
|
||||||
let feature = tokens |> Array.skipWhile (fun it -> it <> "Features") |> Array.skip 1 |> Array.tryHead
|
|
||||||
match feature with
|
|
||||||
| Some f ->
|
|
||||||
_features[cacheKey] <- f
|
|
||||||
feature
|
|
||||||
| None -> None
|
|
||||||
| false -> None
|
|
||||||
|
|
||||||
interface IControllerModelConvention with
|
|
||||||
/// <inheritdoc />
|
|
||||||
member _.Apply (controller: ControllerModel) =
|
|
||||||
controller.Properties.Add("feature", getFeatureName controller.ControllerType)
|
|
||||||
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Mvc.Controllers
|
|
||||||
|
|
||||||
/// Expand the location token with the feature name
|
|
||||||
type FeatureViewLocationExpander () =
|
|
||||||
|
|
||||||
interface IViewLocationExpander with
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
member _.ExpandViewLocations
|
|
||||||
(context : ViewLocationExpanderContext, viewLocations : string seq) : string seq =
|
|
||||||
if isNull context then nullArg (nameof context)
|
|
||||||
if isNull viewLocations then nullArg (nameof viewLocations)
|
|
||||||
match context.ActionContext.ActionDescriptor with
|
|
||||||
| :? ControllerActionDescriptor as descriptor ->
|
|
||||||
let feature = string descriptor.Properties["feature"]
|
|
||||||
viewLocations |> Seq.map (fun location -> location.Replace ("{2}", feature))
|
|
||||||
| _ -> invalidArg "context" "ActionDescriptor not found"
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
member _.PopulateValues(_ : ViewLocationExpanderContext) = ()
|
|
||||||
|
|
||||||
|
|
||||||
open MyWebLog
|
|
||||||
|
|
||||||
/// Types to support themed views
|
|
||||||
module ThemeSupport =
|
|
||||||
|
|
||||||
/// Expand the location token with the theme path
|
|
||||||
type ThemeViewLocationExpander () =
|
|
||||||
interface IViewLocationExpander with
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
member _.ExpandViewLocations
|
|
||||||
(context : ViewLocationExpanderContext, viewLocations : string seq) : string seq =
|
|
||||||
if isNull context then nullArg (nameof context)
|
|
||||||
if isNull viewLocations then nullArg (nameof viewLocations)
|
|
||||||
|
|
||||||
viewLocations |> Seq.map (fun location -> location.Replace ("{3}", string context.Values["theme"]))
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
member _.PopulateValues (context : ViewLocationExpanderContext) =
|
|
||||||
if isNull context then nullArg (nameof context)
|
|
||||||
|
|
||||||
context.Values["theme"] <- (WebLogCache.getByCtx context.ActionContext.HttpContext).themePath
|
|
||||||
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Http
|
|
||||||
open Microsoft.Extensions.DependencyInjection
|
|
||||||
|
|
||||||
/// Custom middleware for this application
|
|
||||||
module Middleware =
|
|
||||||
|
|
||||||
open RethinkDb.Driver.Net
|
|
||||||
open System.Threading.Tasks
|
|
||||||
|
|
||||||
/// Middleware to derive the current web log
|
|
||||||
type WebLogMiddleware (next : RequestDelegate) =
|
|
||||||
|
|
||||||
member _.InvokeAsync (context : HttpContext) : Task = task {
|
|
||||||
let host = WebLogCache.hostToDb context
|
|
||||||
|
|
||||||
match WebLogCache.exists host with
|
|
||||||
| true -> ()
|
|
||||||
| false ->
|
|
||||||
let conn = context.RequestServices.GetRequiredService<IConnection> ()
|
|
||||||
match! Data.WebLog.findByHost (context.Request.Host.ToUriComponent ()) conn with
|
|
||||||
| Some details -> WebLogCache.set host details
|
|
||||||
| None -> ()
|
|
||||||
|
|
||||||
match WebLogCache.exists host with
|
|
||||||
| true -> do! next.Invoke context
|
|
||||||
| false -> context.Response.StatusCode <- 404
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Authentication.Cookies
|
|
||||||
open Microsoft.AspNetCore.Builder
|
|
||||||
open Microsoft.Extensions.Hosting
|
|
||||||
open Microsoft.AspNetCore.Mvc
|
|
||||||
open System
|
|
||||||
open System.IO
|
|
||||||
|
|
||||||
[<EntryPoint>]
|
|
||||||
let main args =
|
|
||||||
let builder = WebApplication.CreateBuilder(args)
|
|
||||||
let _ =
|
|
||||||
builder.Services
|
|
||||||
.AddMvc(fun opts ->
|
|
||||||
opts.Conventions.Add (FeatureSupport.FeatureControllerModelConvention ())
|
|
||||||
opts.Filters.Add (AutoValidateAntiforgeryTokenAttribute ()))
|
|
||||||
.AddRazorOptions(fun opts ->
|
|
||||||
opts.ViewLocationFormats.Clear ()
|
|
||||||
opts.ViewLocationFormats.Add "/Themes/{3}/{0}.cshtml"
|
|
||||||
opts.ViewLocationFormats.Add "/Themes/{3}/Shared/{0}.cshtml"
|
|
||||||
opts.ViewLocationFormats.Add "/Themes/Default/{0}.cshtml"
|
|
||||||
opts.ViewLocationFormats.Add "/Themes/Default/Shared/{0}.cshtml"
|
|
||||||
opts.ViewLocationFormats.Add "/Features/{2}/{1}/{0}.cshtml"
|
|
||||||
opts.ViewLocationFormats.Add "/Features/{2}/{0}.cshtml"
|
|
||||||
opts.ViewLocationFormats.Add "/Features/Shared/{0}.cshtml"
|
|
||||||
opts.ViewLocationExpanders.Add (FeatureSupport.FeatureViewLocationExpander ())
|
|
||||||
opts.ViewLocationExpanders.Add (ThemeSupport.ThemeViewLocationExpander ()))
|
|
||||||
let _ =
|
|
||||||
builder.Services
|
|
||||||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
|
||||||
.AddCookie(fun opts ->
|
|
||||||
opts.ExpireTimeSpan <- TimeSpan.FromMinutes 20.
|
|
||||||
opts.SlidingExpiration <- true
|
|
||||||
opts.AccessDeniedPath <- "/forbidden")
|
|
||||||
let _ = builder.Services.AddAuthorization()
|
|
||||||
let _ = builder.Services.AddSingleton<IHttpContextAccessor, HttpContextAccessor> ()
|
|
||||||
(* builder.Services.AddDbContext<WebLogDbContext>(o =>
|
|
||||||
{
|
|
||||||
// TODO: can get from DI?
|
|
||||||
var db = WebLogCache.HostToDb(new HttpContextAccessor().HttpContext!);
|
|
||||||
// "empty";
|
|
||||||
o.UseSqlite($"Data Source=Db/{db}.db");
|
|
||||||
}); *)
|
|
||||||
|
|
||||||
// Load themes
|
|
||||||
Directory.GetFiles (Directory.GetCurrentDirectory (), "MyWebLog.Themes.*.dll")
|
|
||||||
|> Array.map Assembly.LoadFile
|
|
||||||
|> ignore
|
|
||||||
|
|
||||||
let app = builder.Build ()
|
|
||||||
|
|
||||||
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
|
|
||||||
let _ = app.UseMiddleware<Middleware.WebLogMiddleware> ()
|
|
||||||
let _ = app.UseAuthentication ()
|
|
||||||
let _ = app.UseStaticFiles ()
|
|
||||||
let _ = app.UseRouting ()
|
|
||||||
let _ = app.UseAuthorization ()
|
|
||||||
let _ = app.UseEndpoints (fun endpoints -> endpoints.MapControllers () |> ignore)
|
|
||||||
|
|
||||||
app.Run()
|
|
||||||
|
|
||||||
0 // Exit code
|
|
||||||
|
|
|
@ -1,28 +0,0 @@
|
||||||
{
|
|
||||||
"iisSettings": {
|
|
||||||
"windowsAuthentication": false,
|
|
||||||
"anonymousAuthentication": true,
|
|
||||||
"iisExpress": {
|
|
||||||
"applicationUrl": "http://localhost:29920",
|
|
||||||
"sslPort": 44344
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"profiles": {
|
|
||||||
"MyWebLog.FS": {
|
|
||||||
"commandName": "Project",
|
|
||||||
"dotnetRunMessages": true,
|
|
||||||
"launchBrowser": true,
|
|
||||||
"applicationUrl": "https://localhost:7134;http://localhost:5134",
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"IIS Express": {
|
|
||||||
"commandName": "IISExpress",
|
|
||||||
"launchBrowser": true,
|
|
||||||
"environmentVariables": {
|
|
||||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,252 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<root>
|
|
||||||
<!--
|
|
||||||
Microsoft ResX Schema
|
|
||||||
|
|
||||||
Version 2.0
|
|
||||||
|
|
||||||
The primary goals of this format is to allow a simple XML format
|
|
||||||
that is mostly human readable. The generation and parsing of the
|
|
||||||
various data types are done through the TypeConverter classes
|
|
||||||
associated with the data types.
|
|
||||||
|
|
||||||
Example:
|
|
||||||
|
|
||||||
... ado.net/XML headers & schema ...
|
|
||||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
|
||||||
<resheader name="version">2.0</resheader>
|
|
||||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
|
||||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
|
||||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
|
||||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
|
||||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
|
||||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
|
||||||
</data>
|
|
||||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
|
||||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
|
||||||
<comment>This is a comment</comment>
|
|
||||||
</data>
|
|
||||||
|
|
||||||
There are any number of "resheader" rows that contain simple
|
|
||||||
name/value pairs.
|
|
||||||
|
|
||||||
Each data row contains a name, and value. The row also contains a
|
|
||||||
type or mimetype. Type corresponds to a .NET class that support
|
|
||||||
text/value conversion through the TypeConverter architecture.
|
|
||||||
Classes that don't support this are serialized and stored with the
|
|
||||||
mimetype set.
|
|
||||||
|
|
||||||
The mimetype is used for serialized objects, and tells the
|
|
||||||
ResXResourceReader how to depersist the object. This is currently not
|
|
||||||
extensible. For a given mimetype the value must be set accordingly:
|
|
||||||
|
|
||||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
|
||||||
that the ResXResourceWriter will generate, however the reader can
|
|
||||||
read any of the formats listed below.
|
|
||||||
|
|
||||||
mimetype: application/x-microsoft.net.object.binary.base64
|
|
||||||
value : The object must be serialized with
|
|
||||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
|
||||||
: and then encoded with base64 encoding.
|
|
||||||
|
|
||||||
mimetype: application/x-microsoft.net.object.soap.base64
|
|
||||||
value : The object must be serialized with
|
|
||||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
|
||||||
: and then encoded with base64 encoding.
|
|
||||||
|
|
||||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
|
||||||
value : The object must be serialized into a byte array
|
|
||||||
: using a System.ComponentModel.TypeConverter
|
|
||||||
: and then encoded with base64 encoding.
|
|
||||||
-->
|
|
||||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
|
||||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
|
||||||
<xsd:element name="root" msdata:IsDataSet="true">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:choice maxOccurs="unbounded">
|
|
||||||
<xsd:element name="metadata">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:sequence>
|
|
||||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
|
||||||
</xsd:sequence>
|
|
||||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
|
||||||
<xsd:attribute name="type" type="xsd:string" />
|
|
||||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
|
||||||
<xsd:attribute ref="xml:space" />
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
<xsd:element name="assembly">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:attribute name="alias" type="xsd:string" />
|
|
||||||
<xsd:attribute name="name" type="xsd:string" />
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
<xsd:element name="data">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:sequence>
|
|
||||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
|
||||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
|
||||||
</xsd:sequence>
|
|
||||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
|
||||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
|
||||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
|
||||||
<xsd:attribute ref="xml:space" />
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
<xsd:element name="resheader">
|
|
||||||
<xsd:complexType>
|
|
||||||
<xsd:sequence>
|
|
||||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
|
||||||
</xsd:sequence>
|
|
||||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
</xsd:choice>
|
|
||||||
</xsd:complexType>
|
|
||||||
</xsd:element>
|
|
||||||
</xsd:schema>
|
|
||||||
<resheader name="resmimetype">
|
|
||||||
<value>text/microsoft-resx</value>
|
|
||||||
</resheader>
|
|
||||||
<resheader name="version">
|
|
||||||
<value>2.0</value>
|
|
||||||
</resheader>
|
|
||||||
<resheader name="reader">
|
|
||||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
|
||||||
</resheader>
|
|
||||||
<resheader name="writer">
|
|
||||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
|
||||||
</resheader>
|
|
||||||
<data name="Actions" xml:space="preserve">
|
|
||||||
<value>Actions</value>
|
|
||||||
</data>
|
|
||||||
<data name="AddANewCategory" xml:space="preserve">
|
|
||||||
<value>Add a New Category</value>
|
|
||||||
</data>
|
|
||||||
<data name="AddANewPage" xml:space="preserve">
|
|
||||||
<value>Add a New Page</value>
|
|
||||||
</data>
|
|
||||||
<data name="Admin" xml:space="preserve">
|
|
||||||
<value>Admin</value>
|
|
||||||
</data>
|
|
||||||
<data name="All" xml:space="preserve">
|
|
||||||
<value>All</value>
|
|
||||||
</data>
|
|
||||||
<data name="Categories" xml:space="preserve">
|
|
||||||
<value>Categories</value>
|
|
||||||
</data>
|
|
||||||
<data name="CreateANewPage" xml:space="preserve">
|
|
||||||
<value>Create a New Page</value>
|
|
||||||
</data>
|
|
||||||
<data name="Dashboard" xml:space="preserve">
|
|
||||||
<value>Dashboard</value>
|
|
||||||
</data>
|
|
||||||
<data name="DateFormatString" xml:space="preserve">
|
|
||||||
<value>MMMM d, yyyy</value>
|
|
||||||
</data>
|
|
||||||
<data name="DefaultPage" xml:space="preserve">
|
|
||||||
<value>Default Page</value>
|
|
||||||
</data>
|
|
||||||
<data name="Drafts" xml:space="preserve">
|
|
||||||
<value>Drafts</value>
|
|
||||||
</data>
|
|
||||||
<data name="Edit" xml:space="preserve">
|
|
||||||
<value>Edit</value>
|
|
||||||
</data>
|
|
||||||
<data name="EditPage" xml:space="preserve">
|
|
||||||
<value>Edit Page</value>
|
|
||||||
</data>
|
|
||||||
<data name="EmailAddress" xml:space="preserve">
|
|
||||||
<value>E-mail Address</value>
|
|
||||||
</data>
|
|
||||||
<data name="FirstPageOfPosts" xml:space="preserve">
|
|
||||||
<value>First Page of Posts</value>
|
|
||||||
</data>
|
|
||||||
<data name="InListQuestion" xml:space="preserve">
|
|
||||||
<value>In List?</value>
|
|
||||||
</data>
|
|
||||||
<data name="LastUpdated" xml:space="preserve">
|
|
||||||
<value>Last Updated</value>
|
|
||||||
</data>
|
|
||||||
<data name="LogOff" xml:space="preserve">
|
|
||||||
<value>Log Off</value>
|
|
||||||
</data>
|
|
||||||
<data name="LogOn" xml:space="preserve">
|
|
||||||
<value>Log On</value>
|
|
||||||
</data>
|
|
||||||
<data name="LogOnTo" xml:space="preserve">
|
|
||||||
<value>Log On to</value>
|
|
||||||
</data>
|
|
||||||
<data name="ModifySettings" xml:space="preserve">
|
|
||||||
<value>Modify Settings</value>
|
|
||||||
</data>
|
|
||||||
<data name="Name" xml:space="preserve">
|
|
||||||
<value>Name</value>
|
|
||||||
</data>
|
|
||||||
<data name="No" xml:space="preserve">
|
|
||||||
<value>No</value>
|
|
||||||
</data>
|
|
||||||
<data name="Pages" xml:space="preserve">
|
|
||||||
<value>Pages</value>
|
|
||||||
</data>
|
|
||||||
<data name="PageText" xml:space="preserve">
|
|
||||||
<value>Page Text</value>
|
|
||||||
</data>
|
|
||||||
<data name="Password" xml:space="preserve">
|
|
||||||
<value>Password</value>
|
|
||||||
</data>
|
|
||||||
<data name="Permalink" xml:space="preserve">
|
|
||||||
<value>Permalink</value>
|
|
||||||
</data>
|
|
||||||
<data name="Posts" xml:space="preserve">
|
|
||||||
<value>Posts</value>
|
|
||||||
</data>
|
|
||||||
<data name="PostsPerPage" xml:space="preserve">
|
|
||||||
<value>Posts per Page</value>
|
|
||||||
</data>
|
|
||||||
<data name="Published" xml:space="preserve">
|
|
||||||
<value>Published</value>
|
|
||||||
</data>
|
|
||||||
<data name="SaveChanges" xml:space="preserve">
|
|
||||||
<value>Save Changes</value>
|
|
||||||
</data>
|
|
||||||
<data name="ShowInPageList" xml:space="preserve">
|
|
||||||
<value>Show in Page List</value>
|
|
||||||
</data>
|
|
||||||
<data name="ShownInPageList" xml:space="preserve">
|
|
||||||
<value>Shown in Page List</value>
|
|
||||||
</data>
|
|
||||||
<data name="Subtitle" xml:space="preserve">
|
|
||||||
<value>Subtitle</value>
|
|
||||||
</data>
|
|
||||||
<data name="ThereAreXCategories" xml:space="preserve">
|
|
||||||
<value>There are {0} categories</value>
|
|
||||||
</data>
|
|
||||||
<data name="ThereAreXPages" xml:space="preserve">
|
|
||||||
<value>There are {0} pages</value>
|
|
||||||
</data>
|
|
||||||
<data name="ThereAreXPublishedPostsAndYDrafts" xml:space="preserve">
|
|
||||||
<value>There are {0} published posts and {1} drafts</value>
|
|
||||||
</data>
|
|
||||||
<data name="TimeZone" xml:space="preserve">
|
|
||||||
<value>Time Zone</value>
|
|
||||||
</data>
|
|
||||||
<data name="Title" xml:space="preserve">
|
|
||||||
<value>Title</value>
|
|
||||||
</data>
|
|
||||||
<data name="TopLevel" xml:space="preserve">
|
|
||||||
<value>Top Level</value>
|
|
||||||
</data>
|
|
||||||
<data name="ViewAll" xml:space="preserve">
|
|
||||||
<value>View All</value>
|
|
||||||
</data>
|
|
||||||
<data name="WebLogSettings" xml:space="preserve">
|
|
||||||
<value>Web Log Settings</value>
|
|
||||||
</data>
|
|
||||||
<data name="WriteANewPost" xml:space="preserve">
|
|
||||||
<value>Write a New Post</value>
|
|
||||||
</data>
|
|
||||||
<data name="Yes" xml:space="preserve">
|
|
||||||
<value>Yes</value>
|
|
||||||
</data>
|
|
||||||
</root>
|
|
|
@ -1,27 +0,0 @@
|
||||||
/// <summary>
|
|
||||||
/// In-memory cache of web log details
|
|
||||||
/// </summary>
|
|
||||||
/// <remarks>This is filled by the middleware via the first request for each host, and can be updated via the web log
|
|
||||||
/// settings update page</remarks>
|
|
||||||
module MyWebLog.WebLogCache
|
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Http
|
|
||||||
open System.Collections.Concurrent
|
|
||||||
|
|
||||||
/// The cache of web log details
|
|
||||||
let private _cache = ConcurrentDictionary<string, WebLog> ()
|
|
||||||
|
|
||||||
/// Transform a hostname to a database name
|
|
||||||
let hostToDb (ctx : HttpContext) = ctx.Request.Host.ToUriComponent().Replace (':', '_')
|
|
||||||
|
|
||||||
/// Does a host exist in the cache?
|
|
||||||
let exists host = _cache.ContainsKey host
|
|
||||||
|
|
||||||
/// Get the details for a web log via its host
|
|
||||||
let getByHost host = _cache[host]
|
|
||||||
|
|
||||||
/// Get the details for a web log via its host
|
|
||||||
let getByCtx ctx = _cache[hostToDb ctx]
|
|
||||||
|
|
||||||
/// Set the details for a particular host
|
|
||||||
let set host details = _cache[host] <- details
|
|
|
@ -1,8 +0,0 @@
|
||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,9 +0,0 @@
|
||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"AllowedHosts": "*"
|
|
||||||
}
|
|
|
@ -13,8 +13,6 @@ Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.Data", "MyWebLog.D
|
||||||
EndProject
|
EndProject
|
||||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.CS", "MyWebLog.CS\MyWebLog.CS.csproj", "{B23A8093-28B1-4CB5-93F1-B4659516B74F}"
|
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.CS", "MyWebLog.CS\MyWebLog.CS.csproj", "{B23A8093-28B1-4CB5-93F1-B4659516B74F}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.FS.Old", "MyWebLog.FS.Old\MyWebLog.FS.Old.fsproj", "{C0AD7194-572E-4112-87C4-5235987C90C1}"
|
|
||||||
EndProject
|
|
||||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyWebLog", "MyWebLog\MyWebLog.fsproj", "{5655B63D-429F-4CCD-A14C-FBD74D987ECB}"
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "MyWebLog", "MyWebLog\MyWebLog.fsproj", "{5655B63D-429F-4CCD-A14C-FBD74D987ECB}"
|
||||||
EndProject
|
EndProject
|
||||||
Global
|
Global
|
||||||
|
@ -43,10 +41,6 @@ Global
|
||||||
{B23A8093-28B1-4CB5-93F1-B4659516B74F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{B23A8093-28B1-4CB5-93F1-B4659516B74F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{B23A8093-28B1-4CB5-93F1-B4659516B74F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{B23A8093-28B1-4CB5-93F1-B4659516B74F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{B23A8093-28B1-4CB5-93F1-B4659516B74F}.Release|Any CPU.Build.0 = Release|Any CPU
|
{B23A8093-28B1-4CB5-93F1-B4659516B74F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{C0AD7194-572E-4112-87C4-5235987C90C1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{C0AD7194-572E-4112-87C4-5235987C90C1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{C0AD7194-572E-4112-87C4-5235987C90C1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{C0AD7194-572E-4112-87C4-5235987C90C1}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module MyWebLog.Handlers
|
module MyWebLog.Handlers
|
||||||
|
|
||||||
|
open System.Collections.Generic
|
||||||
open DotLiquid
|
open DotLiquid
|
||||||
open Giraffe
|
open Giraffe
|
||||||
open Microsoft.AspNetCore.Http
|
open Microsoft.AspNetCore.Http
|
||||||
|
@ -40,6 +41,7 @@ module Error =
|
||||||
[<AutoOpen>]
|
[<AutoOpen>]
|
||||||
module private Helpers =
|
module private Helpers =
|
||||||
|
|
||||||
|
open Microsoft.AspNetCore.Antiforgery
|
||||||
open Microsoft.Extensions.DependencyInjection
|
open Microsoft.Extensions.DependencyInjection
|
||||||
open System.Collections.Concurrent
|
open System.Collections.Concurrent
|
||||||
open System.IO
|
open System.IO
|
||||||
|
@ -97,38 +99,162 @@ module private Helpers =
|
||||||
let webLogId ctx = (WebLogCache.getByCtx ctx).id
|
let webLogId ctx = (WebLogCache.getByCtx ctx).id
|
||||||
|
|
||||||
let conn (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IConnection> ()
|
let conn (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IConnection> ()
|
||||||
|
|
||||||
|
let private antiForgery (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IAntiforgery> ()
|
||||||
|
|
||||||
|
/// Get the cross-site request forgery token set
|
||||||
|
let csrfToken (ctx : HttpContext) =
|
||||||
|
(antiForgery ctx).GetAndStoreTokens ctx
|
||||||
|
|
||||||
|
/// Validate the cross-site request forgery token in the current request
|
||||||
|
let validateCsrf : HttpHandler = fun next ctx -> task {
|
||||||
|
match! (antiForgery ctx).IsRequestValidAsync ctx with
|
||||||
|
| true -> return! next ctx
|
||||||
|
| false -> return! RequestErrors.BAD_REQUEST "CSRF token invalid" next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Require a user to be logged on
|
||||||
|
let requireUser = requiresAuthentication Error.notAuthorized
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers to manipulate admin functions
|
||||||
module Admin =
|
module Admin =
|
||||||
|
|
||||||
// GET /admin/
|
// GET /admin/
|
||||||
let dashboard : HttpHandler =
|
let dashboard : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
requiresAuthentication Error.notFound
|
let webLogId' = webLogId ctx
|
||||||
>=> fun next ctx -> task {
|
let conn' = conn ctx
|
||||||
let webLogId' = webLogId ctx
|
let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId' conn'
|
||||||
let conn' = conn ctx
|
let! posts = Data.Post.countByStatus Published |> getCount
|
||||||
let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId' conn'
|
let! drafts = Data.Post.countByStatus Draft |> getCount
|
||||||
let! posts = Data.Post.countByStatus Published |> getCount
|
let! pages = Data.Page.countAll |> getCount
|
||||||
let! drafts = Data.Post.countByStatus Draft |> getCount
|
let! listed = Data.Page.countListed |> getCount
|
||||||
let! pages = Data.Page.countAll |> getCount
|
let! cats = Data.Category.countAll |> getCount
|
||||||
let! listed = Data.Page.countListed |> getCount
|
let! topCats = Data.Category.countTopLevel |> getCount
|
||||||
let! cats = Data.Category.countAll |> getCount
|
return!
|
||||||
let! topCats = Data.Category.countTopLevel |> getCount
|
Hash.FromAnonymousObject
|
||||||
return!
|
{| page_title = "Dashboard"
|
||||||
Hash.FromAnonymousObject
|
model =
|
||||||
{| page_title = "Dashboard"
|
{ posts = posts
|
||||||
model =
|
drafts = drafts
|
||||||
{ posts = posts
|
pages = pages
|
||||||
drafts = drafts
|
listedPages = listed
|
||||||
pages = pages
|
categories = cats
|
||||||
listedPages = listed
|
topLevelCategories = topCats
|
||||||
categories = cats
|
}
|
||||||
topLevelCategories = topCats
|
|}
|
||||||
}
|
|> viewForTheme "admin" "dashboard" None next ctx
|
||||||
|}
|
}
|
||||||
|> viewForTheme "admin" "dashboard" None next ctx
|
|
||||||
}
|
// GET /admin/settings
|
||||||
|
let settings : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||||
|
let webLog = WebLogCache.getByCtx ctx
|
||||||
|
let! allPages = Data.Page.findAll webLog.id (conn ctx)
|
||||||
|
return!
|
||||||
|
Hash.FromAnonymousObject
|
||||||
|
{| csrf = csrfToken ctx
|
||||||
|
model =
|
||||||
|
{ name = webLog.name
|
||||||
|
subtitle = defaultArg webLog.subtitle ""
|
||||||
|
defaultPage = webLog.defaultPage
|
||||||
|
postsPerPage = webLog.postsPerPage
|
||||||
|
timeZone = webLog.timeZone
|
||||||
|
}
|
||||||
|
pages =
|
||||||
|
seq {
|
||||||
|
KeyValuePair.Create ("posts", "- First Page of Posts -")
|
||||||
|
yield! allPages
|
||||||
|
|> List.map (fun p -> KeyValuePair.Create (PageId.toString p.id, p.title))
|
||||||
|
}
|
||||||
|
|> Array.ofSeq
|
||||||
|
web_log = webLog
|
||||||
|
page_title = "Web Log Settings"
|
||||||
|
|}
|
||||||
|
|> viewForTheme "admin" "settings" None next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /admin/settings
|
||||||
|
let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||||
|
let conn' = conn ctx
|
||||||
|
let! model = ctx.BindFormAsync<SettingsModel> ()
|
||||||
|
match! Data.WebLog.findByHost (WebLogCache.getByCtx ctx).urlBase conn' with
|
||||||
|
| Some webLog ->
|
||||||
|
let updated =
|
||||||
|
{ webLog with
|
||||||
|
name = model.name
|
||||||
|
subtitle = match model.subtitle with "" -> None | it -> Some it
|
||||||
|
defaultPage = model.defaultPage
|
||||||
|
postsPerPage = model.postsPerPage
|
||||||
|
timeZone = model.timeZone
|
||||||
|
}
|
||||||
|
do! Data.WebLog.updateSettings updated conn'
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
WebLogCache.set updated.urlBase updated
|
||||||
|
|
||||||
|
// TODO: confirmation message
|
||||||
|
|
||||||
|
return! redirectTo false "/admin/" next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers to manipulate posts
|
||||||
|
module Post =
|
||||||
|
|
||||||
|
// GET /page/{pageNbr}
|
||||||
|
let pageOfPosts (pageNbr : int) : HttpHandler = fun next ctx -> task {
|
||||||
|
let webLog = WebLogCache.getByCtx ctx
|
||||||
|
let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage (conn ctx)
|
||||||
|
let hash = Hash.FromAnonymousObject {| posts = posts |}
|
||||||
|
let title =
|
||||||
|
match pageNbr, webLog.defaultPage with
|
||||||
|
| 1, "posts" -> None
|
||||||
|
| _, "posts" -> Some $"Page {pageNbr}"
|
||||||
|
| _, _ -> Some $"Page {pageNbr} « Posts"
|
||||||
|
match title with Some ttl -> hash.Add ("page_title", ttl) | None -> ()
|
||||||
|
return! themedView "index" None next ctx hash
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /
|
||||||
|
let home : HttpHandler = fun next ctx -> task {
|
||||||
|
let webLog = WebLogCache.getByCtx ctx
|
||||||
|
match webLog.defaultPage with
|
||||||
|
| "posts" -> return! pageOfPosts 1 next ctx
|
||||||
|
| pageId ->
|
||||||
|
match! Data.Page.findById (PageId pageId) webLog.id (conn ctx) with
|
||||||
|
| Some page ->
|
||||||
|
return!
|
||||||
|
Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|
||||||
|
|> themedView "single-page" page.template next ctx
|
||||||
|
| None -> return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET *
|
||||||
|
let catchAll (link : string) : HttpHandler = fun next ctx -> task {
|
||||||
|
let webLog = WebLogCache.getByCtx ctx
|
||||||
|
let conn' = conn ctx
|
||||||
|
let permalink = Permalink link
|
||||||
|
match! Data.Post.findByPermalink permalink webLog.id conn' with
|
||||||
|
| Some post -> return! Error.notFound next ctx
|
||||||
|
// TODO: return via single-post action
|
||||||
|
| None ->
|
||||||
|
match! Data.Page.findByPermalink permalink webLog.id conn' with
|
||||||
|
| Some page ->
|
||||||
|
return!
|
||||||
|
Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|
||||||
|
|> themedView "single-page" page.template next ctx
|
||||||
|
| None ->
|
||||||
|
|
||||||
|
// TOOD: search prior permalinks for posts and pages
|
||||||
|
|
||||||
|
// We tried, we really tried...
|
||||||
|
Console.Write($"Returning 404 for permalink |{permalink}|");
|
||||||
|
return! Error.notFound next ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// Handlers to manipulate users
|
||||||
module User =
|
module User =
|
||||||
|
|
||||||
open Microsoft.AspNetCore.Authentication;
|
open Microsoft.AspNetCore.Authentication;
|
||||||
|
@ -146,12 +272,12 @@ module User =
|
||||||
// GET /user/log-on
|
// GET /user/log-on
|
||||||
let logOn : HttpHandler = fun next ctx -> task {
|
let logOn : HttpHandler = fun next ctx -> task {
|
||||||
return!
|
return!
|
||||||
Hash.FromAnonymousObject {| page_title = "Log On" |}
|
Hash.FromAnonymousObject {| page_title = "Log On"; csrf = (csrfToken ctx) |}
|
||||||
|> viewForTheme "admin" "log-on" None next ctx
|
|> viewForTheme "admin" "log-on" None next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
// POST /user/log-on
|
// POST /user/log-on
|
||||||
let doLogOn : HttpHandler = fun next ctx -> task {
|
let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task {
|
||||||
let! model = ctx.BindFormAsync<LogOnModel> ()
|
let! model = ctx.BindFormAsync<LogOnModel> ()
|
||||||
match! Data.WebLogUser.findByEmail model.emailAddress (webLogId ctx) (conn ctx) with
|
match! Data.WebLogUser.findByEmail model.emailAddress (webLogId ctx) (conn ctx) with
|
||||||
| Some user when user.passwordHash = hashedPassword model.password user.userName user.salt ->
|
| Some user when user.passwordHash = hashedPassword model.password user.userName user.salt ->
|
||||||
|
@ -181,47 +307,27 @@ module User =
|
||||||
|
|
||||||
return! redirectTo false "/" next ctx
|
return! redirectTo false "/" next ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
module CatchAll =
|
|
||||||
|
|
||||||
// GET /
|
|
||||||
let home : HttpHandler = fun next ctx -> task {
|
|
||||||
let webLog = WebLogCache.getByCtx ctx
|
|
||||||
match webLog.defaultPage with
|
|
||||||
| "posts" ->
|
|
||||||
// TODO: page of posts
|
|
||||||
return! Error.notFound next ctx
|
|
||||||
| pageId ->
|
|
||||||
match! Data.Page.findById (PageId pageId) webLog.id (conn ctx) with
|
|
||||||
| Some page ->
|
|
||||||
return!
|
|
||||||
Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|
|
||||||
|> themedView "single-page" page.template next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
let catchAll : HttpHandler = fun next ctx -> task {
|
|
||||||
let webLog = WebLogCache.getByCtx ctx
|
|
||||||
let pageId = PageId webLog.defaultPage
|
|
||||||
match! Data.Page.findById pageId webLog.id (conn ctx) with
|
|
||||||
| Some page ->
|
|
||||||
return!
|
|
||||||
Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|
|
||||||
|> themedView "single-page" page.template next ctx
|
|
||||||
| None -> return! Error.notFound next ctx
|
|
||||||
}
|
|
||||||
|
|
||||||
open Giraffe.EndpointRouting
|
open Giraffe.EndpointRouting
|
||||||
|
|
||||||
/// The endpoints defined in the above handlers
|
/// The endpoints defined in the above handlers
|
||||||
let endpoints = [
|
let endpoints = [
|
||||||
GET [
|
GET [
|
||||||
route "/" CatchAll.home
|
route "/" Post.home
|
||||||
]
|
]
|
||||||
subRoute "/admin" [
|
subRoute "/admin" [
|
||||||
GET [
|
GET [
|
||||||
route "/" Admin.dashboard
|
route "/" Admin.dashboard
|
||||||
|
route "/settings" Admin.settings
|
||||||
|
]
|
||||||
|
POST [
|
||||||
|
route "/settings" Admin.saveSettings
|
||||||
|
]
|
||||||
|
]
|
||||||
|
subRoute "/page" [
|
||||||
|
GET [
|
||||||
|
routef "/%d" Post.pageOfPosts
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
subRoute "/user" [
|
subRoute "/user" [
|
||||||
|
|
|
@ -1,13 +1,7 @@
|
||||||
open Giraffe.EndpointRouting
|
open System.Collections.Generic
|
||||||
open Microsoft.AspNetCore.Authentication.Cookies
|
|
||||||
open Microsoft.AspNetCore.Builder
|
|
||||||
open Microsoft.AspNetCore.Http
|
open Microsoft.AspNetCore.Http
|
||||||
open Microsoft.Extensions.Configuration
|
|
||||||
open Microsoft.Extensions.Hosting
|
|
||||||
open Microsoft.Extensions.DependencyInjection
|
open Microsoft.Extensions.DependencyInjection
|
||||||
open Microsoft.Extensions.Logging
|
|
||||||
open MyWebLog
|
open MyWebLog
|
||||||
open RethinkDb.Driver.FSharp
|
|
||||||
open RethinkDb.Driver.Net
|
open RethinkDb.Driver.Net
|
||||||
open System
|
open System
|
||||||
|
|
||||||
|
@ -103,8 +97,17 @@ let initDb args sp = task {
|
||||||
return! System.Threading.Tasks.Task.CompletedTask
|
return! System.Threading.Tasks.Task.CompletedTask
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
open DotLiquid
|
open DotLiquid
|
||||||
|
open Giraffe
|
||||||
|
open Giraffe.EndpointRouting
|
||||||
|
open Microsoft.AspNetCore.Antiforgery
|
||||||
|
open Microsoft.AspNetCore.Authentication.Cookies
|
||||||
|
open Microsoft.AspNetCore.Builder
|
||||||
|
open Microsoft.Extensions.Configuration
|
||||||
|
open Microsoft.Extensions.Logging
|
||||||
open MyWebLog.ViewModels
|
open MyWebLog.ViewModels
|
||||||
|
open RethinkDb.Driver.FSharp
|
||||||
|
|
||||||
[<EntryPoint>]
|
[<EntryPoint>]
|
||||||
let main args =
|
let main args =
|
||||||
|
@ -118,7 +121,9 @@ let main args =
|
||||||
opts.SlidingExpiration <- true
|
opts.SlidingExpiration <- true
|
||||||
opts.AccessDeniedPath <- "/forbidden")
|
opts.AccessDeniedPath <- "/forbidden")
|
||||||
let _ = builder.Services.AddLogging ()
|
let _ = builder.Services.AddLogging ()
|
||||||
let _ = builder.Services.AddAuthorization()
|
let _ = builder.Services.AddAuthorization ()
|
||||||
|
let _ = builder.Services.AddAntiforgery ()
|
||||||
|
let _ = builder.Services.AddGiraffe ()
|
||||||
|
|
||||||
// Configure RethinkDB's connection
|
// Configure RethinkDB's connection
|
||||||
JsonConverters.all () |> Seq.iter Converter.Serializer.Converters.Add
|
JsonConverters.all () |> Seq.iter Converter.Serializer.Converters.Add
|
||||||
|
@ -139,6 +144,11 @@ let main args =
|
||||||
Template.RegisterSafeType (typeof<Page>, all)
|
Template.RegisterSafeType (typeof<Page>, all)
|
||||||
Template.RegisterSafeType (typeof<WebLog>, all)
|
Template.RegisterSafeType (typeof<WebLog>, all)
|
||||||
Template.RegisterSafeType (typeof<DashboardModel>, all)
|
Template.RegisterSafeType (typeof<DashboardModel>, all)
|
||||||
|
Template.RegisterSafeType (typeof<SettingsModel>, all)
|
||||||
|
|
||||||
|
Template.RegisterSafeType (typeof<AntiforgeryTokenSet>, all)
|
||||||
|
Template.RegisterSafeType (typeof<Option<_>>, all) // doesn't quite get the job done....
|
||||||
|
Template.RegisterSafeType (typeof<KeyValuePair>, all)
|
||||||
|
|
||||||
let app = builder.Build ()
|
let app = builder.Build ()
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<h2 class="p-3 ">Log On to {{ web_log.name }}</h2>
|
<h2 class="p-3 ">Log On to {{ web_log.name }}</h2>
|
||||||
<article class="pb-3">
|
<article class="pb-3">
|
||||||
<form action="/user/log-on" method="post">
|
<form action="/user/log-on" method="post">
|
||||||
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<div class="row pb-3">
|
<div class="row pb-3">
|
||||||
<div class="col col-md-6 col-lg-4 offset-lg-2">
|
<div class="col col-md-6 col-lg-4 offset-lg-2">
|
||||||
|
|
55
src/MyWebLog/themes/admin/settings.liquid
Normal file
55
src/MyWebLog/themes/admin/settings.liquid
Normal file
|
@ -0,0 +1,55 @@
|
||||||
|
<article class="pt-3">
|
||||||
|
<form action="/admin/settings" method="post">
|
||||||
|
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 offset-xl-2 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="text" name="name" id="name" class="form-control" value="{{ model.name }}" required autofocus>
|
||||||
|
<label for="name">Name</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6 col-xl-4 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="text" name="subtitle" id="subtitle" class="form-control" value="{{ model.subtitle }}">
|
||||||
|
<label for="subtitle">Subtitle</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 col-md-4 col-xl-2 offset-xl-2 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="number" name="postsPerPage" id="postsPerPage" class="form-control" min="0" max="50" required
|
||||||
|
value="{{ model.posts_per_page }}">
|
||||||
|
<label for="postsPerPage">Posts per Page</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4 col-xl-3 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<input type="text" name="timeZone" id="timeZone" class="form-control" required
|
||||||
|
value="{{ model.time_zone }}">
|
||||||
|
<label for="timeZone">Time Zone</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-4 col-xl-3 pb-3">
|
||||||
|
<div class="form-floating">
|
||||||
|
<select name="defaultPage" id="defaultPage" class="form-control" required>
|
||||||
|
{% for pg in pages -%}
|
||||||
|
<option value="{{ pg[0] }}"
|
||||||
|
{%- if pg[0] == model.default_page %} selected="selected"{% endif %}>
|
||||||
|
{{ pg[1] }}
|
||||||
|
</option>
|
||||||
|
{%- endfor %}
|
||||||
|
</select>
|
||||||
|
<label for="defaultPage">Default Page</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row pb-3">
|
||||||
|
<div class="col text-center">
|
||||||
|
<button type="submit" class="btn btn-primary">Save Changes</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</article>
|
Loading…
Reference in New Issue
Block a user