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 | ||||||
| @ -52,3 +32,23 @@ 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 | ||||||
| @ -98,13 +100,28 @@ module private Helpers = | |||||||
|      |      | ||||||
|     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 |  | ||||||
|         >=> fun next ctx -> task { |  | ||||||
|         let webLogId' = webLogId ctx |         let webLogId' = webLogId ctx | ||||||
|         let conn' = conn ctx |         let conn' = conn ctx | ||||||
|         let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId' conn' |         let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId' conn' | ||||||
| @ -129,6 +146,115 @@ module Admin = | |||||||
|             |> 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 -> | ||||||
| @ -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 | 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 = | ||||||
| @ -119,6 +122,8 @@ let main args = | |||||||
|                 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…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user