WIP on DotLiquid support

This commit is contained in:
2022-04-16 23:06:38 -04:00
parent 62f7896621
commit 98eb2b1785
78 changed files with 451 additions and 45 deletions

View File

@@ -1 +0,0 @@
*.db*

View File

@@ -1,53 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace MyWebLog.Features.Admin;
/// <summary>
/// Controller for admin-specific displays and routes
/// </summary>
[Route("/admin")]
[Authorize]
public class AdminController : MyWebLogController
{
/// <inheritdoc />
public AdminController(WebLogDbContext db) : base(db) { }
[HttpGet("")]
public async Task<IActionResult> Index() =>
View(new DashboardModel(WebLog)
{
Posts = await Db.Posts.CountByStatus(PostStatus.Published),
Drafts = await Db.Posts.CountByStatus(PostStatus.Draft),
Pages = await Db.Pages.CountAll(),
ListedPages = await Db.Pages.CountListed(),
Categories = await Db.Categories.CountAll(),
TopLevelCategories = await Db.Categories.CountTopLevel()
});
[HttpGet("settings")]
public async Task<IActionResult> Settings() =>
View(new SettingsModel(WebLog)
{
DefaultPages = Enumerable.Repeat(new SelectListItem($"- {Resources.FirstPageOfPosts} -", "posts"), 1)
.Concat((await Db.Pages.FindAll()).Select(p => new SelectListItem(p.Title, p.Id)))
});
[HttpPost("settings")]
public async Task<IActionResult> SaveSettings(SettingsModel model)
{
var details = await Db.WebLogDetails.GetByHost(WebLog.UrlBase);
if (details is null) return NotFound();
model.PopulateSettings(details);
await Db.SaveChangesAsync();
// Update cache
WebLogCache.Set(WebLogCache.HostToDb(HttpContext), (await Db.WebLogDetails.FindByHost(WebLog.UrlBase))!);
// TODO: confirmation message
return RedirectToAction(nameof(Index));
}
}

View File

@@ -1,40 +0,0 @@
namespace MyWebLog.Features.Admin;
/// <summary>
/// The model used to display the dashboard
/// </summary>
public class DashboardModel : MyWebLogModel
{
/// <summary>
/// The number of published posts
/// </summary>
public int Posts { get; set; } = 0;
/// <summary>
/// The number of post drafts
/// </summary>
public int Drafts { get; set; } = 0;
/// <summary>
/// The number of pages
/// </summary>
public int Pages { get; set; } = 0;
/// <summary>
/// The number of pages in the page list
/// </summary>
public int ListedPages { get; set; } = 0;
/// <summary>
/// The number of categories
/// </summary>
public int Categories { get; set; } = 0;
/// <summary>
/// The top-level categories
/// </summary>
public int TopLevelCategories { get; set; } = 0;
/// <inheritdoc />
public DashboardModel(WebLogDetails webLog) : base(webLog) { }
}

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,50 +0,0 @@
@model SettingsModel
@{
Layout = "_AdminLayout";
ViewBag.Title = Resources.WebLogSettings;
}
<article class="pt-3">
<form asp-action="SaveSettings" method="post">
<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" asp-for="Name" class="form-control">
<label asp-for="Name"></label>
</div>
</div>
<div class="col-12 col-md-6 col-xl-4 pb-3">
<div class="form-floating">
<input type="text" asp-for="Subtitle" class="form-control">
<label asp-for="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" asp-for="PostsPerPage" class="form-control" min="0" max="50">
<label asp-for="PostsPerPage"></label>
</div>
</div>
<div class="col-12 col-md-4 col-xl-3 pb-3">
<div class="form-floating">
<input type="text" asp-for="TimeZone" class="form-control">
<label asp-for="TimeZone"></label>
</div>
</div>
<div class="col-12 col-md-4 col-xl-3 pb-3">
<div class="form-floating">
<select asp-for="DefaultPage" asp-items="Model.DefaultPages" class="form-control"></select>
<label asp-for="DefaultPage"></label>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col text-center">
<button type="submit" class="btn btn-primary">@Resources.SaveChanges</button>
</div>
</div>
</div>
</form>
</article>

View File

@@ -1,76 +0,0 @@
using Microsoft.AspNetCore.Mvc.Rendering;
using System.ComponentModel.DataAnnotations;
namespace MyWebLog.Features.Admin;
/// <summary>
/// View model for editing web log settings
/// </summary>
public class SettingsModel : MyWebLogModel
{
/// <summary>
/// The name of the web log
/// </summary>
[Required(AllowEmptyStrings = false)]
[Display(ResourceType = typeof(Resources), Name = "Name")]
public string Name { get; set; } = "";
/// <summary>
/// The subtitle of the web log
/// </summary>
[Display(ResourceType = typeof(Resources), Name = "Subtitle")]
public string Subtitle { get; set; } = "";
/// <summary>
/// The default page
/// </summary>
[Required]
[Display(ResourceType = typeof(Resources), Name = "DefaultPage")]
public string DefaultPage { get; set; } = "";
/// <summary>
/// How many posts should appear on index pages
/// </summary>
[Required]
[Display(ResourceType = typeof(Resources), Name = "PostsPerPage")]
[Range(0, 50)]
public byte PostsPerPage { get; set; } = 10;
/// <summary>
/// The time zone in which dates/times should be displayed
/// </summary>
[Required]
[Display(ResourceType = typeof(Resources), Name = "TimeZone")]
public string TimeZone { get; set; } = "";
/// <summary>
/// Possible values for the default page
/// </summary>
public IEnumerable<SelectListItem> DefaultPages { get; set; } = Enumerable.Empty<SelectListItem>();
[Obsolete("Only used for model binding; use the WebLogDetails constructor")]
public SettingsModel() : base(new()) { }
/// <inheritdoc />
public SettingsModel(WebLogDetails webLog) : base(webLog)
{
Name = webLog.Name;
Subtitle = webLog.Subtitle ?? "";
DefaultPage = webLog.DefaultPage;
PostsPerPage = webLog.PostsPerPage;
TimeZone = webLog.TimeZone;
}
/// <summary>
/// Populate the settings object from the data in this form
/// </summary>
/// <param name="settings">The settings to be updated</param>
public void PopulateSettings(WebLogDetails settings)
{
settings.Name = Name;
settings.Subtitle = Subtitle == "" ? null : Subtitle;
settings.DefaultPage = DefaultPage;
settings.PostsPerPage = PostsPerPage;
settings.TimeZone = TimeZone;
}
}

View File

@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MyWebLog.Features.Categories;
/// <summary>
/// Handle routes for categories
/// </summary>
[Route("/category")]
[Authorize]
public class CategoryController : MyWebLogController
{
/// <inheritdoc />
public CategoryController(WebLogDbContext db) : base(db) { }
[HttpGet("all")]
public async Task<IActionResult> All()
{
await Task.CompletedTask;
throw new NotImplementedException();
}
[HttpGet("{id}/edit")]
public async Task<IActionResult> Edit(string id)
{
await Task.CompletedTask;
throw new NotImplementedException();
}
}

View File

@@ -1,67 +0,0 @@
using Microsoft.AspNetCore.Mvc.ApplicationModels;
using Microsoft.AspNetCore.Mvc.Controllers;
using Microsoft.AspNetCore.Mvc.Razor;
using System.Collections.Concurrent;
using System.Reflection;
namespace MyWebLog.Features;
/// <summary>
/// A controller model convention that identifies the feature in which a controller exists
/// </summary>
public class FeatureControllerModelConvention : IControllerModelConvention
{
/// <summary>
/// A cache of controller types to features
/// </summary>
private static readonly ConcurrentDictionary<string, string> _features = new();
/// <summary>
/// Derive the feature name from the controller's type
/// </summary>
private static string? GetFeatureName(TypeInfo typ)
{
var cacheKey = typ.FullName ?? "";
if (_features.ContainsKey(cacheKey)) return _features[cacheKey];
var tokens = cacheKey.Split('.');
if (tokens.Any(it => it == "Features"))
{
var feature = tokens.SkipWhile(it => it != "Features").Skip(1).Take(1).FirstOrDefault();
if (feature is not null)
{
_features[cacheKey] = feature;
return feature;
}
}
return null;
}
/// <inheritdoc />
public void Apply(ControllerModel controller) =>
controller.Properties.Add("feature", GetFeatureName(controller.ControllerType));
}
/// <summary>
/// Expand the location token with the feature name
/// </summary>
public class FeatureViewLocationExpander : IViewLocationExpander
{
/// <inheritdoc />
public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context,
IEnumerable<string> viewLocations)
{
_ = context ?? throw new ArgumentNullException(nameof(context));
_ = viewLocations ?? throw new ArgumentNullException(nameof(viewLocations));
if (context.ActionContext.ActionDescriptor is not ControllerActionDescriptor descriptor)
throw new ArgumentException("ActionDescriptor not found");
var feature = descriptor.Properties["feature"] as string ?? "";
foreach (var location in viewLocations)
yield return location.Replace("{2}", feature);
}
/// <inheritdoc />
public void PopulateValues(ViewLocationExpanderContext _) { }
}

View File

@@ -1,37 +0,0 @@
@model PageListModel
@{
Layout = "_AdminLayout";
ViewBag.Title = Resources.Pages;
}
<article class="container">
<a asp-action="Edit" asp-route-id="new" class="btn btn-primary btn-sm my-3">@Resources.CreateANewPage</a>
<table class="table table-sm table-striped table-hover">
<thead>
<tr>
<th scope="col">@Resources.Actions</th>
<th scope="col">@Resources.Title</th>
<th scope="col">@Resources.InListQuestion</th>
<th scope="col">@Resources.LastUpdated</th>
</tr>
</thead>
<tbody>
@foreach (var pg in Model.Pages)
{
<tr>
<td>
<a asp-action="Edit" asp-route-id="@pg.Id">@Resources.Edit</a>
</td>
<td>
@pg.Title
@if (pg.Id == Model.WebLog.DefaultPage)
{
<text> &nbsp; </text><span class="badge bg-success">HOME PAGE</span>
}
</td>
<td><yes-no asp-for="pg.ShowInPageList" /></td>
<td>@pg.UpdatedOn.ToString(Resources.DateFormatString)</td>
</tr>
}
</tbody>
</table>
</article>

View File

@@ -1,56 +0,0 @@
@model EditPageModel
@{
Layout = "_AdminLayout";
ViewBag.Title = Model.IsNew ? Resources.AddANewPage : Resources.EditPage;
}
<article>
<h2 class="pb-3">@ViewBag.Title</h2>
<form asp-action="Save" asp-route-id="@Model.PageId" method="post">
<input type="hidden" asp-for="PageId">
<div class="container">
<div class="row mb-3">
<div class="col">
<div class="form-floating">
<input type="text" asp-for="Title" class="form-control" autofocus>
<label asp-for="Title"></label>
<span asp-validation-for="Title"></span>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-9">
<div class="form-floating">
<input type="text" asp-for="Permalink" class="form-control">
<label asp-for="Permalink"></label>
<span asp-validation-for="Permalink"></span>
</div>
</div>
<div class="col-3 align-self-center">
<div class="form-check form-switch">
<input type="checkbox" asp-for="IsShownInPageList" class="form-check-input">
<label asp-for="IsShownInPageList" class="form-check-label"></label>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col">
<label asp-for="Text"></label> &nbsp; &nbsp;
<input type="radio" asp-for="Source" id="source_html" class="btn-check" value="@RevisionSource.Html">
<label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label>
<input type="radio" asp-for="Source" id="source_md" class="btn-check" value="@RevisionSource.Markdown">
<label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label>
</div>
</div>
<div class="row mb-3">
<div class="col">
<textarea asp-for="Text" class="form-control" rows="10"></textarea>
</div>
</div>
<div class="row mb-3">
<div class="col">
<button type="submit" class="btn btn-primary">@Resources.SaveChanges</button>
</div>
</div>
</div>
</form>
</article>

View File

@@ -1,99 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace MyWebLog.Features.Pages;
/// <summary>
/// Model used to edit pages
/// </summary>
public class EditPageModel : MyWebLogModel
{
/// <summary>
/// The ID of the page being edited
/// </summary>
public string PageId { get; set; } = "new";
/// <summary>
/// Whether this is a new page
/// </summary>
public bool IsNew => PageId == "new";
/// <summary>
/// The title of the page
/// </summary>
[Display(ResourceType = typeof(Resources), Name = "Title")]
[Required(AllowEmptyStrings = false)]
public string Title { get; set; } = "";
/// <summary>
/// The permalink for the page
/// </summary>
[Display(ResourceType = typeof(Resources), Name = "Permalink")]
[Required(AllowEmptyStrings = false)]
public string Permalink { get; set; } = "";
/// <summary>
/// Whether this page is shown in the page list
/// </summary>
[Display(ResourceType = typeof(Resources), Name = "ShowInPageList")]
public bool IsShownInPageList { get; set; } = false;
/// <summary>
/// The source format for the text
/// </summary>
public RevisionSource Source { get; set; } = RevisionSource.Html;
/// <summary>
/// The text of the page
/// </summary>
[Display(ResourceType = typeof(Resources), Name = "PageText")]
[Required(AllowEmptyStrings = false)]
public string Text { get; set; } = "";
[Obsolete("Only used for model binding; use the WebLogDetails constructor")]
public EditPageModel() : base(new()) { }
/// <inheritdoc />
public EditPageModel(WebLogDetails webLog) : base(webLog) { }
/// <summary>
/// Create a model from an existing page
/// </summary>
/// <param name="page">The page from which the model will be created</param>
/// <param name="webLog">The web log to which the page belongs</param>
/// <returns>A populated model</returns>
public static EditPageModel CreateFromPage(Page page, WebLogDetails webLog)
{
var lastRev = page.Revisions.OrderByDescending(r => r.AsOf).First();
return new(webLog)
{
PageId = page.Id,
Title = page.Title,
Permalink = page.Permalink,
IsShownInPageList = page.ShowInPageList,
Source = lastRev.SourceType,
Text = lastRev.Text
};
}
/// <summary>
/// Populate a page from the values contained in this page
/// </summary>
/// <param name="page">The page to be populated</param>
/// <returns>The populated page</returns>
public Page? PopulatePage(Page? page)
{
if (page == null) return null;
page.Title = Title;
page.Permalink = Permalink;
page.ShowInPageList = IsShownInPageList;
page.Revisions.Add(new()
{
Id = WebLogDbContext.NewId(),
AsOf = DateTime.UtcNow,
SourceType = Source,
Text = Text
});
return page;
}
}

View File

@@ -1,64 +0,0 @@
using Markdig;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace MyWebLog.Features.Pages;
/// <summary>
/// Handle routes for pages
/// </summary>
[Route("/page")]
[Authorize]
public class PageController : MyWebLogController
{
/// <summary>
/// Pipeline with most extensions enabled
/// </summary>
private readonly MarkdownPipeline _pipeline = new MarkdownPipelineBuilder()
.UseSmartyPants().UseAdvancedExtensions().Build();
/// <inheritdoc />
public PageController(WebLogDbContext db) : base(db) { }
[HttpGet("all")]
[HttpGet("all/page/{pageNbr:int}")]
public async Task<IActionResult> All(int? pageNbr) =>
View(new PageListModel(await Db.Pages.FindPageOfPages(pageNbr ?? 1), WebLog));
[HttpGet("{id}/edit")]
public async Task<IActionResult> Edit(string id)
{
if (id == "new") return View(new EditPageModel(WebLog));
var page = await Db.Pages.FindByIdWithRevisions(id);
if (page == null) return NotFound();
return View(EditPageModel.CreateFromPage(page, WebLog));
}
[HttpPost("{id}/edit")]
public async Task<IActionResult> Save(EditPageModel model)
{
var page = model.PopulatePage(model.IsNew
? new()
{
Id = WebLogDbContext.NewId(),
AuthorId = UserId,
PublishedOn = DateTime.UtcNow,
Revisions = new List<PageRevision>()
}
: await Db.Pages.GetById(model.PageId));
if (page == null) return NotFound();
page.Text = model.Source == RevisionSource.Html ? model.Text : Markdown.ToHtml(model.Text, _pipeline);
page.UpdatedOn = DateTime.UtcNow;
if (model.IsNew) await Db.Pages.AddAsync(page);
await Db.SaveChangesAsync();
// TODO: confirmation
return RedirectToAction(nameof(All));
}
}

View File

@@ -1,19 +0,0 @@
namespace MyWebLog.Features.Pages;
/// <summary>
/// View model for viewing a list of pages
/// </summary>
public class PageListModel : MyWebLogModel
{
public IList<Page> Pages { get; init; }
/// <summary>
/// Constructor
/// </summary>
/// <param name="pages">The pages to display</param>
/// <param name="webLog">The web log details</param>
public PageListModel(IList<Page> pages, WebLogDetails webLog) : base(webLog)
{
Pages = pages;
}
}

View File

@@ -1,27 +0,0 @@
namespace MyWebLog.Features.Pages;
/// <summary>
/// The model used to render a single page
/// </summary>
public class SinglePageModel : MyWebLogModel
{
/// <summary>
/// The page to be rendered
/// </summary>
public Page Page { get; init; }
/// <summary>
/// Is this the home page?
/// </summary>
public bool IsHome => Page.Id == WebLog.DefaultPage;
/// <summary>
/// Constructor
/// </summary>
/// <param name="page">The page to be rendered</param>
/// <param name="webLog">The details for the web log</param>
public SinglePageModel(Page page, WebLogDetails webLog) : base(webLog)
{
Page = page;
}
}

View File

@@ -1,22 +0,0 @@
namespace MyWebLog.Features.Posts;
/// <summary>
/// The model used to render multiple posts
/// </summary>
public class MultiplePostModel : MyWebLogModel
{
/// <summary>
/// The posts to be rendered
/// </summary>
public IEnumerable<Post> Posts { get; init; }
/// <summary>
/// Constructor
/// </summary>
/// <param name="posts">The posts to be rendered</param>
/// <param name="webLog">The details for the web log</param>
public MultiplePostModel(IEnumerable<Post> posts, WebLogDetails webLog) : base(webLog)
{
Posts = posts;
}
}

View File

@@ -1,68 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using MyWebLog.Features.Pages;
namespace MyWebLog.Features.Posts;
/// <summary>
/// Handle post-related requests
/// </summary>
[Route("/post")]
[Authorize]
public class PostController : MyWebLogController
{
/// <inheritdoc />
public PostController(WebLogDbContext db) : base(db) { }
[HttpGet("~/")]
[AllowAnonymous]
public async Task<IActionResult> Index()
{
if (WebLog.DefaultPage == "posts") return await PageOfPosts(1);
var page = await Db.Pages.FindById(WebLog.DefaultPage);
return page is null ? NotFound() : ThemedView(page.Template ?? "SinglePage", new SinglePageModel(page, WebLog));
}
[HttpGet("~/page/{pageNbr:int}")]
[AllowAnonymous]
public async Task<IActionResult> PageOfPosts(int pageNbr) =>
ThemedView("Index",
new MultiplePostModel(await Db.Posts.FindPageOfPublishedPosts(pageNbr, WebLog.PostsPerPage), WebLog));
[HttpGet("~/{*permalink}")]
public async Task<IActionResult> CatchAll(string permalink)
{
var post = await Db.Posts.FindByPermalink(permalink);
if (post != null)
{
// TODO: return via single-post action
}
var page = await Db.Pages.FindByPermalink(permalink);
if (page != null)
{
return ThemedView(page.Template ?? "SinglePage", new SinglePageModel(page, WebLog));
}
// TOOD: search prior permalinks for posts and pages
// We tried, we really tried...
Console.Write($"Returning 404 for permalink |{permalink}|");
return NotFound();
}
[HttpGet("all")]
public async Task<IActionResult> All()
{
await Task.CompletedTask;
throw new NotImplementedException();
}
[HttpGet("{id}/edit")]
public async Task<IActionResult> Edit(string id)
{
await Task.CompletedTask;
throw new NotImplementedException();
}
}

View File

@@ -1,41 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
namespace MyWebLog.Features.Shared;
/// <summary>
/// Base class for myWebLog controllers
/// </summary>
public abstract class MyWebLogController : Controller
{
/// <summary>
/// The data context to use to fulfil this request
/// </summary>
protected WebLogDbContext Db { get; init; }
/// <summary>
/// The details for the current web log
/// </summary>
protected WebLogDetails WebLog => WebLogCache.Get(HttpContext);
/// <summary>
/// The ID of the currently authenticated user
/// </summary>
protected string UserId => User.Claims.FirstOrDefault(c => c.Type == ClaimTypes.NameIdentifier)?.Value ?? "";
/// <summary>
/// Constructor
/// </summary>
/// <param name="db">The data context to use to fulfil this request</param>
protected MyWebLogController(WebLogDbContext db) : base()
{
Db = db;
}
protected ViewResult ThemedView(string template, object model)
{
// TODO: get actual version
ViewBag.Version = "2";
return View(template, model);
}
}

View File

@@ -1,21 +0,0 @@
namespace MyWebLog.Features.Shared;
/// <summary>
/// Base model class for myWebLog views
/// </summary>
public class MyWebLogModel
{
/// <summary>
/// The details for the web log
/// </summary>
public WebLogDetails WebLog { get; init; }
/// <summary>
/// Constructor
/// </summary>
/// <param name="webLog">The details for the web log</param>
protected MyWebLogModel(WebLogDetails webLog)
{
WebLog = webLog;
}
}

View File

@@ -1,37 +0,0 @@
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System.Text.Encodings.Web;
namespace MyWebLog.Features.Shared.TagHelpers;
/// <summary>
/// Image tag helper to load a theme's image
/// </summary>
[HtmlTargetElement("img", Attributes = "asp-theme")]
public class ImageTagHelper : Microsoft.AspNetCore.Mvc.TagHelpers.ImageTagHelper
{
/// <summary>
/// The theme for which the image should be loaded
/// </summary>
[HtmlAttributeName("asp-theme")]
public string Theme { get; set; } = "";
/// <inheritdoc />
public ImageTagHelper(IFileVersionProvider fileVersionProvider, HtmlEncoder htmlEncoder,
IUrlHelperFactory urlHelperFactory)
: base(fileVersionProvider, htmlEncoder, urlHelperFactory) { }
/// <inheritdoc />
public override void Process(TagHelperContext context, TagHelperOutput output)
{
if (Theme == "")
{
base.Process(context, output);
return;
}
output.Attributes.SetAttribute("src", $"~/{Theme}/img/{context.AllAttributes["src"]?.Value}");
ProcessUrlAttribute("src", output);
}
}

View File

@@ -1,55 +0,0 @@
using Microsoft.AspNetCore.Mvc.Razor.Infrastructure;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System.Text.Encodings.Web;
namespace MyWebLog.Features.Shared.TagHelpers;
/// <summary>
/// Tag helper to link stylesheets for a theme
/// </summary>
[HtmlTargetElement("link", Attributes = "asp-theme")]
public class LinkTagHelper : Microsoft.AspNetCore.Mvc.TagHelpers.LinkTagHelper
{
/// <summary>
/// The theme for which a style sheet should be loaded
/// </summary>
[HtmlAttributeName("asp-theme")]
public string Theme { get; set; } = "";
/// <summary>
/// The style sheet to be loaded (defaults to "style")
/// </summary>
[HtmlAttributeName("asp-style")]
public string Style { get; set; } = "style";
/// <inheritdoc />
public LinkTagHelper(IWebHostEnvironment hostingEnvironment, TagHelperMemoryCacheProvider cacheProvider,
IFileVersionProvider fileVersionProvider, HtmlEncoder htmlEncoder, JavaScriptEncoder javaScriptEncoder,
IUrlHelperFactory urlHelperFactory)
: base(hostingEnvironment, cacheProvider, fileVersionProvider, htmlEncoder, javaScriptEncoder, urlHelperFactory)
{ }
/// <inheritdoc />
public override void Process(TagHelperContext context, TagHelperOutput output)
{
if (Theme == "")
{
base.Process(context, output);
return;
}
switch (context.AllAttributes["rel"]?.Value.ToString())
{
case "stylesheet":
output.Attributes.SetAttribute("href", $"~/{Theme}/css/{Style}.css");
break;
case "icon":
output.Attributes.SetAttribute("type", "image/x-icon");
output.Attributes.SetAttribute("href", $"~/{Theme}/img/favicon.ico");
break;
}
ProcessUrlAttribute("href", output);
}
}

View File

@@ -1,29 +0,0 @@
using Microsoft.AspNetCore.Razor.TagHelpers;
namespace MyWebLog.Features.Shared.TagHelpers;
/// <summary>
/// Write a Yes or No based on a boolean value
/// </summary>
public class YesNoTagHelper : TagHelper
{
/// <summary>
/// The attribute in question
/// </summary>
[HtmlAttributeName("asp-for")]
public bool For { get; set; } = false;
/// <summary>
/// Optional; if set, that value will be wrapped with &lt;strong&gt; instead of &lt;span&gt;
/// </summary>
[HtmlAttributeName("asp-strong-if")]
public bool? StrongIf { get; set; }
/// <inheritdoc />
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.TagName = For == StrongIf ? "strong" : "span";
output.TagMode = TagMode.StartTagAndEndTag;
output.Content.Append(For ? Resources.Yes : Resources.No);
}
}

View File

@@ -1,44 +0,0 @@
@model MyWebLogModel
<!DOCTYPE html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width" />
<title>@ViewBag.Title &laquo; @Resources.Admin &laquo; @Model.WebLog.Name</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link rel="stylesheet" href="~/css/admin.css">
</head>
<body>
<header>
<nav class="navbar navbar-dark bg-dark navbar-expand-md justify-content-start px-2">
<div class="container-fluid">
<a class="navbar-brand" href="~/">@Model.WebLog.Name</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText"
aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarText">
<span class="navbar-text">@ViewBag.Title</span>
@await Html.PartialAsync("_LogOnOffPartial")
</div>
</div>
</nav>
</header>
<main>
@* Each.Messages
@Current.ToDisplay
@EndEach *@
@RenderBody()
</main>
<footer>
<div class="container-fluid">
<div class="row">
<div class="col-xs-12 text-end"><img src="~/img/logo-light.png" alt="myWebLog"></div>
</div>
</div>
</footer>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM"
crossorigin="anonymous"></script>
</body>
</html>

View File

@@ -1,11 +0,0 @@
<ul class="navbar-nav flex-grow-1 justify-content-end">
@if (User is not null && (User.Identity?.IsAuthenticated ?? false))
{
<li class="nav-item"><a class="nav-link" asp-action="Index" asp-controller="Admin">@Resources.Dashboard</a></li>
<li class="nav-item"><a class="nav-link" asp-action="LogOff" asp-controller="User">@Resources.LogOff</a></li>
}
else
{
<li class="nav-item"><a class="nav-link" asp-action="LogOn" asp-controller="User">@Resources.LogOn</a></li>
}
</ul>

View File

@@ -1,28 +0,0 @@
using Microsoft.AspNetCore.Mvc.Razor;
namespace MyWebLog.Features;
/// <summary>
/// Expand the location token with the theme path
/// </summary>
public class ThemeViewLocationExpander : IViewLocationExpander
{
/// <inheritdoc />
public IEnumerable<string> ExpandViewLocations(ViewLocationExpanderContext context,
IEnumerable<string> viewLocations)
{
_ = context ?? throw new ArgumentNullException(nameof(context));
_ = viewLocations ?? throw new ArgumentNullException(nameof(viewLocations));
foreach (var location in viewLocations)
yield return location.Replace("{3}", context.Values["theme"]!);
}
/// <inheritdoc />
public void PopulateValues(ViewLocationExpanderContext context)
{
_ = context ?? throw new ArgumentNullException(nameof(context));
context.Values["theme"] = WebLogCache.Get(context.ActionContext.HttpContext).ThemePath;
}
}

View File

@@ -1,31 +0,0 @@
@model LogOnModel
@{
Layout = "_AdminLayout";
ViewBag.Title = @Resources.LogOn;
}
<h2 class="p-3 ">@Resources.LogOnTo @Model.WebLog.Name</h2>
<article class="pb-3">
<form asp-action="DoLogOn" asp-controller="User" method="post">
<div class="container">
<div class="row pb-3">
<div class="col col-md-6 col-lg-4 offset-lg-2">
<div class="form-floating">
<input type="email" asp-for="EmailAddress" class="form-control" autofocus>
<label asp-for="EmailAddress"></label>
</div>
</div>
<div class="col col-md-6 col-lg-4">
<div class="form-floating">
<input type="password" asp-for="Password" class="form-control">
<label asp-for="Password"></label>
</div>
</div>
</div>
<div class="row pb-3">
<div class="col text-center">
<button type="submit" class="btn btn-primary">@Resources.LogOn</button>
</div>
</div>
</div>
</form>
</article>

View File

@@ -1,30 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace MyWebLog.Features.Users;
/// <summary>
/// The model to use to allow a user to log on
/// </summary>
public class LogOnModel : MyWebLogModel
{
/// <summary>
/// The user's e-mail address
/// </summary>
[Required(AllowEmptyStrings = false)]
[EmailAddress]
[Display(ResourceType = typeof(Resources), Name = "EmailAddress")]
public string EmailAddress { get; set; } = "";
/// <summary>
/// The user's password
/// </summary>
[Required(AllowEmptyStrings = false)]
[Display(ResourceType = typeof(Resources), Name = "Password")]
public string Password { get; set; } = "";
[Obsolete("Only used for model binding; use the WebLogDetails constructor")]
public LogOnModel() : base(new()) { }
/// <inheritdoc />
public LogOnModel(WebLogDetails webLog) : base(webLog) { }
}

View File

@@ -1,76 +0,0 @@
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
namespace MyWebLog.Features.Users;
/// <summary>
/// Controller for the users feature
/// </summary>
[Route("/user")]
public class UserController : MyWebLogController
{
/// <summary>
/// Hash a password for a given user
/// </summary>
/// <param name="plainText">The plain-text password</param>
/// <param name="email">The user's e-mail address</param>
/// <param name="salt">The user-specific salt</param>
/// <returns></returns>
internal static string HashedPassword(string plainText, string email, Guid salt)
{
var allSalt = salt.ToByteArray().Concat(Encoding.UTF8.GetBytes(email)).ToArray();
using Rfc2898DeriveBytes alg = new(plainText, allSalt, 2_048);
return Convert.ToBase64String(alg.GetBytes(64));
}
/// <inheritdoc />
public UserController(WebLogDbContext db) : base(db) { }
[HttpGet("log-on")]
public IActionResult LogOn() =>
View(new LogOnModel(WebLog));
[HttpPost("log-on")]
public async Task<IActionResult> DoLogOn(LogOnModel model)
{
var user = await Db.Users.FindByEmail(model.EmailAddress);
if (user == null || user.PasswordHash != HashedPassword(model.Password, user.UserName, user.Salt))
{
// TODO: make error, not 404
return NotFound();
}
List<Claim> claims = new()
{
new(ClaimTypes.NameIdentifier, user.Id),
new(ClaimTypes.Name, $"{user.FirstName} {user.LastName}"),
new(ClaimTypes.GivenName, user.PreferredName),
new(ClaimTypes.Role, user.AuthorizationLevel.ToString())
};
ClaimsIdentity identity = new(claims, CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignInAsync(identity.AuthenticationType, new(identity),
new() { IssuedUtc = DateTime.UtcNow });
// TODO: confirmation message
return RedirectToAction("Index", "Admin");
}
[HttpGet("log-off")]
[Authorize]
public async Task<IActionResult> LogOff()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
// TODO: confirmation message
return LocalRedirect("~/");
}
}

View File

@@ -1,7 +0,0 @@
@namespace MyWebLog.Features
@using MyWebLog
@using MyWebLog.Properties
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper *, MyWebLog

View File

@@ -1,3 +0,0 @@
global using MyWebLog.Data;
global using MyWebLog.Features.Shared;
global using MyWebLog.Properties;

59
src/MyWebLog/Handlers.fs Normal file
View File

@@ -0,0 +1,59 @@
[<RequireQualifiedAccess>]
module MyWebLog.Handlers
open Giraffe
open MyWebLog
open MyWebLog.ViewModels
open System
[<AutoOpen>]
module private Helpers =
open DotLiquid
open System.Collections.Concurrent
open System.IO
/// Cache for parsed templates
let private themeViews = ConcurrentDictionary<string, Template> ()
/// Return a view for a theme
let themedView<'T> (template : string) (model : obj) : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx
let templatePath = $"themes/{webLog.themePath}/{template}"
match themeViews.ContainsKey templatePath with
| true -> ()
| false ->
let! file = File.ReadAllTextAsync $"{templatePath}.liquid"
themeViews[templatePath] <- Template.Parse file
let view = themeViews[templatePath].Render (Hash.FromAnonymousObject model)
return! htmlString view next ctx
}
module User =
open System.Security.Cryptography
open System.Text
/// Hash a password for a given user
let hashedPassword (plainText : string) (email : string) (salt : Guid) =
let allSalt = Array.concat [ salt.ToByteArray(); (Encoding.UTF8.GetBytes email) ]
use alg = new Rfc2898DeriveBytes (plainText, allSalt, 2_048)
Convert.ToBase64String(alg.GetBytes(64))
module CatchAll =
let catchAll : HttpHandler = fun next ctx -> task {
let testPage = { Page.empty with text = "Howdy, folks!" }
return! themedView "single-page" { page = testPage; webLog = WebLogCache.getByCtx ctx } next ctx
}
open Giraffe.EndpointRouting
/// The endpoints defined in the above handlers
let endpoints = [
GET [
route "" CatchAll.catchAll
]
]

View File

@@ -1,40 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<Folder Include="wwwroot\img\" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Markdig" Version="0.27.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyWebLog.DataCS\MyWebLog.DataCS.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>PublicResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Content Include="appsettings.json" CopyToOutputDirectory="Always" />
<Compile Include="WebLogCache.fs" />
<Compile Include="Handlers.fs" />
<Compile Include="Program.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="DotLiquid" Version="2.2.610" />
<PackageReference Include="Giraffe" Version="6.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\MyWebLog.Data\MyWebLog.Data.fsproj" />
<ProjectReference Include="..\MyWebLog.Domain\MyWebLog.Domain.fsproj" />
</ItemGroup>
<ItemGroup>
<None Include=".\themes\**" CopyToOutputDirectory="Always" />
<None Include=".\wwwroot\**" CopyToOutputDirectory="Always" />
</ItemGroup>
<ItemGroup />
</Project>

View File

@@ -1,140 +0,0 @@
using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using MyWebLog;
using MyWebLog.Features;
using MyWebLog.Features.Users;
using System.Reflection;
if (args.Length > 0 && args[0] == "init")
{
await InitDb();
return;
}
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMvc(opts =>
{
opts.Conventions.Add(new FeatureControllerModelConvention());
opts.Filters.Add(new AutoValidateAntiforgeryTokenAttribute());
}).AddRazorOptions(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(new FeatureViewLocationExpander());
opts.ViewLocationExpanders.Add(new ThemeViewLocationExpander());
});
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(opts =>
{
opts.ExpireTimeSpan = TimeSpan.FromMinutes(20);
opts.SlidingExpiration = true;
opts.AccessDeniedPath = "/forbidden";
});
builder.Services.AddAuthorization();
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
Array.ForEach(Directory.GetFiles(Directory.GetCurrentDirectory(), "MyWebLog.Themes.*.dll"),
it => { Assembly.LoadFile(it); });
var app = builder.Build();
app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Strict });
app.UseMiddleware<WebLogMiddleware>();
app.UseAuthentication();
app.UseStaticFiles();
app.UseRouting();
app.UseAuthorization();
app.UseEndpoints(endpoints => endpoints.MapControllers());
app.Run();
/// <summary>
/// Initialize a new database
/// </summary>
async Task InitDb()
{
if (args.Length != 5)
{
Console.WriteLine("Usage: MyWebLog init [url] [name] [admin-email] [admin-pw]");
return;
}
using var db = new WebLogDbContext(new DbContextOptionsBuilder<WebLogDbContext>()
.UseSqlite($"Data Source=Db/{args[1].Replace(':', '_')}.db").Options);
await db.Database.MigrateAsync();
// Create the admin user
var salt = Guid.NewGuid();
var user = new WebLogUser
{
Id = WebLogDbContext.NewId(),
UserName = args[3],
FirstName = "Admin",
LastName = "User",
PreferredName = "Admin",
PasswordHash = UserController.HashedPassword(args[4], args[3], salt),
Salt = salt,
AuthorizationLevel = AuthorizationLevel.Administrator
};
await db.Users.AddAsync(user);
// Create the default home page
var home = new Page
{
Id = WebLogDbContext.NewId(),
AuthorId = user.Id,
Title = "Welcome to myWebLog!",
Permalink = "welcome-to-myweblog.html",
PublishedOn = DateTime.UtcNow,
UpdatedOn = DateTime.UtcNow,
Text = "<p>This is your default home page.</p>",
Revisions = new[]
{
new PageRevision
{
Id = WebLogDbContext.NewId(),
AsOf = DateTime.UtcNow,
SourceType = RevisionSource.Html,
Text = "<p>This is your default home page.</p>"
}
}
};
await db.Pages.AddAsync(home);
// Add the details
var timeZone = TimeZoneInfo.Local.Id;
if (!TimeZoneInfo.Local.HasIanaId)
{
timeZone = TimeZoneInfo.TryConvertWindowsIdToIanaId(timeZone, out var ianaId)
? ianaId
: throw new TimeZoneNotFoundException($"Cannot find IANA timezone for {timeZone}");
}
var details = new WebLogDetails
{
Name = args[2],
UrlBase = args[1],
DefaultPage = home.Id,
TimeZone = timeZone
};
await db.WebLogDetails.AddAsync(details);
await db.SaveChangesAsync();
Console.WriteLine($"Successfully initialized database for {args[2]} with URL base {args[1]}");
}

150
src/MyWebLog/Program.fs Normal file
View File

@@ -0,0 +1,150 @@
open Giraffe.EndpointRouting
open Microsoft.AspNetCore.Authentication.Cookies
open Microsoft.AspNetCore.Builder
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
/// Middleware to derive the current web log
type WebLogMiddleware (next : RequestDelegate) =
member this.InvokeAsync (ctx : HttpContext) = task {
let host = ctx.Request.Host.ToUriComponent ()
match WebLogCache.exists host with
| true -> return! next.Invoke ctx
| false ->
let conn = ctx.RequestServices.GetRequiredService<IConnection> ()
match! Data.WebLog.findByHost host conn with
| Some webLog ->
WebLogCache.set host webLog
return! next.Invoke ctx
| None -> ctx.Response.StatusCode <- 404
}
/// Initialize a new database
let initDbValidated (args : string[]) (sp : IServiceProvider) = task {
let conn = sp.GetRequiredService<IConnection> ()
let timeZone =
let local = TimeZoneInfo.Local.Id
match TimeZoneInfo.Local.HasIanaId with
| true -> local
| false ->
match TimeZoneInfo.TryConvertWindowsIdToIanaId local with
| true, ianaId -> ianaId
| false, _ -> raise <| TimeZoneNotFoundException $"Cannot find IANA timezone for {local}"
// Create the web log
let webLogId = WebLogId.create ()
let userId = WebLogUserId.create ()
let homePageId = PageId.create ()
do! Data.WebLog.add
{ WebLog.empty with
id = webLogId
name = args[2]
urlBase = args[1]
defaultPage = PageId.toString homePageId
timeZone = timeZone
} conn
// Create the admin user
let salt = Guid.NewGuid ()
do! Data.WebLogUser.add
{ WebLogUser.empty with
id = userId
webLogId = webLogId
userName = args[3]
firstName = "Admin"
lastName = "User"
preferredName = "Admin"
passwordHash = Handlers.User.hashedPassword args[4] args[3] salt
salt = salt
authorizationLevel = Administrator
} conn
// Create the default home page
do! Data.Page.add
{ Page.empty with
id = homePageId
webLogId = webLogId
authorId = userId
title = "Welcome to myWebLog!"
permalink = Permalink "welcome-to-myweblog.html"
publishedOn = DateTime.UtcNow
updatedOn = DateTime.UtcNow
text = "<p>This is your default home page.</p>"
revisions = [
{ asOf = DateTime.UtcNow
sourceType = Html
text = "<p>This is your default home page.</p>"
}
]
} conn
Console.WriteLine($"Successfully initialized database for {args[2]} with URL base {args[1]}");
}
/// Initialize a new database
let initDb args sp = task {
match args |> Array.length with
| 5 -> return! initDbValidated args sp
| _ ->
Console.WriteLine "Usage: MyWebLog init [url] [name] [admin-email] [admin-pw]"
return! System.Threading.Tasks.Task.CompletedTask
}
[<EntryPoint>]
let main args =
let builder = WebApplication.CreateBuilder(args)
let _ =
builder.Services
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
.AddCookie(fun opts ->
opts.ExpireTimeSpan <- TimeSpan.FromMinutes 20.
opts.SlidingExpiration <- true
opts.AccessDeniedPath <- "/forbidden")
let _ = builder.Services.AddLogging ()
let _ = builder.Services.AddAuthorization()
// Configure RethinkDB's connection
JsonConverters.all () |> Seq.iter Converter.Serializer.Converters.Add
let sp = builder.Services.BuildServiceProvider ()
let config = sp.GetRequiredService<IConfiguration> ()
let loggerFac = sp.GetRequiredService<ILoggerFactory> ()
let rethinkCfg = DataConfig.FromConfiguration (config.GetSection "RethinkDB")
let conn =
task {
let! conn = rethinkCfg.CreateConnectionAsync ()
do! Data.Startup.ensureDb rethinkCfg (loggerFac.CreateLogger (nameof Data.Startup)) conn
return conn
} |> Async.AwaitTask |> Async.RunSynchronously
let _ = builder.Services.AddSingleton<IConnection> conn
let app = builder.Build ()
match args |> Array.tryHead with
| Some it when it = "init" -> initDb args app.Services |> Async.AwaitTask |> Async.RunSynchronously
| _ ->
let _ = app.UseCookiePolicy (CookiePolicyOptions (MinimumSameSitePolicy = SameSiteMode.Strict))
let _ = app.UseMiddleware<WebLogMiddleware> ()
let _ = app.UseAuthentication ()
let _ = app.UseStaticFiles ()
let _ = app.UseRouting ()
let _ = app.UseEndpoints (fun endpoints -> endpoints.MapGiraffeEndpoints Handlers.endpoints)
app.Run()
0 // Exit code

View File

@@ -1,459 +0,0 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace MyWebLog.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
public class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("MyWebLog.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
public static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized string similar to Actions.
/// </summary>
public static string Actions {
get {
return ResourceManager.GetString("Actions", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Add a New Category.
/// </summary>
public static string AddANewCategory {
get {
return ResourceManager.GetString("AddANewCategory", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Add a New Page.
/// </summary>
public static string AddANewPage {
get {
return ResourceManager.GetString("AddANewPage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Admin.
/// </summary>
public static string Admin {
get {
return ResourceManager.GetString("Admin", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to All.
/// </summary>
public static string All {
get {
return ResourceManager.GetString("All", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Categories.
/// </summary>
public static string Categories {
get {
return ResourceManager.GetString("Categories", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Create a New Page.
/// </summary>
public static string CreateANewPage {
get {
return ResourceManager.GetString("CreateANewPage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Dashboard.
/// </summary>
public static string Dashboard {
get {
return ResourceManager.GetString("Dashboard", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to MMMM d, yyyy.
/// </summary>
public static string DateFormatString {
get {
return ResourceManager.GetString("DateFormatString", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Default Page.
/// </summary>
public static string DefaultPage {
get {
return ResourceManager.GetString("DefaultPage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Drafts.
/// </summary>
public static string Drafts {
get {
return ResourceManager.GetString("Drafts", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Edit.
/// </summary>
public static string Edit {
get {
return ResourceManager.GetString("Edit", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Edit Page.
/// </summary>
public static string EditPage {
get {
return ResourceManager.GetString("EditPage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to E-mail Address.
/// </summary>
public static string EmailAddress {
get {
return ResourceManager.GetString("EmailAddress", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to First Page of Posts.
/// </summary>
public static string FirstPageOfPosts {
get {
return ResourceManager.GetString("FirstPageOfPosts", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to In List?.
/// </summary>
public static string InListQuestion {
get {
return ResourceManager.GetString("InListQuestion", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Last Updated.
/// </summary>
public static string LastUpdated {
get {
return ResourceManager.GetString("LastUpdated", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Log Off.
/// </summary>
public static string LogOff {
get {
return ResourceManager.GetString("LogOff", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Log On.
/// </summary>
public static string LogOn {
get {
return ResourceManager.GetString("LogOn", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Log On to.
/// </summary>
public static string LogOnTo {
get {
return ResourceManager.GetString("LogOnTo", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Modify Settings.
/// </summary>
public static string ModifySettings {
get {
return ResourceManager.GetString("ModifySettings", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Name.
/// </summary>
public static string Name {
get {
return ResourceManager.GetString("Name", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to No.
/// </summary>
public static string No {
get {
return ResourceManager.GetString("No", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Pages.
/// </summary>
public static string Pages {
get {
return ResourceManager.GetString("Pages", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Page Text.
/// </summary>
public static string PageText {
get {
return ResourceManager.GetString("PageText", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Password.
/// </summary>
public static string Password {
get {
return ResourceManager.GetString("Password", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Permalink.
/// </summary>
public static string Permalink {
get {
return ResourceManager.GetString("Permalink", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Posts.
/// </summary>
public static string Posts {
get {
return ResourceManager.GetString("Posts", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Posts per Page.
/// </summary>
public static string PostsPerPage {
get {
return ResourceManager.GetString("PostsPerPage", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Published.
/// </summary>
public static string Published {
get {
return ResourceManager.GetString("Published", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Save Changes.
/// </summary>
public static string SaveChanges {
get {
return ResourceManager.GetString("SaveChanges", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Show in Page List.
/// </summary>
public static string ShowInPageList {
get {
return ResourceManager.GetString("ShowInPageList", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Shown in Page List.
/// </summary>
public static string ShownInPageList {
get {
return ResourceManager.GetString("ShownInPageList", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Subtitle.
/// </summary>
public static string Subtitle {
get {
return ResourceManager.GetString("Subtitle", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to There are {0} categories.
/// </summary>
public static string ThereAreXCategories {
get {
return ResourceManager.GetString("ThereAreXCategories", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to There are {0} pages.
/// </summary>
public static string ThereAreXPages {
get {
return ResourceManager.GetString("ThereAreXPages", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to There are {0} published posts and {1} drafts.
/// </summary>
public static string ThereAreXPublishedPostsAndYDrafts {
get {
return ResourceManager.GetString("ThereAreXPublishedPostsAndYDrafts", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Time Zone.
/// </summary>
public static string TimeZone {
get {
return ResourceManager.GetString("TimeZone", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Title.
/// </summary>
public static string Title {
get {
return ResourceManager.GetString("Title", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Top Level.
/// </summary>
public static string TopLevel {
get {
return ResourceManager.GetString("TopLevel", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to View All.
/// </summary>
public static string ViewAll {
get {
return ResourceManager.GetString("ViewAll", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Web Log Settings.
/// </summary>
public static string WebLogSettings {
get {
return ResourceManager.GetString("WebLogSettings", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Write a New Post.
/// </summary>
public static string WriteANewPost {
get {
return ResourceManager.GetString("WriteANewPost", resourceCulture);
}
}
/// <summary>
/// Looks up a localized string similar to Yes.
/// </summary>
public static string Yes {
get {
return ResourceManager.GetString("Yes", resourceCulture);
}
}
}
}

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,28 +0,0 @@
{
"iisSettings": {
"windowsAuthentication": false,
"anonymousAuthentication": true,
"iisExpress": {
"applicationUrl": "http://localhost:3330",
"sslPort": 0
}
},
"profiles": {
"MyWebLog": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:5010",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"IIS Express": {
"commandName": "IISExpress",
"launchBrowser": true,
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}

View File

@@ -1,35 +0,0 @@
@model MyWebLogModel
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta name="generator" content="myWebLog 2">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link asp-theme="@Model.WebLog.ThemePath" />
@await RenderSectionAsync("Style", false)
<title>@ViewBag.Title &laquo; @Model.WebLog.Name</title>
</head>
<body>
@if (IsSectionDefined("Header"))
{
@await RenderSectionAsync("Header")
}
else
{
@await Html.PartialAsync("_DefaultHeader", Model)
}
<main>
@RenderBody()
</main>
@if (IsSectionDefined("Footer"))
{
@await RenderSectionAsync("Footer")
} else
{
@await Html.PartialAsync("_DefaultFooter")
}
@await RenderSectionAsync("Script", false)
</body>
</html>

View File

@@ -1,10 +0,0 @@
@using MyWebLog.Features.Pages
@model SinglePageModel
@{
Layout = "_Layout";
ViewBag.Title = Model.Page.Title;
}
<h2>@Model.Page.Title</h2>
<article>
@Html.Raw(Model.Page.Text)
</article>

View File

@@ -1,3 +0,0 @@
@namespace MyWebLog.Themes
@addTagHelper *, MyWebLog

View File

@@ -0,0 +1,24 @@
/// <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> ()
/// Does a host exist in the cache?
let exists host = _cache.ContainsKey host
/// Get the details for a web log via its host
let get host = _cache[host]
/// Get the details for a web log via its host
let getByCtx (ctx : HttpContext) = _cache[ctx.Request.Host.ToUriComponent ()]
/// Set the details for a particular host
let set host details = _cache[host] <- details

View File

@@ -1,91 +0,0 @@
using System.Collections.Concurrent;
namespace MyWebLog;
/// <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>
public static class WebLogCache
{
/// <summary>
/// The cache of web log details
/// </summary>
private static readonly ConcurrentDictionary<string, WebLogDetails> _cache = new();
/// <summary>
/// Transform a hostname to a database name
/// </summary>
/// <param name="ctx">The current HTTP context</param>
/// <returns>The hostname, with an underscore replacing a colon</returns>
public static string HostToDb(HttpContext ctx) => ctx.Request.Host.ToUriComponent().Replace(':', '_');
/// <summary>
/// Does a host exist in the cache?
/// </summary>
/// <param name="host">The host in question</param>
/// <returns>True if it exists, false if not</returns>
public static bool Exists(string host) => _cache.ContainsKey(host);
/// <summary>
/// Get the details for a web log via its host
/// </summary>
/// <param name="host">The host which should be retrieved</param>
/// <returns>The web log details</returns>
public static WebLogDetails Get(string host) => _cache[host];
/// <summary>
/// Get the details for a web log via its host
/// </summary>
/// <param name="ctx">The HTTP context for the request</param>
/// <returns>The web log details</returns>
public static WebLogDetails Get(HttpContext ctx) => _cache[HostToDb(ctx)];
/// <summary>
/// Set the details for a particular host
/// </summary>
/// <param name="host">The host for which details should be set</param>
/// <param name="details">The details to be set</param>
public static void Set(string host, WebLogDetails details) => _cache[host] = details;
}
/// <summary>
/// Middleware to derive the current web log
/// </summary>
public class WebLogMiddleware
{
/// <summary>
/// The next action in the pipeline
/// </summary>
private readonly RequestDelegate _next;
/// <summary>
/// Constructor
/// </summary>
/// <param name="next">The next action in the pipeline</param>
public WebLogMiddleware(RequestDelegate next)
{
_next = next;
}
public async Task InvokeAsync(HttpContext context)
{
var host = WebLogCache.HostToDb(context);
if (!WebLogCache.Exists(host))
{
var db = context.RequestServices.GetRequiredService<WebLogDbContext>();
var details = await db.WebLogDetails.FindByHost(context.Request.Host.ToUriComponent());
if (details == null)
{
context.Response.StatusCode = 404;
return;
}
WebLogCache.Set(host, details);
}
await _next.Invoke(context);
}
}

View File

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

View File

@@ -1,9 +1,6 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
{
"RethinkDB": {
"hostname": "data02.bitbadger.solutions",
"database": "myWebLog-dev"
}
}

View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width">
<meta name="generator" content="myWebLog 2">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css"
integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
<link asp-theme="@Model.WebLog.ThemePath" />
<title>{{ title | escape }} &laquo; {{ web_log_name | escape }}</title>
</head>

View File

@@ -1,6 +1,6 @@
<footer>
<hr>
<div class="container-fluid text-end">
<img src="~/img/logo-dark.png" alt="myWebLog">
<img src="/img/logo-dark.png" alt="myWebLog">
</div>
</footer>

View File

@@ -1,17 +1,15 @@
@model MyWebLogModel
<header>
<header>
<nav class="navbar navbar-light bg-light navbar-expand-md justify-content-start px-2">
<div class="container-fluid">
<a class="navbar-brand" href="~/">@Model.WebLog.Name</a>
<a class="navbar-brand" href="~/">{{ web_log.name }}</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarText"
aria-controls="navbarText" aria-expanded="false" aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarText">
@if (Model.WebLog.Subtitle is not null)
{
<span class="navbar-text">@Model.WebLog.Subtitle</span>
}
{% if web_log.subtitle -%}
<span class="navbar-text">{{ web_log.subtitle | escape }}</span>
{%- endif %}
@* TODO: list pages for current web log *@
@await Html.PartialAsync("_LogOnOffPartial")
</div>

View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
{{ render "_html-head", title: title, web_log_name: web_log.name }}
<body>
{{ render "_page-head", web_log: web_log }}
<main>
<h2>{{ page.title }}</h2>
<article>
{{ page.text }}
</article>
</main>
{{ render "_page-foot" }}
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB