Add anti-CSRF; add settings page
This commit is contained in:
parent
1897095ff2
commit
8ce2d5a2ed
@ -1,13 +1,5 @@
|
||||
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
|
||||
[<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
|
||||
type DashboardModel =
|
||||
{ /// The number of published posts
|
||||
@ -51,4 +31,24 @@ type DashboardModel =
|
||||
|
||||
/// The top-level categories
|
||||
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
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.CS", "MyWebLog.CS\MyWebLog.CS.csproj", "{B23A8093-28B1-4CB5-93F1-B4659516B74F}"
|
||||
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}"
|
||||
EndProject
|
||||
Global
|
||||
@ -43,10 +41,6 @@ Global
|
||||
{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.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.Build.0 = Debug|Any CPU
|
||||
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
@ -1,6 +1,7 @@
|
||||
[<RequireQualifiedAccess>]
|
||||
module MyWebLog.Handlers
|
||||
|
||||
open System.Collections.Generic
|
||||
open DotLiquid
|
||||
open Giraffe
|
||||
open Microsoft.AspNetCore.Http
|
||||
@ -40,6 +41,7 @@ module Error =
|
||||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open System.Collections.Concurrent
|
||||
open System.IO
|
||||
@ -97,38 +99,162 @@ module private Helpers =
|
||||
let webLogId ctx = (WebLogCache.getByCtx ctx).id
|
||||
|
||||
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 =
|
||||
|
||||
// GET /admin/
|
||||
let dashboard : HttpHandler =
|
||||
requiresAuthentication Error.notFound
|
||||
>=> fun next ctx -> task {
|
||||
let webLogId' = webLogId ctx
|
||||
let conn' = conn ctx
|
||||
let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId' conn'
|
||||
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!
|
||||
Hash.FromAnonymousObject
|
||||
{| page_title = "Dashboard"
|
||||
model =
|
||||
{ posts = posts
|
||||
drafts = drafts
|
||||
pages = pages
|
||||
listedPages = listed
|
||||
categories = cats
|
||||
topLevelCategories = topCats
|
||||
}
|
||||
|}
|
||||
|> viewForTheme "admin" "dashboard" None next ctx
|
||||
}
|
||||
let dashboard : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let webLogId' = webLogId ctx
|
||||
let conn' = conn ctx
|
||||
let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId' conn'
|
||||
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!
|
||||
Hash.FromAnonymousObject
|
||||
{| page_title = "Dashboard"
|
||||
model =
|
||||
{ posts = posts
|
||||
drafts = drafts
|
||||
pages = pages
|
||||
listedPages = listed
|
||||
categories = cats
|
||||
topLevelCategories = topCats
|
||||
}
|
||||
|}
|
||||
|> 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 =
|
||||
|
||||
open Microsoft.AspNetCore.Authentication;
|
||||
@ -146,12 +272,12 @@ module User =
|
||||
// GET /user/log-on
|
||||
let logOn : HttpHandler = fun next ctx -> task {
|
||||
return!
|
||||
Hash.FromAnonymousObject {| page_title = "Log On" |}
|
||||
Hash.FromAnonymousObject {| page_title = "Log On"; csrf = (csrfToken ctx) |}
|
||||
|> viewForTheme "admin" "log-on" None next ctx
|
||||
}
|
||||
|
||||
// POST /user/log-on
|
||||
let doLogOn : HttpHandler = fun next ctx -> task {
|
||||
let doLogOn : HttpHandler = validateCsrf >=> fun next ctx -> task {
|
||||
let! model = ctx.BindFormAsync<LogOnModel> ()
|
||||
match! Data.WebLogUser.findByEmail model.emailAddress (webLogId ctx) (conn ctx) with
|
||||
| Some user when user.passwordHash = hashedPassword model.password user.userName user.salt ->
|
||||
@ -181,47 +307,27 @@ module User =
|
||||
|
||||
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
|
||||
|
||||
/// The endpoints defined in the above handlers
|
||||
let endpoints = [
|
||||
GET [
|
||||
route "/" CatchAll.home
|
||||
route "/" Post.home
|
||||
]
|
||||
subRoute "/admin" [
|
||||
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" [
|
||||
|
@ -1,13 +1,7 @@
|
||||
open Giraffe.EndpointRouting
|
||||
open Microsoft.AspNetCore.Authentication.Cookies
|
||||
open Microsoft.AspNetCore.Builder
|
||||
open System.Collections.Generic
|
||||
open Microsoft.AspNetCore.Http
|
||||
open Microsoft.Extensions.Configuration
|
||||
open Microsoft.Extensions.Hosting
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open Microsoft.Extensions.Logging
|
||||
open MyWebLog
|
||||
open RethinkDb.Driver.FSharp
|
||||
open RethinkDb.Driver.Net
|
||||
open System
|
||||
|
||||
@ -103,8 +97,17 @@ let initDb args sp = task {
|
||||
return! System.Threading.Tasks.Task.CompletedTask
|
||||
}
|
||||
|
||||
|
||||
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 RethinkDb.Driver.FSharp
|
||||
|
||||
[<EntryPoint>]
|
||||
let main args =
|
||||
@ -118,7 +121,9 @@ let main args =
|
||||
opts.SlidingExpiration <- true
|
||||
opts.AccessDeniedPath <- "/forbidden")
|
||||
let _ = builder.Services.AddLogging ()
|
||||
let _ = builder.Services.AddAuthorization()
|
||||
let _ = builder.Services.AddAuthorization ()
|
||||
let _ = builder.Services.AddAntiforgery ()
|
||||
let _ = builder.Services.AddGiraffe ()
|
||||
|
||||
// Configure RethinkDB's connection
|
||||
JsonConverters.all () |> Seq.iter Converter.Serializer.Converters.Add
|
||||
@ -139,6 +144,11 @@ let main args =
|
||||
Template.RegisterSafeType (typeof<Page>, all)
|
||||
Template.RegisterSafeType (typeof<WebLog>, 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 ()
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
<h2 class="p-3 ">Log On to {{ web_log.name }}</h2>
|
||||
<article class="pb-3">
|
||||
<form action="/user/log-on" method="post">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<div class="container">
|
||||
<div class="row pb-3">
|
||||
<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