Add anti-CSRF; add settings page

This commit is contained in:
Daniel J. Summers 2022-04-18 01:05:06 -04:00
parent 1897095ff2
commit 8ce2d5a2ed
21 changed files with 260 additions and 969 deletions

View File

@ -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
@ -52,3 +32,23 @@ 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
}

View File

@ -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 ()
}

View File

@ -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
}

View File

@ -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>
&nbsp; @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>
&nbsp; @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>
&nbsp; @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>

View File

@ -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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

@ -1,2 +0,0 @@
module MyWebLog.Handlers

View File

@ -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>

View File

@ -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

View File

@ -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"
}
}
}
}

View File

@ -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>

View File

@ -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

View File

@ -1,8 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -1,9 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -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

View File

@ -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
@ -98,13 +100,28 @@ module private Helpers =
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 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'
@ -129,6 +146,115 @@ module Admin =
|> 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} &#xab; 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 ->
@ -183,45 +309,25 @@ module User =
}
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 "/settings" Admin.settings
]
POST [
route "/settings" Admin.saveSettings
]
]
subRoute "/page" [
GET [
routef "/%d" Post.pageOfPosts
]
]
subRoute "/user" [

View File

@ -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 =
@ -119,6 +122,8 @@ let main args =
opts.AccessDeniedPath <- "/forbidden")
let _ = builder.Services.AddLogging ()
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 ()

View File

@ -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">

View 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>