Add page edit

- Add nav link and user log-on link filter/tag
- Add page list support
- Add prior permalink index/search
- Remove v2 C# projects
This commit is contained in:
Daniel J. Summers 2022-04-18 18:06:17 -04:00
parent 8ce2d5a2ed
commit 48e6d3edfa
85 changed files with 593 additions and 4878 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;

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

@ -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]}");
}

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,6 +0,0 @@
<footer>
<hr>
<div class="container-fluid text-end">
<img src="~/img/logo-dark.png" alt="myWebLog">
</div>
</footer>

View File

@ -1,20 +0,0 @@
@model MyWebLogModel
<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>
<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>
}
@* TODO: list pages for current web log *@
@await Html.PartialAsync("_LogOnOffPartial")
</div>
</div>
</nav>
</header>

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

@ -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 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -1,5 +0,0 @@
footer {
background-color: #808080;
border-top: solid 1px black;
color: white;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -83,6 +83,20 @@ module Startup =
withRetryOnce conn withRetryOnce conn
} }
() ()
// Prior permalinks are searched when a post or page permalink do not match the current URL
match indexes |> List.contains "priorPermalinks" with
| true -> ()
| false ->
log.LogInformation($"Creating index {table}.priorPermalinks...")
let! _ =
rethink {
withTable table
indexCreate "priorPermalinks"
indexOption Multi
write
withRetryOnce conn
}
()
| false -> () | false -> ()
// Users log on with e-mail // Users log on with e-mail
match Table.WebLogUser = table with match Table.WebLogUser = table with
@ -190,17 +204,27 @@ module Page =
withRetryDefault withRetryDefault
} }
/// Retrieve all pages for a web log /// Retrieve all pages for a web log (excludes text, prior permalinks, and revisions)
let findAll (webLogId : WebLogId) = let findAll (webLogId : WebLogId) =
rethink<Page list> { rethink<Page list> {
withTable Table.Page withTable Table.Page
getAll [ webLogId ] (nameof webLogId) getAll [ webLogId ] (nameof webLogId)
without [ "priorPermalinks", "revisions" ] without [ "text", "priorPermalinks", "revisions" ]
result result
withRetryDefault withRetryDefault
} }
/// Find a page by its ID /// Find a page by its ID (including prior permalinks and revisions)
let findByFullId (pageId : PageId) webLogId =
rethink<Page> {
withTable Table.Page
get pageId
resultOption
withRetryDefault
}
|> verifyWebLog webLogId (fun it -> it.webLogId)
/// Find a page by its ID (excludes prior permalinks and revisions)
let findById (pageId : PageId) webLogId = let findById (pageId : PageId) webLogId =
rethink<Page> { rethink<Page> {
withTable Table.Page withTable Table.Page
@ -222,16 +246,31 @@ module Page =
withRetryDefault withRetryDefault
} }
|> tryFirst |> tryFirst
/// Find a page by its ID (including permalinks and revisions) /// Find the current permalink for a page by a prior permalink
let findByFullId (pageId : PageId) webLogId = let findCurrentPermalink (permalink : Permalink) (webLogId : WebLogId) =
rethink<Page> { rethink<Permalink list> {
withTable Table.Page withTable Table.Page
get pageId getAll [ permalink ] "priorPermalinks"
resultOption filter [ "webLogId", webLogId :> obj ]
pluck [ "permalink" ]
limit 1
result
withRetryDefault
}
|> tryFirst
/// Find all pages in the page list for the given web log
let findListed (webLogId : WebLogId) =
rethink<Page list> {
withTable Table.Page
getAll [ webLogId ] (nameof webLogId)
filter [ "showInPageList", true :> obj ]
without [ "text", "priorPermalinks", "revisions" ]
orderBy "title"
result
withRetryDefault withRetryDefault
} }
|> verifyWebLog webLogId (fun it -> it.webLogId)
/// Find a list of pages (displayed in admin area) /// Find a list of pages (displayed in admin area)
let findPageOfPages (webLogId : WebLogId) pageNbr = let findPageOfPages (webLogId : WebLogId) pageNbr =
@ -245,6 +284,25 @@ module Page =
result result
withRetryDefault withRetryDefault
} }
/// Update a page
let update (page : Page) =
rethink {
withTable Table.Page
get page.id
update [
"title", page.title
"permalink", page.permalink
"updatedOn", page.updatedOn
"showInPageList", page.showInPageList
"text", page.text
"priorPermalinks", page.priorPermalinks
"revisions", page.revisions
]
write
withRetryDefault
ignoreResult
}
/// Functions to manipulate posts /// Functions to manipulate posts
module Post = module Post =
@ -272,6 +330,19 @@ module Post =
} }
|> tryFirst |> tryFirst
/// Find the current permalink for a post by a prior permalink
let findCurrentPermalink (permalink : Permalink) (webLogId : WebLogId) =
rethink<Permalink list> {
withTable Table.Post
getAll [ permalink ] "priorPermalinks"
filter [ "webLogId", webLogId :> obj ]
pluck [ "permalink" ]
limit 1
result
withRetryDefault
}
|> tryFirst
/// Find posts to be displayed on a page /// Find posts to be displayed on a page
let findPageOfPublishedPosts (webLogId : WebLogId) pageNbr postsPerPage = let findPageOfPublishedPosts (webLogId : WebLogId) pageNbr postsPerPage =
rethink<Post list> { rethink<Post list> {
@ -300,7 +371,7 @@ module WebLog =
ignoreResult ignoreResult
} }
/// Retrieve web log details by the URL base /// Retrieve a web log by the URL base
let findByHost (url : string) = let findByHost (url : string) =
rethink<WebLog list> { rethink<WebLog list> {
withTable Table.WebLog withTable Table.WebLog
@ -311,6 +382,15 @@ module WebLog =
} }
|> tryFirst |> tryFirst
/// Retrieve a web log by its ID
let findById (webLogId : WebLogId) =
rethink<WebLog> {
withTable Table.WebLog
get webLogId
resultOption
withRetryDefault
}
/// Update web log settings /// Update web log settings
let updateSettings (webLog : WebLog) = let updateSettings (webLog : WebLog) =
rethink { rethink {

View File

@ -1,42 +0,0 @@
namespace MyWebLog.Data;
/// <summary>
/// A category under which a post may be identfied
/// </summary>
public class Category
{
/// <summary>
/// The ID of the category
/// </summary>
public string Id { get; set; } = "";
/// <summary>
/// The displayed name
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// The slug (used in category URLs)
/// </summary>
public string Slug { get; set; } = "";
/// <summary>
/// A longer description of the category
/// </summary>
public string? Description { get; set; } = null;
/// <summary>
/// The parent ID of this category (if a subcategory)
/// </summary>
public string? ParentId { get; set; } = null;
/// <summary>
/// The parent of this category (if a subcategory)
/// </summary>
public Category? Parent { get; set; } = default;
/// <summary>
/// The posts assigned to this category
/// </summary>
public ICollection<Post> Posts { get; set; } = default!;
}

View File

@ -1,62 +0,0 @@
namespace MyWebLog.Data;
/// <summary>
/// A comment on a post
/// </summary>
public class Comment
{
/// <summary>
/// The ID of the comment
/// </summary>
public string Id { get; set; } = "";
/// <summary>
/// The ID of the post to which this comment applies
/// </summary>
public string PostId { get; set; } = "";
/// <summary>
/// The post to which this comment applies
/// </summary>
public Post Post { get; set; } = default!;
/// <summary>
/// The ID of the comment to which this comment is a reply
/// </summary>
public string? InReplyToId { get; set; } = null;
/// <summary>
/// The comment to which this comment is a reply
/// </summary>
public Comment? InReplyTo { get; set; } = default;
/// <summary>
/// The name of the commentor
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// The e-mail address of the commentor
/// </summary>
public string Email { get; set; } = "";
/// <summary>
/// The URL of the commentor's personal website
/// </summary>
public string? Url { get; set; } = null;
/// <summary>
/// The status of the comment
/// </summary>
public CommentStatus Status { get; set; } = CommentStatus.Pending;
/// <summary>
/// When the comment was posted
/// </summary>
public DateTime PostedOn { get; set; } = DateTime.UtcNow;
/// <summary>
/// The text of the comment
/// </summary>
public string Text { get; set; } = "";
}

View File

@ -1,47 +0,0 @@
namespace MyWebLog.Data;
/// <summary>
/// The source format for a revision
/// </summary>
public enum RevisionSource
{
/// <summary>Markdown text</summary>
Markdown,
/// <summary>HTML</summary>
Html
}
/// <summary>
/// A level of authorization for a given web log
/// </summary>
public enum AuthorizationLevel
{
/// <summary>The user may administer all aspects of a web log</summary>
Administrator,
/// <summary>The user is a known user of a web log</summary>
User
}
/// <summary>
/// Statuses for posts
/// </summary>
public enum PostStatus
{
/// <summary>The post should not be publicly available</summary>
Draft,
/// <summary>The post is publicly viewable</summary>
Published
}
/// <summary>
/// Statuses for post comments
/// </summary>
public enum CommentStatus
{
/// <summary>The comment is approved</summary>
Approved,
/// <summary>The comment has yet to be approved</summary>
Pending,
/// <summary>The comment was unsolicited and unwelcome</summary>
Spam
}

View File

@ -1,20 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace MyWebLog.Data;
public static class CategoryExtensions
{
/// <summary>
/// Count all categories
/// </summary>
/// <returns>A count of all categories</returns>
public static async Task<int> CountAll(this DbSet<Category> db) =>
await db.CountAsync().ConfigureAwait(false);
/// <summary>
/// Count top-level categories (those that do not have a parent)
/// </summary>
/// <returns>A count of all top-level categories</returns>
public static async Task<int> CountTopLevel(this DbSet<Category> db) =>
await db.CountAsync(c => c.ParentId == null).ConfigureAwait(false);
}

View File

@ -1,67 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace MyWebLog.Data;
public static class PageExtensions
{
/// <summary>
/// Count the number of pages
/// </summary>
/// <returns>The number of pages</returns>
public static async Task<int> CountAll(this DbSet<Page> db) =>
await db.CountAsync().ConfigureAwait(false);
/// <summary>
/// Count the number of pages in the page list
/// </summary>
/// <returns>The number of pages in the page list</returns>
public static async Task<int> CountListed(this DbSet<Page> db) =>
await db.CountAsync(p => p.ShowInPageList).ConfigureAwait(false);
/// <summary>
/// Retrieve all pages (non-tracked)
/// </summary>
/// <returns>A list of all pages</returns>
public static async Task<List<Page>> FindAll(this DbSet<Page> db) =>
await db.OrderBy(p => p.Title).ToListAsync().ConfigureAwait(false);
/// <summary>
/// Retrieve a page by its ID (non-tracked)
/// </summary>
/// <param name="id">The ID of the page to retrieve</param>
/// <returns>The requested page (or null if it is not found)</returns>
public static async Task<Page?> FindById(this DbSet<Page> db, string id) =>
await db.SingleOrDefaultAsync(p => p.Id == id).ConfigureAwait(false);
/// <summary>
/// Retrieve a page by its ID, including its revisions (non-tracked)
/// </summary>
/// <param name="id">The ID of the page to retrieve</param>
/// <returns>The requested page (or null if it is not found)</returns>
public static async Task<Page?> FindByIdWithRevisions(this DbSet<Page> db, string id) =>
await db.Include(p => p.Revisions).SingleOrDefaultAsync(p => p.Id == id).ConfigureAwait(false);
/// <summary>
/// Retrieve a page by its permalink (non-tracked)
/// </summary>
/// <param name="permalink">The permalink</param>
/// <returns>The requested page (or null if it is not found)</returns>
public static async Task<Page?> FindByPermalink(this DbSet<Page> db, string permalink) =>
await db.SingleOrDefaultAsync(p => p.Permalink == permalink).ConfigureAwait(false);
/// <summary>
/// Retrieve a page of pages (non-tracked)
/// </summary>
/// <param name="pageNbr">The page number to retrieve</param>
/// <returns>The pages</returns>
public static async Task<List<Page>> FindPageOfPages(this DbSet<Page> db, int pageNbr) =>
await db.OrderBy(p => p.Title).Skip((pageNbr - 1) * 25).Take(25).ToListAsync().ConfigureAwait(false);
/// <summary>
/// Retrieve a page by its ID (tracked)
/// </summary>
/// <param name="id">The ID of the page to retrieve</param>
/// <returns>The requested page (or null if it is not found)</returns>
public static async Task<Page?> GetById(this DbSet<Page> db, string id) =>
await db.AsTracking().Include(p => p.Revisions).SingleOrDefaultAsync(p => p.Id == id).ConfigureAwait(false);
}

View File

@ -1,33 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace MyWebLog.Data;
public static class PostExtensions
{
/// <summary>
/// Count the posts in the given status
/// </summary>
/// <param name="status">The status for which posts should be counted</param>
/// <returns>A count of the posts in the given status</returns>
public static async Task<int> CountByStatus(this DbSet<Post> db, PostStatus status) =>
await db.CountAsync(p => p.Status == status).ConfigureAwait(false);
/// <summary>
/// Retrieve a post by its permalink (non-tracked)
/// </summary>
/// <param name="permalink">The possible post permalink</param>
/// <returns>The post matching the permalink, or null if none is found</returns>
public static async Task<Post?> FindByPermalink(this DbSet<Post> db, string permalink) =>
await db.SingleOrDefaultAsync(p => p.Id == permalink).ConfigureAwait(false);
/// <summary>
/// Retrieve a page of published posts (non-tracked)
/// </summary>
/// <param name="pageNbr">The page number to retrieve</param>
/// <param name="postsPerPage">The number of posts per page</param>
/// <returns>A list of posts representing the posts for the given page</returns>
public static async Task<List<Post>> FindPageOfPublishedPosts(this DbSet<Post> db, int pageNbr, int postsPerPage) =>
await db.Where(p => p.Status == PostStatus.Published)
.Skip((pageNbr - 1) * postsPerPage).Take(postsPerPage)
.ToListAsync();
}

View File

@ -1,22 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace MyWebLog.Data;
public static class WebLogDetailsExtensions
{
/// <summary>
/// Find the details of a web log by its host (non-tracked)
/// </summary>
/// <param name="host">The host</param>
/// <returns>The web log (or null if not found)</returns>
public static async Task<WebLogDetails?> FindByHost(this DbSet<WebLogDetails> db, string host) =>
await db.FirstOrDefaultAsync(wld => wld.UrlBase == host).ConfigureAwait(false);
/// <summary>
/// Get the details of a web log by its host (tracked)
/// </summary>
/// <param name="host">The host</param>
/// <returns>The web log (or null if not found)</returns>
public static async Task<WebLogDetails?> GetByHost(this DbSet<WebLogDetails> db, string host) =>
await db.AsTracking().FirstOrDefaultAsync(wld => wld.UrlBase == host).ConfigureAwait(false);
}

View File

@ -1,16 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace MyWebLog.Data;
public static class WebLogUserExtensions
{
/// <summary>
/// Find a user by their log on information (non-tracked)
/// </summary>
/// <param name="email">The user's e-mail address</param>
/// <param name="pwHash">The hash of the password provided by the user</param>
/// <returns>The user, if the credentials match; null if they do not</returns>
public static async Task<WebLogUser?> FindByEmail(this DbSet<WebLogUser> db, string email) =>
await db.SingleOrDefaultAsync(wlu => wlu.UserName == email).ConfigureAwait(false);
}

View File

@ -1,521 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MyWebLog.Data;
#nullable disable
namespace MyWebLog.Data.Migrations
{
[DbContext(typeof(WebLogDbContext))]
[Migration("20220307034307_Initial")]
partial class Initial
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.2");
modelBuilder.Entity("CategoryPost", b =>
{
b.Property<string>("CategoriesId")
.HasColumnType("TEXT");
b.Property<string>("PostsId")
.HasColumnType("TEXT");
b.HasKey("CategoriesId", "PostsId");
b.HasIndex("PostsId");
b.ToTable("CategoryPost");
});
modelBuilder.Entity("MyWebLog.Data.Category", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ParentId")
.HasColumnType("TEXT");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ParentId");
b.HasIndex("Slug");
b.ToTable("Category");
});
modelBuilder.Entity("MyWebLog.Data.Comment", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("InReplyToId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PostId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("PostedOn")
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Url")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InReplyToId");
b.HasIndex("PostId");
b.ToTable("Comment");
});
modelBuilder.Entity("MyWebLog.Data.Page", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("AuthorId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Permalink")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("PublishedOn")
.HasColumnType("TEXT");
b.Property<bool>("ShowInPageList")
.HasColumnType("INTEGER");
b.Property<string>("Template")
.HasColumnType("TEXT");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("Permalink");
b.ToTable("Page");
});
modelBuilder.Entity("MyWebLog.Data.PagePermalink", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("PageId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PageId");
b.ToTable("PagePermalink");
});
modelBuilder.Entity("MyWebLog.Data.PageRevision", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<DateTime>("AsOf")
.HasColumnType("TEXT");
b.Property<string>("PageId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SourceType")
.HasColumnType("INTEGER");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PageId");
b.ToTable("PageRevision");
});
modelBuilder.Entity("MyWebLog.Data.Post", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("AuthorId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Permalink")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("PublishedOn")
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("Permalink");
b.ToTable("Post");
});
modelBuilder.Entity("MyWebLog.Data.PostPermalink", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("PostId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PostId");
b.ToTable("PostPermalink");
});
modelBuilder.Entity("MyWebLog.Data.PostRevision", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<DateTime>("AsOf")
.HasColumnType("TEXT");
b.Property<string>("PostId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SourceType")
.HasColumnType("INTEGER");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PostId");
b.ToTable("PostRevision");
});
modelBuilder.Entity("MyWebLog.Data.Tag", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Name");
b.ToTable("Tag");
});
modelBuilder.Entity("MyWebLog.Data.WebLogDetails", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("DefaultPage")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte>("PostsPerPage")
.HasColumnType("INTEGER");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.Property<string>("ThemePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TimeZone")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UrlBase")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Name");
b.ToTable("WebLogDetails");
});
modelBuilder.Entity("MyWebLog.Data.WebLogUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AuthorizationLevel")
.HasColumnType("INTEGER");
b.Property<string>("FirstName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("LastName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PreferredName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("Salt")
.HasColumnType("TEXT");
b.Property<string>("Url")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("WebLogUser");
});
modelBuilder.Entity("PostTag", b =>
{
b.Property<string>("PostsId")
.HasColumnType("TEXT");
b.Property<string>("TagsName")
.HasColumnType("TEXT");
b.HasKey("PostsId", "TagsName");
b.HasIndex("TagsName");
b.ToTable("PostTag");
});
modelBuilder.Entity("CategoryPost", b =>
{
b.HasOne("MyWebLog.Data.Category", null)
.WithMany()
.HasForeignKey("CategoriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MyWebLog.Data.Post", null)
.WithMany()
.HasForeignKey("PostsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MyWebLog.Data.Category", b =>
{
b.HasOne("MyWebLog.Data.Category", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("MyWebLog.Data.Comment", b =>
{
b.HasOne("MyWebLog.Data.Comment", "InReplyTo")
.WithMany()
.HasForeignKey("InReplyToId");
b.HasOne("MyWebLog.Data.Post", "Post")
.WithMany()
.HasForeignKey("PostId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("InReplyTo");
b.Navigation("Post");
});
modelBuilder.Entity("MyWebLog.Data.Page", b =>
{
b.HasOne("MyWebLog.Data.WebLogUser", "Author")
.WithMany("Pages")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
});
modelBuilder.Entity("MyWebLog.Data.PagePermalink", b =>
{
b.HasOne("MyWebLog.Data.Page", "Page")
.WithMany("PriorPermalinks")
.HasForeignKey("PageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Page");
});
modelBuilder.Entity("MyWebLog.Data.PageRevision", b =>
{
b.HasOne("MyWebLog.Data.Page", "Page")
.WithMany("Revisions")
.HasForeignKey("PageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Page");
});
modelBuilder.Entity("MyWebLog.Data.Post", b =>
{
b.HasOne("MyWebLog.Data.WebLogUser", "Author")
.WithMany("Posts")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
});
modelBuilder.Entity("MyWebLog.Data.PostPermalink", b =>
{
b.HasOne("MyWebLog.Data.Post", "Post")
.WithMany("PriorPermalinks")
.HasForeignKey("PostId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Post");
});
modelBuilder.Entity("MyWebLog.Data.PostRevision", b =>
{
b.HasOne("MyWebLog.Data.Post", "Post")
.WithMany("Revisions")
.HasForeignKey("PostId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Post");
});
modelBuilder.Entity("PostTag", b =>
{
b.HasOne("MyWebLog.Data.Post", null)
.WithMany()
.HasForeignKey("PostsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MyWebLog.Data.Tag", null)
.WithMany()
.HasForeignKey("TagsName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MyWebLog.Data.Page", b =>
{
b.Navigation("PriorPermalinks");
b.Navigation("Revisions");
});
modelBuilder.Entity("MyWebLog.Data.Post", b =>
{
b.Navigation("PriorPermalinks");
b.Navigation("Revisions");
});
modelBuilder.Entity("MyWebLog.Data.WebLogUser", b =>
{
b.Navigation("Pages");
b.Navigation("Posts");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,399 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace MyWebLog.Data.Migrations
{
public partial class Initial : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Category",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
Name = table.Column<string>(type: "TEXT", nullable: false),
Slug = table.Column<string>(type: "TEXT", nullable: false),
Description = table.Column<string>(type: "TEXT", nullable: true),
ParentId = table.Column<string>(type: "TEXT", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Category", x => x.Id);
table.ForeignKey(
name: "FK_Category_Category_ParentId",
column: x => x.ParentId,
principalTable: "Category",
principalColumn: "Id");
});
migrationBuilder.CreateTable(
name: "Tag",
columns: table => new
{
Name = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Tag", x => x.Name);
});
migrationBuilder.CreateTable(
name: "WebLogDetails",
columns: table => new
{
Name = table.Column<string>(type: "TEXT", nullable: false),
Subtitle = table.Column<string>(type: "TEXT", nullable: true),
DefaultPage = table.Column<string>(type: "TEXT", nullable: false),
PostsPerPage = table.Column<byte>(type: "INTEGER", nullable: false),
ThemePath = table.Column<string>(type: "TEXT", nullable: false),
UrlBase = table.Column<string>(type: "TEXT", nullable: false),
TimeZone = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WebLogDetails", x => x.Name);
});
migrationBuilder.CreateTable(
name: "WebLogUser",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
UserName = table.Column<string>(type: "TEXT", nullable: false),
FirstName = table.Column<string>(type: "TEXT", nullable: false),
LastName = table.Column<string>(type: "TEXT", nullable: false),
PreferredName = table.Column<string>(type: "TEXT", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
Salt = table.Column<Guid>(type: "TEXT", nullable: false),
Url = table.Column<string>(type: "TEXT", nullable: true),
AuthorizationLevel = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_WebLogUser", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Page",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
AuthorId = table.Column<string>(type: "TEXT", nullable: false),
Title = table.Column<string>(type: "TEXT", nullable: false),
Permalink = table.Column<string>(type: "TEXT", nullable: false),
PublishedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
UpdatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
ShowInPageList = table.Column<bool>(type: "INTEGER", nullable: false),
Template = table.Column<string>(type: "TEXT", nullable: true),
Text = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Page", x => x.Id);
table.ForeignKey(
name: "FK_Page_WebLogUser_AuthorId",
column: x => x.AuthorId,
principalTable: "WebLogUser",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Post",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
AuthorId = table.Column<string>(type: "TEXT", nullable: false),
Status = table.Column<int>(type: "INTEGER", nullable: false),
Title = table.Column<string>(type: "TEXT", nullable: false),
Permalink = table.Column<string>(type: "TEXT", nullable: false),
PublishedOn = table.Column<DateTime>(type: "TEXT", nullable: true),
UpdatedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
Text = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Post", x => x.Id);
table.ForeignKey(
name: "FK_Post_WebLogUser_AuthorId",
column: x => x.AuthorId,
principalTable: "WebLogUser",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PagePermalink",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
PageId = table.Column<string>(type: "TEXT", nullable: false),
Url = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PagePermalink", x => x.Id);
table.ForeignKey(
name: "FK_PagePermalink_Page_PageId",
column: x => x.PageId,
principalTable: "Page",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PageRevision",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
PageId = table.Column<string>(type: "TEXT", nullable: false),
AsOf = table.Column<DateTime>(type: "TEXT", nullable: false),
SourceType = table.Column<int>(type: "INTEGER", nullable: false),
Text = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PageRevision", x => x.Id);
table.ForeignKey(
name: "FK_PageRevision_Page_PageId",
column: x => x.PageId,
principalTable: "Page",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CategoryPost",
columns: table => new
{
CategoriesId = table.Column<string>(type: "TEXT", nullable: false),
PostsId = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CategoryPost", x => new { x.CategoriesId, x.PostsId });
table.ForeignKey(
name: "FK_CategoryPost_Category_CategoriesId",
column: x => x.CategoriesId,
principalTable: "Category",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CategoryPost_Post_PostsId",
column: x => x.PostsId,
principalTable: "Post",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Comment",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
PostId = table.Column<string>(type: "TEXT", nullable: false),
InReplyToId = table.Column<string>(type: "TEXT", nullable: true),
Name = table.Column<string>(type: "TEXT", nullable: false),
Email = table.Column<string>(type: "TEXT", nullable: false),
Url = table.Column<string>(type: "TEXT", nullable: true),
Status = table.Column<int>(type: "INTEGER", nullable: false),
PostedOn = table.Column<DateTime>(type: "TEXT", nullable: false),
Text = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Comment", x => x.Id);
table.ForeignKey(
name: "FK_Comment_Comment_InReplyToId",
column: x => x.InReplyToId,
principalTable: "Comment",
principalColumn: "Id");
table.ForeignKey(
name: "FK_Comment_Post_PostId",
column: x => x.PostId,
principalTable: "Post",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PostPermalink",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
PostId = table.Column<string>(type: "TEXT", nullable: false),
Url = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PostPermalink", x => x.Id);
table.ForeignKey(
name: "FK_PostPermalink_Post_PostId",
column: x => x.PostId,
principalTable: "Post",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PostRevision",
columns: table => new
{
Id = table.Column<string>(type: "TEXT", nullable: false),
PostId = table.Column<string>(type: "TEXT", nullable: false),
AsOf = table.Column<DateTime>(type: "TEXT", nullable: false),
SourceType = table.Column<int>(type: "INTEGER", nullable: false),
Text = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PostRevision", x => x.Id);
table.ForeignKey(
name: "FK_PostRevision_Post_PostId",
column: x => x.PostId,
principalTable: "Post",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "PostTag",
columns: table => new
{
PostsId = table.Column<string>(type: "TEXT", nullable: false),
TagsName = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PostTag", x => new { x.PostsId, x.TagsName });
table.ForeignKey(
name: "FK_PostTag_Post_PostsId",
column: x => x.PostsId,
principalTable: "Post",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_PostTag_Tag_TagsName",
column: x => x.TagsName,
principalTable: "Tag",
principalColumn: "Name",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_Category_ParentId",
table: "Category",
column: "ParentId");
migrationBuilder.CreateIndex(
name: "IX_Category_Slug",
table: "Category",
column: "Slug");
migrationBuilder.CreateIndex(
name: "IX_CategoryPost_PostsId",
table: "CategoryPost",
column: "PostsId");
migrationBuilder.CreateIndex(
name: "IX_Comment_InReplyToId",
table: "Comment",
column: "InReplyToId");
migrationBuilder.CreateIndex(
name: "IX_Comment_PostId",
table: "Comment",
column: "PostId");
migrationBuilder.CreateIndex(
name: "IX_Page_AuthorId",
table: "Page",
column: "AuthorId");
migrationBuilder.CreateIndex(
name: "IX_Page_Permalink",
table: "Page",
column: "Permalink");
migrationBuilder.CreateIndex(
name: "IX_PagePermalink_PageId",
table: "PagePermalink",
column: "PageId");
migrationBuilder.CreateIndex(
name: "IX_PageRevision_PageId",
table: "PageRevision",
column: "PageId");
migrationBuilder.CreateIndex(
name: "IX_Post_AuthorId",
table: "Post",
column: "AuthorId");
migrationBuilder.CreateIndex(
name: "IX_Post_Permalink",
table: "Post",
column: "Permalink");
migrationBuilder.CreateIndex(
name: "IX_PostPermalink_PostId",
table: "PostPermalink",
column: "PostId");
migrationBuilder.CreateIndex(
name: "IX_PostRevision_PostId",
table: "PostRevision",
column: "PostId");
migrationBuilder.CreateIndex(
name: "IX_PostTag_TagsName",
table: "PostTag",
column: "TagsName");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "CategoryPost");
migrationBuilder.DropTable(
name: "Comment");
migrationBuilder.DropTable(
name: "PagePermalink");
migrationBuilder.DropTable(
name: "PageRevision");
migrationBuilder.DropTable(
name: "PostPermalink");
migrationBuilder.DropTable(
name: "PostRevision");
migrationBuilder.DropTable(
name: "PostTag");
migrationBuilder.DropTable(
name: "WebLogDetails");
migrationBuilder.DropTable(
name: "Category");
migrationBuilder.DropTable(
name: "Page");
migrationBuilder.DropTable(
name: "Post");
migrationBuilder.DropTable(
name: "Tag");
migrationBuilder.DropTable(
name: "WebLogUser");
}
}
}

View File

@ -1,519 +0,0 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using MyWebLog.Data;
#nullable disable
namespace MyWebLog.Data.Migrations
{
[DbContext(typeof(WebLogDbContext))]
partial class WebLogDbContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.2");
modelBuilder.Entity("CategoryPost", b =>
{
b.Property<string>("CategoriesId")
.HasColumnType("TEXT");
b.Property<string>("PostsId")
.HasColumnType("TEXT");
b.HasKey("CategoriesId", "PostsId");
b.HasIndex("PostsId");
b.ToTable("CategoryPost");
});
modelBuilder.Entity("MyWebLog.Data.Category", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("ParentId")
.HasColumnType("TEXT");
b.Property<string>("Slug")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ParentId");
b.HasIndex("Slug");
b.ToTable("Category");
});
modelBuilder.Entity("MyWebLog.Data.Comment", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("InReplyToId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PostId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("PostedOn")
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Url")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("InReplyToId");
b.HasIndex("PostId");
b.ToTable("Comment");
});
modelBuilder.Entity("MyWebLog.Data.Page", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("AuthorId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Permalink")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("PublishedOn")
.HasColumnType("TEXT");
b.Property<bool>("ShowInPageList")
.HasColumnType("INTEGER");
b.Property<string>("Template")
.HasColumnType("TEXT");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("Permalink");
b.ToTable("Page");
});
modelBuilder.Entity("MyWebLog.Data.PagePermalink", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("PageId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PageId");
b.ToTable("PagePermalink");
});
modelBuilder.Entity("MyWebLog.Data.PageRevision", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<DateTime>("AsOf")
.HasColumnType("TEXT");
b.Property<string>("PageId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SourceType")
.HasColumnType("INTEGER");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PageId");
b.ToTable("PageRevision");
});
modelBuilder.Entity("MyWebLog.Data.Post", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("AuthorId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Permalink")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime?>("PublishedOn")
.HasColumnType("TEXT");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTime>("UpdatedOn")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("AuthorId");
b.HasIndex("Permalink");
b.ToTable("Post");
});
modelBuilder.Entity("MyWebLog.Data.PostPermalink", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<string>("PostId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Url")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PostId");
b.ToTable("PostPermalink");
});
modelBuilder.Entity("MyWebLog.Data.PostRevision", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<DateTime>("AsOf")
.HasColumnType("TEXT");
b.Property<string>("PostId")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("SourceType")
.HasColumnType("INTEGER");
b.Property<string>("Text")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("PostId");
b.ToTable("PostRevision");
});
modelBuilder.Entity("MyWebLog.Data.Tag", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("Name");
b.ToTable("Tag");
});
modelBuilder.Entity("MyWebLog.Data.WebLogDetails", b =>
{
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<string>("DefaultPage")
.IsRequired()
.HasColumnType("TEXT");
b.Property<byte>("PostsPerPage")
.HasColumnType("INTEGER");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.Property<string>("ThemePath")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("TimeZone")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("UrlBase")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Name");
b.ToTable("WebLogDetails");
});
modelBuilder.Entity("MyWebLog.Data.WebLogUser", b =>
{
b.Property<string>("Id")
.HasColumnType("TEXT");
b.Property<int>("AuthorizationLevel")
.HasColumnType("INTEGER");
b.Property<string>("FirstName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("LastName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PreferredName")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("Salt")
.HasColumnType("TEXT");
b.Property<string>("Url")
.HasColumnType("TEXT");
b.Property<string>("UserName")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("WebLogUser");
});
modelBuilder.Entity("PostTag", b =>
{
b.Property<string>("PostsId")
.HasColumnType("TEXT");
b.Property<string>("TagsName")
.HasColumnType("TEXT");
b.HasKey("PostsId", "TagsName");
b.HasIndex("TagsName");
b.ToTable("PostTag");
});
modelBuilder.Entity("CategoryPost", b =>
{
b.HasOne("MyWebLog.Data.Category", null)
.WithMany()
.HasForeignKey("CategoriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MyWebLog.Data.Post", null)
.WithMany()
.HasForeignKey("PostsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MyWebLog.Data.Category", b =>
{
b.HasOne("MyWebLog.Data.Category", "Parent")
.WithMany()
.HasForeignKey("ParentId");
b.Navigation("Parent");
});
modelBuilder.Entity("MyWebLog.Data.Comment", b =>
{
b.HasOne("MyWebLog.Data.Comment", "InReplyTo")
.WithMany()
.HasForeignKey("InReplyToId");
b.HasOne("MyWebLog.Data.Post", "Post")
.WithMany()
.HasForeignKey("PostId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("InReplyTo");
b.Navigation("Post");
});
modelBuilder.Entity("MyWebLog.Data.Page", b =>
{
b.HasOne("MyWebLog.Data.WebLogUser", "Author")
.WithMany("Pages")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
});
modelBuilder.Entity("MyWebLog.Data.PagePermalink", b =>
{
b.HasOne("MyWebLog.Data.Page", "Page")
.WithMany("PriorPermalinks")
.HasForeignKey("PageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Page");
});
modelBuilder.Entity("MyWebLog.Data.PageRevision", b =>
{
b.HasOne("MyWebLog.Data.Page", "Page")
.WithMany("Revisions")
.HasForeignKey("PageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Page");
});
modelBuilder.Entity("MyWebLog.Data.Post", b =>
{
b.HasOne("MyWebLog.Data.WebLogUser", "Author")
.WithMany("Posts")
.HasForeignKey("AuthorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Author");
});
modelBuilder.Entity("MyWebLog.Data.PostPermalink", b =>
{
b.HasOne("MyWebLog.Data.Post", "Post")
.WithMany("PriorPermalinks")
.HasForeignKey("PostId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Post");
});
modelBuilder.Entity("MyWebLog.Data.PostRevision", b =>
{
b.HasOne("MyWebLog.Data.Post", "Post")
.WithMany("Revisions")
.HasForeignKey("PostId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Post");
});
modelBuilder.Entity("PostTag", b =>
{
b.HasOne("MyWebLog.Data.Post", null)
.WithMany()
.HasForeignKey("PostsId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("MyWebLog.Data.Tag", null)
.WithMany()
.HasForeignKey("TagsName")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("MyWebLog.Data.Page", b =>
{
b.Navigation("PriorPermalinks");
b.Navigation("Revisions");
});
modelBuilder.Entity("MyWebLog.Data.Post", b =>
{
b.Navigation("PriorPermalinks");
b.Navigation("Revisions");
});
modelBuilder.Entity("MyWebLog.Data.WebLogUser", b =>
{
b.Navigation("Pages");
b.Navigation("Posts");
});
#pragma warning restore 612, 618
}
}
}

View File

@ -1,20 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.2" />
</ItemGroup>
<PropertyGroup>
<GenerateRuntimeConfigurationFiles>True</GenerateRuntimeConfigurationFiles>
</PropertyGroup>
</Project>

View File

@ -1,67 +0,0 @@
namespace MyWebLog.Data;
/// <summary>
/// A page (text not associated with a date/time)
/// </summary>
public class Page
{
/// <summary>
/// The ID of this page
/// </summary>
public string Id { get; set; } = "";
/// <summary>
/// The ID of the author of this page
/// </summary>
public string AuthorId { get; set; } = "";
/// <summary>
/// The author of this page
/// </summary>
public WebLogUser Author { get; set; } = default!;
/// <summary>
/// The title of the page
/// </summary>
public string Title { get; set; } = "";
/// <summary>
/// The link at which this page is displayed
/// </summary>
public string Permalink { get; set; } = "";
/// <summary>
/// The instant this page was published
/// </summary>
public DateTime PublishedOn { get; set; } = DateTime.MinValue;
/// <summary>
/// The instant this page was last updated
/// </summary>
public DateTime UpdatedOn { get; set; } = DateTime.MinValue;
/// <summary>
/// Whether this page shows as part of the web log's navigation
/// </summary>
public bool ShowInPageList { get; set; } = false;
/// <summary>
/// The template to use when rendering this page
/// </summary>
public string? Template { get; set; } = null;
/// <summary>
/// The current text of the page
/// </summary>
public string Text { get; set; } = "";
/// <summary>
/// Permalinks at which this page may have been previously served (useful for migrated content)
/// </summary>
public ICollection<PagePermalink> PriorPermalinks { get; set; } = default!;
/// <summary>
/// Revisions of this page
/// </summary>
public ICollection<PageRevision> Revisions { get; set; } = default!;
}

View File

@ -1,49 +0,0 @@
namespace MyWebLog.Data;
/// <summary>
/// A permalink which a post or page used to have
/// </summary>
public abstract class Permalink
{
/// <summary>
/// The ID of this permalink
/// </summary>
public string Id { get; set; } = "";
/// <summary>
/// The link
/// </summary>
public string Url { get; set; } = "";
}
/// <summary>
/// A prior permalink for a page
/// </summary>
public class PagePermalink : Permalink
{
/// <summary>
/// The ID of the page to which this permalink belongs
/// </summary>
public string PageId { get; set; } = "";
/// <summary>
/// The page to which this permalink belongs
/// </summary>
public Page Page { get; set; } = default!;
}
/// <summary>
/// A prior permalink for a post
/// </summary>
public class PostPermalink : Permalink
{
/// <summary>
/// The ID of the post to which this permalink belongs
/// </summary>
public string PostId { get; set; } = "";
/// <summary>
/// The post to which this permalink belongs
/// </summary>
public Post Post { get; set; } = default!;
}

View File

@ -1,72 +0,0 @@
namespace MyWebLog.Data;
/// <summary>
/// A web log post
/// </summary>
public class Post
{
/// <summary>
/// The ID of this post
/// </summary>
public string Id { get; set; } = "";
/// <summary>
/// The ID of the author of this post
/// </summary>
public string AuthorId { get; set; } = "";
/// <summary>
/// The author of the post
/// </summary>
public WebLogUser Author { get; set; } = default!;
/// <summary>
/// The status
/// </summary>
public PostStatus Status { get; set; } = PostStatus.Draft;
/// <summary>
/// The title
/// </summary>
public string Title { get; set; } = "";
/// <summary>
/// The link at which the post resides
/// </summary>
public string Permalink { get; set; } = "";
/// <summary>
/// The instant on which the post was originally published
/// </summary>
public DateTime? PublishedOn { get; set; } = null;
/// <summary>
/// The instant on which the post was last updated
/// </summary>
public DateTime UpdatedOn { get; set; } = DateTime.MinValue;
/// <summary>
/// The text of the post in HTML (ready to display) format
/// </summary>
public string Text { get; set; } = "";
/// <summary>
/// The Ids of the categories to which this is assigned
/// </summary>
public ICollection<Category> Categories { get; set; } = default!;
/// <summary>
/// The tags for the post
/// </summary>
public ICollection<Tag> Tags { get; set; } = default!;
/// <summary>
/// Permalinks at which this post may have been previously served (useful for migrated content)
/// </summary>
public ICollection<PostPermalink> PriorPermalinks { get; set; } = default!;
/// <summary>
/// The revisions for this post
/// </summary>
public ICollection<PostRevision> Revisions { get; set; } = default!;
}

View File

@ -1,59 +0,0 @@
namespace MyWebLog.Data;
/// <summary>
/// A revision of a page or post
/// </summary>
public abstract class Revision
{
/// <summary>
/// The ID of this revision
/// </summary>
public string Id { get; set; } = "";
/// <summary>
/// When this revision was saved
/// </summary>
public DateTime AsOf { get; set; } = DateTime.UtcNow;
/// <summary>
/// The source language (Markdown or HTML)
/// </summary>
public RevisionSource SourceType { get; set; } = RevisionSource.Html;
/// <summary>
/// The text of the revision
/// </summary>
public string Text { get; set; } = "";
}
/// <summary>
/// A revision of a page
/// </summary>
public class PageRevision : Revision
{
/// <summary>
/// The ID of the page to which this revision belongs
/// </summary>
public string PageId { get; set; } = "";
/// <summary>
/// The page to which this revision belongs
/// </summary>
public Page Page { get; set; } = default!;
}
/// <summary>
/// A revision of a post
/// </summary>
public class PostRevision : Revision
{
/// <summary>
/// The ID of the post to which this revision applies
/// </summary>
public string PostId { get; set; } = "";
/// <summary>
/// The post to which this revision applies
/// </summary>
public Post Post { get; set; } = default!;
}

View File

@ -1,17 +0,0 @@
namespace MyWebLog.Data;
/// <summary>
/// A tag
/// </summary>
public class Tag
{
/// <summary>
/// The name of the tag
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// The posts with this tag assigned
/// </summary>
public ICollection<Post> Posts { get; set; } = default!;
}

View File

@ -1,87 +0,0 @@
using Microsoft.EntityFrameworkCore;
namespace MyWebLog.Data;
/// <summary>
/// Data context for web log data
/// </summary>
public sealed class WebLogDbContext : DbContext
{
/// <summary>
/// Create a new ID (short GUID)
/// </summary>
/// <returns>A new short GUID</returns>
/// <remarks>https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID</remarks>
public static string NewId() =>
Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace('/', '_').Replace('+', '-')[..22];
/// <summary>
/// The categories for the web log
/// </summary>
public DbSet<Category> Categories { get; set; } = default!;
/// <summary>
/// Comments on posts
/// </summary>
public DbSet<Comment> Comments { get; set; } = default!;
/// <summary>
/// Pages
/// </summary>
public DbSet<Page> Pages { get; set; } = default!;
/// <summary>
/// Web log posts
/// </summary>
public DbSet<Post> Posts { get; set; } = default!;
/// <summary>
/// Post tags
/// </summary>
public DbSet<Tag> Tags { get; set; } = default!;
/// <summary>
/// The users of the web log
/// </summary>
public DbSet<WebLogUser> Users { get; set; } = default!;
/// <summary>
/// The details for the web log
/// </summary>
public DbSet<WebLogDetails> WebLogDetails { get; set; } = default!;
/// <summary>
/// Constructor
/// </summary>
/// <param name="options">Configuration options</param>
public WebLogDbContext(DbContextOptions<WebLogDbContext> options) : base(options) { }
/// <inheritdoc />
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
optionsBuilder.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking);
}
/// <inheritdoc />
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// Make tables use singular names
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
entityType.SetTableName(entityType.DisplayName().Split(' ')[0]);
// Tag and WebLogDetails use Name as its ID
modelBuilder.Entity<Tag>().HasKey(t => t.Name);
modelBuilder.Entity<WebLogDetails>().HasKey(wld => wld.Name);
// Index slugs and links
modelBuilder.Entity<Category>().HasIndex(c => c.Slug);
modelBuilder.Entity<Page>().HasIndex(p => p.Permalink);
modelBuilder.Entity<Post>().HasIndex(p => p.Permalink);
// Link "author" to "user"
modelBuilder.Entity<Page>().HasOne(p => p.Author).WithMany(wbu => wbu.Pages).HasForeignKey(p => p.AuthorId);
modelBuilder.Entity<Post>().HasOne(p => p.Author).WithMany(wbu => wbu.Posts).HasForeignKey(p => p.AuthorId);
}
}

View File

@ -1,42 +0,0 @@
namespace MyWebLog.Data;
/// <summary>
/// The details about a web log
/// </summary>
public class WebLogDetails
{
/// <summary>
/// The name of the web log
/// </summary>
public string Name { get; set; } = "";
/// <summary>
/// A subtitle for the web log
/// </summary>
public string? Subtitle { get; set; } = null;
/// <summary>
/// The default page ("posts" or a page Id)
/// </summary>
public string DefaultPage { get; set; } = "";
/// <summary>
/// The number of posts to display on pages of posts
/// </summary>
public byte PostsPerPage { get; set; } = 10;
/// <summary>
/// The path of the theme (within /views/themes)
/// </summary>
public string ThemePath { get; set; } = "Default";
/// <summary>
/// The URL base
/// </summary>
public string UrlBase { get; set; } = "";
/// <summary>
/// The time zone in which dates/times should be displayed
/// </summary>
public string TimeZone { get; set; } = "";
}

View File

@ -1,62 +0,0 @@
namespace MyWebLog.Data;
/// <summary>
/// A user of the web log
/// </summary>
public class WebLogUser
{
/// <summary>
/// The ID of the user
/// </summary>
public string Id { get; set; } = "";
/// <summary>
/// The user name (e-mail address)
/// </summary>
public string UserName { get; set; } = "";
/// <summary>
/// The user's first name
/// </summary>
public string FirstName { get; set; } = "";
/// <summary>
/// The user's last name
/// </summary>
public string LastName { get; set; } = "";
/// <summary>
/// The user's preferred name
/// </summary>
public string PreferredName { get; set; } = "";
/// <summary>
/// The hash of the user's password
/// </summary>
public string PasswordHash { get; set; } = "";
/// <summary>
/// Salt used to calculate the user's password hash
/// </summary>
public Guid Salt { get; set; } = Guid.Empty;
/// <summary>
/// The URL of the user's personal site
/// </summary>
public string? Url { get; set; } = null;
/// <summary>
/// The user's authorization level
/// </summary>
public AuthorizationLevel AuthorizationLevel { get; set; } = AuthorizationLevel.User;
/// <summary>
/// Pages written by this author
/// </summary>
public ICollection<Page> Pages { get; set; } = default!;
/// <summary>
/// Posts written by this author
/// </summary>
public ICollection<Post> Posts { get; set; } = default!;
}

View File

@ -2,7 +2,7 @@
open System open System
/// A category under which a post may be identfied /// A category under which a post may be identified
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Category = type Category =
{ /// The ID of the category { /// The ID of the category
@ -120,7 +120,7 @@ type Page =
text : string text : string
/// Permalinks at which this page may have been previously served (useful for migrated content) /// Permalinks at which this page may have been previously served (useful for migrated content)
priorPermalinks : string list priorPermalinks : Permalink list
/// Revisions of this page /// Revisions of this page
revisions : Revision list revisions : Revision list

View File

@ -61,6 +61,14 @@ type RevisionSource =
/// HTML /// HTML
| Html | Html
/// Functions to support revision sources
module RevisionSource =
/// Convert a revision source to a string representation
let toString = function Markdown -> "Markdown" | Html -> "HTML"
/// Convert a string to a revision source
let ofString = function "Markdown" -> Markdown | "HTML" -> Html | x -> invalidArg "string" x
/// A revision of a page or post /// A revision of a page or post
[<CLIMutable; NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]

View File

@ -1,5 +1,43 @@
namespace MyWebLog.ViewModels namespace MyWebLog.ViewModels
open MyWebLog
open System
/// Details about a page used to display page lists
type DisplayPage =
{ /// The ID of this page
id : string
/// The title of the page
title : string
/// The link at which this page is displayed
permalink : string
/// When this page was published
publishedOn : DateTime
/// When this page was last updated
updatedOn : DateTime
/// Whether this page shows as part of the web log's navigation
showInPageList : bool
/// Is this the default page?
isDefault : bool
}
/// Create a display page from a database page
static member fromPage webLog (page : Page) =
let pageId = PageId.toString page.id
{ id = pageId
title = page.title
permalink = Permalink.toString page.permalink
publishedOn = page.publishedOn
updatedOn = page.updatedOn
showInPageList = page.showInPageList
isDefault = pageId = webLog.defaultPage
}
/// The model to use to allow a user to log on /// The model to use to allow a user to log on
[<CLIMutable>] [<CLIMutable>]
@ -34,6 +72,42 @@ type DashboardModel =
} }
/// View model to edit a page
[<CLIMutable>]
type EditPageModel =
{ /// The ID of the page being edited
pageId : string
/// The title of the page
title : string
/// The permalink for the page
permalink : string
/// Whether this page is shown in the page list
isShownInPageList : bool
/// The source format for the text
source : string
/// The text of the page
text : string
}
/// Create an edit model from an existing page
static member fromPage (page : Page) =
let latest =
match page.revisions |> List.sortByDescending (fun r -> r.asOf) |> List.tryHead with
| Some rev -> rev
| None -> Revision.empty
{ pageId = PageId.toString page.id
title = page.title
permalink = Permalink.toString page.permalink
isShownInPageList = page.showInPageList
source = RevisionSource.toString latest.sourceType
text = latest.text
}
/// View model for editing web log settings /// View model for editing web log settings
[<CLIMutable>] [<CLIMutable>]
type SettingsModel = type SettingsModel =

View File

@ -1,178 +0,0 @@
module Program
open MyWebLog
open MyWebLog.Data.RethinkDB
open MyWebLog.Entities
open Nancy.Cryptography
open Newtonsoft.Json
open Newtonsoft.Json.Linq
open NodaTime
open RethinkDb.Driver
open System
open System.Linq
let r = RethinkDB.R
let appCfg = try AppConfig.FromJson (System.IO.File.ReadAllText "config.json")
with ex -> raise <| Exception ("Bad config.json file", ex)
let cfg = appCfg.DataConfig
// DataConfig.Connect
// (JsonConvert.DeserializeObject<DataConfig>("""{ "hostname" : "data01", "authKey" : "1d9a76f8-2d85-4033-be15-1f4313a96bb2", "database" : "myWebLog" }"""))
let conn = cfg.Conn
let toTicks (dt : DateTime) = Instant.FromDateTimeUtc(dt.ToUniversalTime()).ToUnixTimeTicks ()
/// Hash the user's password
let pbkdf2 (pw : string) =
PassphraseKeyGenerator(pw, appCfg.PasswordSalt, 4096).GetBytes 512
|> Seq.fold (fun acc byt -> sprintf "%s%s" acc (byt.ToString "x2")) ""
let migr8 () =
SetUp.startUpCheck cfg
Console.WriteLine "Migrating web logs..."
r.Db("MyWebLog").Table(Table.WebLog)
.RunCursor<JObject>(conn)
|> Seq.iter (fun x ->
r.Db("myWebLog").Table(Table.WebLog)
.Insert({ Id = string x.["id"]
Name = string x.["name"]
Subtitle = Some <| string x.["subtitle"]
DefaultPage = string x.["defaultPage"]
ThemePath = string x.["themePath"]
TimeZone = string x.["timeZone"]
UrlBase = string x.["urlBase"]
PageList = []
})
.RunResult(conn)
|> ignore)
Console.WriteLine "Migrating users..."
r.Db("MyWebLog").Table(Table.User)
.RunCursor<JObject>(conn)
|> Seq.iter (fun x ->
r.Db("myWebLog").Table(Table.User)
.Insert({ Id = string x.["id"]
UserName = string x.["userName"]
FirstName = string x.["firstName"]
LastName = string x.["lastName"]
PreferredName = string x.["preferredName"]
PasswordHash = string x.["passwordHash"]
Url = Some <| string x.["url"]
Authorizations = x.["authorizations"] :?> JArray
|> Seq.map (fun y -> { WebLogId = string y.["webLogId"]
Level = string y.["level"] })
|> Seq.toList
})
.RunResult(conn)
|> ignore)
Console.WriteLine "Migrating categories..."
r.Db("MyWebLog").Table(Table.Category)
.RunCursor<JObject>(conn)
|> Seq.iter (fun x ->
r.Db("myWebLog").Table(Table.Category)
.Insert({ Id = string x.["id"]
WebLogId = string x.["webLogId"]
Name = string x.["name"]
Slug = string x.["slug"]
Description = match String.IsNullOrEmpty(string x.["description"]) with
| true -> None
| _ -> Some <| string x.["description"]
ParentId = match String.IsNullOrEmpty(string x.["parentId"]) with
| true -> None
| _ -> Some <| string x.["parentId"]
Children = x.["children"] :?> JArray
|> Seq.map (fun y -> string y)
|> Seq.toList
})
.RunResult(conn)
|> ignore)
Console.WriteLine "Migrating comments..."
r.Db("MyWebLog").Table(Table.Comment)
.RunCursor<JObject>(conn)
|> Seq.iter (fun x ->
r.Db("myWebLog").Table(Table.Comment)
.Insert({ Id = string x.["id"]
PostId = string x.["postId"]
InReplyToId = match String.IsNullOrEmpty(string x.["inReplyToId"]) with
| true -> None
| _ -> Some <| string x.["inReplyToId"]
Name = string x.["name"]
Email = string x.["email"]
Url = match String.IsNullOrEmpty(string x.["url"]) with
| true -> None
| _ -> Some <| string x.["url"]
Status = string x.["status"]
PostedOn = x.["postedDate"].ToObject<DateTime>() |> toTicks
Text = string x.["text"]
})
.RunResult(conn)
|> ignore)
Console.WriteLine "Migrating pages..."
r.Db("MyWebLog").Table(Table.Page)
.RunCursor<JObject>(conn)
|> Seq.iter (fun x ->
r.Db("myWebLog").Table(Table.Page)
.Insert({ Id = string x.["id"]
WebLogId = string x.["webLogId"]
AuthorId = string x.["authorId"]
Title = string x.["title"]
Permalink = string x.["permalink"]
PublishedOn = x.["publishedDate"].ToObject<DateTime> () |> toTicks
UpdatedOn = x.["lastUpdatedDate"].ToObject<DateTime> () |> toTicks
ShowInPageList = x.["showInPageList"].ToObject<bool>()
Text = string x.["text"]
Revisions = [{ AsOf = x.["lastUpdatedDate"].ToObject<DateTime> () |> toTicks
SourceType = RevisionSource.HTML
Text = string x.["text"]
}]
})
.RunResult(conn)
|> ignore)
Console.WriteLine "Migrating posts..."
r.Db("MyWebLog").Table(Table.Post)
.RunCursor<JObject>(conn)
|> Seq.iter (fun x ->
r.Db("myWebLog").Table(Table.Post)
.Insert({ Id = string x.["id"]
WebLogId = string x.["webLogId"]
AuthorId = "9b491a0f-48df-4b7b-8c10-120b5cd02895"
Status = string x.["status"]
Title = string x.["title"]
Permalink = string x.["permalink"]
PublishedOn = match x.["publishedDate"] with
| null -> int64 0
| dt -> dt.ToObject<DateTime> () |> toTicks
UpdatedOn = x.["lastUpdatedDate"].ToObject<DateTime> () |> toTicks
Revisions = [{ AsOf = x.["lastUpdatedDate"].ToObject<DateTime> ()
|> toTicks
SourceType = RevisionSource.HTML
Text = string x.["text"]
}]
Text = string x.["text"]
Tags = x.["tag"] :?> JArray
|> Seq.map (fun y -> string y)
|> Seq.toList
CategoryIds = x.["category"] :?> JArray
|> Seq.map (fun y -> string y)
|> Seq.toList
PriorPermalinks = []
Categories = []
Comments = []
})
.RunResult(conn)
|> ignore)
[<EntryPoint>]
let main argv =
migr8 ()
0 // return an integer exit code

View File

@ -1,33 +0,0 @@
{
"version": "1.0.0-*",
"buildOptions": {
"debugType": "portable",
"emitEntryPoint": true,
"compilerName": "fsc",
"compile": {
"includeFiles": [
"Program.fs"
]
}
},
"dependencies": {
"MyWebLog.App": "0.9.2",
"MyWebLog.Data.RethinkDB": "0.9.2",
"MyWebLog.Entities": "0.9.2",
"NodaTime": "2.0.0-alpha20160729"
},
"tools": {
"dotnet-compile-fsc":"1.0.0-preview2-*"
},
"frameworks": {
"netcoreapp1.0": {
"dependencies": {
"Microsoft.NETCore.App": {
"type": "platform",
"version": "1.0.1"
},
"Microsoft.FSharp.Core.netcore": "1.0.0-alpha-160629"
}
}
}
}

View File

@ -7,12 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.Themes.BitBadger",
EndProject EndProject
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.Domain", "MyWebLog.Domain\MyWebLog.Domain.fsproj", "{8CA99122-888A-4524-8C1B-685F0A4B7B4B}" Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.Domain", "MyWebLog.Domain\MyWebLog.Domain.fsproj", "{8CA99122-888A-4524-8C1B-685F0A4B7B4B}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.DataCS", "MyWebLog.DataCS\MyWebLog.DataCS.csproj", "{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}"
EndProject
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.fsproj", "{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}" Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.fsproj", "{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.CS", "MyWebLog.CS\MyWebLog.CS.csproj", "{B23A8093-28B1-4CB5-93F1-B4659516B74F}"
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
@ -29,18 +25,10 @@ Global
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Debug|Any CPU.Build.0 = Debug|Any CPU {8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Release|Any CPU.ActiveCfg = Release|Any CPU {8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Release|Any CPU.Build.0 = Release|Any CPU {8CA99122-888A-4524-8C1B-685F0A4B7B4B}.Release|Any CPU.Build.0 = Release|Any CPU
{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C9129BED-E4AE-41BB-BDB2-5418B7F924CC}.Release|Any CPU.Build.0 = Release|Any CPU
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Debug|Any CPU.Build.0 = Debug|Any CPU {D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Release|Any CPU.ActiveCfg = Release|Any CPU {D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Release|Any CPU.Build.0 = Release|Any CPU {D284584D-2CB2-40C8-B605-6D0FD84D9D3D}.Release|Any CPU.Build.0 = Release|Any CPU
{B23A8093-28B1-4CB5-93F1-B4659516B74F}.Debug|Any CPU.ActiveCfg = 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.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

74
src/MyWebLog/Caches.fs Normal file
View File

@ -0,0 +1,74 @@
namespace MyWebLog
open Microsoft.AspNetCore.Http
/// Helper functions for caches
module Cache =
/// Create the cache key for the web log for the current request
let makeKey (ctx : HttpContext) = ctx.Request.Host.ToUriComponent ()
open System.Collections.Concurrent
/// <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 WebLogCache =
/// The cache of web log details
let private _cache = ConcurrentDictionary<string, WebLog> ()
/// Does a host exist in the cache?
let exists ctx = _cache.ContainsKey (Cache.makeKey ctx)
/// Get the web log for the current request
let get ctx = _cache[Cache.makeKey ctx]
/// Cache the web log for a particular host
let set ctx webLog = _cache[Cache.makeKey ctx] <- webLog
/// A cache of page information needed to display the page list in templates
module PageListCache =
open Microsoft.Extensions.DependencyInjection
open MyWebLog.ViewModels
open RethinkDb.Driver.Net
/// Cache of displayed pages
let private _cache = ConcurrentDictionary<string, DisplayPage[]> ()
/// Get the pages for the web log for this request
let get ctx = _cache[Cache.makeKey ctx]
/// Update the pages for the current web log
let update ctx = task {
let webLog = WebLogCache.get ctx
let conn = ctx.RequestServices.GetRequiredService<IConnection> ()
let! pages = Data.Page.findListed webLog.id conn
_cache[Cache.makeKey ctx] <- pages |> List.map (DisplayPage.fromPage webLog) |> Array.ofList
}
/// Cache for parsed templates
module TemplateCache =
open DotLiquid
open System.IO
/// Cache of parsed templates
let private _cache = ConcurrentDictionary<string, Template> ()
/// Get a template for the given theme and template nate
let get (theme : string) (templateName : string) = task {
let templatePath = $"themes/{theme}/{templateName}"
match _cache.ContainsKey templatePath with
| true -> ()
| false ->
let! file = File.ReadAllTextAsync $"{templatePath}.liquid"
_cache[templatePath] <- Template.Parse (file, SyntaxCompatibility.DotLiquid22)
return _cache[templatePath]
}

View File

@ -1,7 +1,6 @@
[<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
@ -26,12 +25,11 @@ module Error =
>=> text ex.Message *) >=> text ex.Message *)
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response /// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
let notAuthorized : HttpHandler = let notAuthorized : HttpHandler = fun next ctx ->
fun next ctx -> (next, ctx)
(next, ctx) ||> match ctx.Request.Method with
||> match ctx.Request.Method with | "GET" -> redirectTo false $"/user/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}"
| "GET" -> redirectTo false $"/user/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}" | _ -> setStatusCode 401 >=> fun _ _ -> Task.FromResult<HttpContext option> None
| _ -> setStatusCode 401 >=> fun _ _ -> Task.FromResult<HttpContext option> None
/// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there /// Handle 404s from the API, sending known URL paths to the Vue app so that they can be handled there
let notFound : HttpHandler = let notFound : HttpHandler =
@ -41,42 +39,27 @@ module Error =
[<AutoOpen>] [<AutoOpen>]
module private Helpers = module private Helpers =
open Markdig
open Microsoft.AspNetCore.Antiforgery open Microsoft.AspNetCore.Antiforgery
open Microsoft.Extensions.DependencyInjection open Microsoft.Extensions.DependencyInjection
open System.Collections.Concurrent open System.Security.Claims
open System.IO
/// Cache for parsed templates
module private TemplateCache =
/// Cache of parsed templates
let private views = ConcurrentDictionary<string, Template> ()
/// Get a template for the given web log
let get (theme : string) (templateName : string) = task {
let templatePath = $"themes/{theme}/{templateName}"
match views.ContainsKey templatePath with
| true -> ()
| false ->
let! file = File.ReadAllTextAsync $"{templatePath}.liquid"
views[templatePath] <- Template.Parse (file, SyntaxCompatibility.DotLiquid22)
return views[templatePath]
}
/// Either get the web log from the hash, or get it from the cache and add it to the hash /// Either get the web log from the hash, or get it from the cache and add it to the hash
let deriveWebLogFromHash (hash : Hash) ctx = let private deriveWebLogFromHash (hash : Hash) ctx =
match hash.ContainsKey "web_log" with match hash.ContainsKey "web_log" with
| true -> hash["web_log"] :?> WebLog | true -> hash["web_log"] :?> WebLog
| false -> | false ->
let wl = WebLogCache.getByCtx ctx let wl = WebLogCache.get ctx
hash.Add ("web_log", wl) hash.Add ("web_log", wl)
wl wl
/// Render a view for the specified theme, using the specified template, layout, and hash /// Render a view for the specified theme, using the specified template, layout, and hash
let viewForTheme theme template layout next ctx = fun (hash : Hash) -> task { let viewForTheme theme template next ctx = fun (hash : Hash) -> task {
// Don't need the web log, but this adds it to the hash if the function is called directly // Don't need the web log, but this adds it to the hash if the function is called directly
let _ = deriveWebLogFromHash hash ctx let _ = deriveWebLogFromHash hash ctx
hash.Add ("logged_on", ctx.User.Identity.IsAuthenticated) hash.Add ("logged_on", ctx.User.Identity.IsAuthenticated)
hash.Add ("page_list", PageListCache.get ctx)
hash.Add ("current_page", ctx.Request.Path.Value.Substring 1)
// NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a two-pass // NOTE: DotLiquid does not support {% render %} or {% include %} in its templates, so we will do a two-pass
// render; the net effect is a "layout" capability similar to Razor or Pug // render; the net effect is a "layout" capability similar to Razor or Pug
@ -86,20 +69,26 @@ module private Helpers =
hash.Add ("content", contentTemplate.Render hash) hash.Add ("content", contentTemplate.Render hash)
// ...then render that content with its layout // ...then render that content with its layout
let! layoutTemplate = TemplateCache.get theme (defaultArg layout "layout") let! layoutTemplate = TemplateCache.get theme "layout"
return! htmlString (layoutTemplate.Render hash) next ctx return! htmlString (layoutTemplate.Render hash) next ctx
} }
/// Return a view for the web log's default theme /// Return a view for the web log's default theme
let themedView template layout next ctx = fun (hash : Hash) -> task { let themedView template next ctx = fun (hash : Hash) -> task {
return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template layout next ctx hash return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template next ctx hash
} }
/// The web log ID for the current request /// Get the web log ID for the current request
let webLogId ctx = (WebLogCache.getByCtx ctx).id let webLogId ctx = (WebLogCache.get ctx).id
/// Get the user ID for the current request
let userId (ctx : HttpContext) =
WebLogUserId (ctx.User.Claims |> Seq.find (fun c -> c.Type = ClaimTypes.NameIdentifier)).Value
/// Get the RethinkDB connection
let conn (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IConnection> () let conn (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IConnection> ()
/// Get the Anti-CSRF service
let private antiForgery (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IAntiforgery> () let private antiForgery (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IAntiforgery> ()
/// Get the cross-site request forgery token set /// Get the cross-site request forgery token set
@ -115,16 +104,26 @@ module private Helpers =
/// Require a user to be logged on /// Require a user to be logged on
let requireUser = requiresAuthentication Error.notAuthorized let requireUser = requiresAuthentication Error.notAuthorized
/// Pipeline with most extensions enabled
let mdPipeline =
MarkdownPipelineBuilder().UseSmartyPants().UseAdvancedExtensions().Build ()
/// Get the HTML representation of the text of a revision
let revisionToHtml (rev : Revision) =
match rev.sourceType with Html -> rev.text | Markdown -> Markdown.ToHtml (rev.text, mdPipeline)
open System.Collections.Generic
/// Handlers to manipulate admin functions /// Handlers to manipulate admin functions
module Admin = module Admin =
// GET /admin/ // GET /admin
let dashboard : HttpHandler = requireUser >=> fun next ctx -> task { let dashboard : HttpHandler = requireUser >=> 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
let! posts = Data.Post.countByStatus Published |> getCount let! posts = Data.Post.countByStatus Published |> getCount
let! drafts = Data.Post.countByStatus Draft |> getCount let! drafts = Data.Post.countByStatus Draft |> getCount
let! pages = Data.Page.countAll |> getCount let! pages = Data.Page.countAll |> getCount
@ -143,12 +142,12 @@ module Admin =
topLevelCategories = topCats topLevelCategories = topCats
} }
|} |}
|> viewForTheme "admin" "dashboard" None next ctx |> viewForTheme "admin" "dashboard" next ctx
} }
// GET /admin/settings // GET /admin/settings
let settings : HttpHandler = requireUser >=> fun next ctx -> task { let settings : HttpHandler = requireUser >=> fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx let webLog = WebLogCache.get ctx
let! allPages = Data.Page.findAll webLog.id (conn ctx) let! allPages = Data.Page.findAll webLog.id (conn ctx)
return! return!
Hash.FromAnonymousObject Hash.FromAnonymousObject
@ -170,14 +169,14 @@ module Admin =
web_log = webLog web_log = webLog
page_title = "Web Log Settings" page_title = "Web Log Settings"
|} |}
|> viewForTheme "admin" "settings" None next ctx |> viewForTheme "admin" "settings" next ctx
} }
// POST /admin/settings // POST /admin/settings
let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task { let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
let conn' = conn ctx let conn = conn ctx
let! model = ctx.BindFormAsync<SettingsModel> () let! model = ctx.BindFormAsync<SettingsModel> ()
match! Data.WebLog.findByHost (WebLogCache.getByCtx ctx).urlBase conn' with match! Data.WebLog.findById (WebLogCache.get ctx).id conn with
| Some webLog -> | Some webLog ->
let updated = let updated =
{ webLog with { webLog with
@ -187,14 +186,102 @@ module Admin =
postsPerPage = model.postsPerPage postsPerPage = model.postsPerPage
timeZone = model.timeZone timeZone = model.timeZone
} }
do! Data.WebLog.updateSettings updated conn' do! Data.WebLog.updateSettings updated conn
// Update cache // Update cache
WebLogCache.set updated.urlBase updated WebLogCache.set ctx updated
// TODO: confirmation message // TODO: confirmation message
return! redirectTo false "/admin/" next ctx return! redirectTo false "/admin" next ctx
| None -> return! Error.notFound next ctx
}
/// Handlers to manipulate pages
module Page =
// GET /pages
// GET /pages/page/{pageNbr}
let all pageNbr : HttpHandler = requireUser >=> fun next ctx -> task {
let webLog = WebLogCache.get ctx
let! pages = Data.Page.findPageOfPages webLog.id pageNbr (conn ctx)
return!
Hash.FromAnonymousObject
{| pages = pages |> List.map (DisplayPage.fromPage webLog)
page_title = "Pages"
|}
|> viewForTheme "admin" "page-list" next ctx
}
// GET /page/{id}/edit
let edit pgId : HttpHandler = requireUser >=> fun next ctx -> task {
let! hash = task {
match pgId with
| "new" ->
return
Hash.FromAnonymousObject {|
csrf = csrfToken ctx
model = EditPageModel.fromPage { Page.empty with id = PageId "new" }
page_title = "Add a New Page"
|} |> Some
| _ ->
match! Data.Page.findByFullId (PageId pgId) (webLogId ctx) (conn ctx) with
| Some page ->
return
Hash.FromAnonymousObject {|
csrf = csrfToken ctx
model = EditPageModel.fromPage page
page_title = "Edit Page"
|} |> Some
| None -> return None
}
match hash with
| Some h -> return! viewForTheme "admin" "page-edit" next ctx h
| None -> return! Error.notFound next ctx
}
// POST /page/{id}/edit
let save : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
let! model = ctx.BindFormAsync<EditPageModel> ()
let webLogId = webLogId ctx
let conn = conn ctx
let now = DateTime.UtcNow
let! pg = task {
match model.pageId with
| "new" ->
return Some
{ Page.empty with
id = PageId.create ()
webLogId = webLogId
authorId = userId ctx
publishedOn = now
}
| pgId -> return! Data.Page.findByFullId (PageId pgId) webLogId conn
}
match pg with
| Some page ->
let updateList = page.showInPageList <> model.isShownInPageList
let revision = { asOf = now; sourceType = RevisionSource.ofString model.source; text = model.text }
// Detect a permalink change, and add the prior one to the prior list
let page =
match Permalink.toString page.permalink with
| "" -> page
| link when link = model.permalink -> page
| _ -> { page with priorPermalinks = page.permalink :: page.priorPermalinks }
let page =
{ page with
title = model.title
permalink = Permalink model.permalink
updatedOn = now
showInPageList = model.isShownInPageList
text = revisionToHtml revision
revisions = revision :: page.revisions
}
do! (match model.pageId with "new" -> Data.Page.add | _ -> Data.Page.update) page conn
if updateList then do! PageListCache.update ctx
// TODO: confirmation
return! redirectTo false $"/page/{PageId.toString page.id}/edit" next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
@ -204,21 +291,21 @@ module Post =
// GET /page/{pageNbr} // GET /page/{pageNbr}
let pageOfPosts (pageNbr : int) : HttpHandler = fun next ctx -> task { let pageOfPosts (pageNbr : int) : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx let webLog = WebLogCache.get ctx
let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage (conn ctx) let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage (conn ctx)
let hash = Hash.FromAnonymousObject {| posts = posts |} let hash = Hash.FromAnonymousObject {| posts = posts |}
let title = let title =
match pageNbr, webLog.defaultPage with match pageNbr, webLog.defaultPage with
| 1, "posts" -> None | 1, "posts" -> None
| _, "posts" -> Some $"Page {pageNbr}" | _, "posts" -> Some $"Page {pageNbr}"
| _, _ -> Some $"Page {pageNbr} &#xab; Posts" | _, _ -> Some $"Page {pageNbr} &#xab; Posts"
match title with Some ttl -> hash.Add ("page_title", ttl) | None -> () match title with Some ttl -> hash.Add ("page_title", ttl) | None -> ()
return! themedView "index" None next ctx hash return! themedView "index" next ctx hash
} }
// GET / // GET /
let home : HttpHandler = fun next ctx -> task { let home : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx let webLog = WebLogCache.get ctx
match webLog.defaultPage with match webLog.defaultPage with
| "posts" -> return! pageOfPosts 1 next ctx | "posts" -> return! pageOfPosts 1 next ctx
| pageId -> | pageId ->
@ -226,31 +313,38 @@ module Post =
| Some page -> | Some page ->
return! return!
Hash.FromAnonymousObject {| page = page; page_title = page.title |} Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|> themedView "single-page" page.template next ctx |> themedView (defaultArg page.template "single-page") next ctx
| None -> return! Error.notFound next ctx | None -> return! Error.notFound next ctx
} }
// GET * // GET {**link}
let catchAll (link : string) : HttpHandler = fun next ctx -> task { let catchAll : HttpHandler = fun next ctx -> task {
let webLog = WebLogCache.getByCtx ctx let webLog = WebLogCache.get ctx
let conn' = conn ctx let conn = conn ctx
let permalink = Permalink link let permalink = (string >> Permalink) ctx.Request.RouteValues["link"]
match! Data.Post.findByPermalink permalink webLog.id conn' with // Current post
| Some post -> return! Error.notFound next ctx match! Data.Post.findByPermalink permalink webLog.id conn with
| Some _ -> return! Error.notFound next ctx
// TODO: return via single-post action // TODO: return via single-post action
| None -> | None ->
match! Data.Page.findByPermalink permalink webLog.id conn' with // Current page
match! Data.Page.findByPermalink permalink webLog.id conn with
| Some page -> | Some page ->
return! return!
Hash.FromAnonymousObject {| page = page; page_title = page.title |} Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|> themedView "single-page" page.template next ctx |> themedView (defaultArg page.template "single-page") next ctx
| None -> | None ->
// Prior post
// TOOD: search prior permalinks for posts and pages match! Data.Post.findCurrentPermalink permalink webLog.id conn with
| Some link -> return! redirectTo true $"/{Permalink.toString link}" next ctx
// We tried, we really tried... | None ->
Console.Write($"Returning 404 for permalink |{permalink}|"); // Prior page
return! Error.notFound next ctx match! Data.Page.findCurrentPermalink permalink webLog.id conn with
| Some link -> return! redirectTo true $"/{Permalink.toString link}" next ctx
| None ->
// We tried, we really did...
Console.Write($"Returning 404 for permalink |{permalink}|");
return! Error.notFound next ctx
} }
@ -265,15 +359,15 @@ module User =
/// Hash a password for a given user /// Hash a password for a given user
let hashedPassword (plainText : string) (email : string) (salt : Guid) = let hashedPassword (plainText : string) (email : string) (salt : Guid) =
let allSalt = Array.concat [ salt.ToByteArray(); (Encoding.UTF8.GetBytes email) ] let allSalt = Array.concat [ salt.ToByteArray (); Encoding.UTF8.GetBytes email ]
use alg = new Rfc2898DeriveBytes (plainText, allSalt, 2_048) use alg = new Rfc2898DeriveBytes (plainText, allSalt, 2_048)
Convert.ToBase64String(alg.GetBytes(64)) Convert.ToBase64String (alg.GetBytes 64)
// 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"; csrf = (csrfToken ctx) |} Hash.FromAnonymousObject {| page_title = "Log On"; csrf = (csrfToken ctx) |}
|> viewForTheme "admin" "log-on" None next ctx |> viewForTheme "admin" "log-on" next ctx
} }
// POST /user/log-on // POST /user/log-on
@ -283,9 +377,9 @@ module User =
| Some user when user.passwordHash = hashedPassword model.password user.userName user.salt -> | Some user when user.passwordHash = hashedPassword model.password user.userName user.salt ->
let claims = seq { let claims = seq {
Claim (ClaimTypes.NameIdentifier, WebLogUserId.toString user.id) Claim (ClaimTypes.NameIdentifier, WebLogUserId.toString user.id)
Claim (ClaimTypes.Name, $"{user.firstName} {user.lastName}") Claim (ClaimTypes.Name, $"{user.firstName} {user.lastName}")
Claim (ClaimTypes.GivenName, user.preferredName) Claim (ClaimTypes.GivenName, user.preferredName)
Claim (ClaimTypes.Role, user.authorizationLevel.ToString ()) Claim (ClaimTypes.Role, user.authorizationLevel.ToString ())
} }
let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme) let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme)
@ -294,7 +388,7 @@ module User =
// TODO: confirmation message // TODO: confirmation message
return! redirectTo false "/admin/" next ctx return! redirectTo false "/admin" next ctx
| _ -> | _ ->
// TODO: make error, not 404 // TODO: make error, not 404
return! Error.notFound next ctx return! Error.notFound next ctx
@ -318,7 +412,7 @@ let endpoints = [
] ]
subRoute "/admin" [ subRoute "/admin" [
GET [ GET [
route "/" Admin.dashboard route "" Admin.dashboard
route "/settings" Admin.settings route "/settings" Admin.settings
] ]
POST [ POST [
@ -327,7 +421,13 @@ let endpoints = [
] ]
subRoute "/page" [ subRoute "/page" [
GET [ GET [
routef "/%d" Post.pageOfPosts routef "/%d" Post.pageOfPosts
routef "/%s/edit" Page.edit
route "s" (Page.all 1)
routef "s/page/%d" Page.all
]
POST [
route "/save" Page.save
] ]
] ]
subRoute "/user" [ subRoute "/user" [
@ -339,4 +439,5 @@ let endpoints = [
route "/log-on" User.doLogOn route "/log-on" User.doLogOn
] ]
] ]
route "{**link}" Post.catchAll
] ]

View File

@ -3,11 +3,12 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
<NoWarn>3391</NoWarn>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<Content Include="appsettings.json" CopyToOutputDirectory="Always" /> <Content Include="appsettings.json" CopyToOutputDirectory="Always" />
<Compile Include="WebLogCache.fs" /> <Compile Include="Caches.fs" />
<Compile Include="Handlers.fs" /> <Compile Include="Handlers.fs" />
<Compile Include="Program.fs" /> <Compile Include="Program.fs" />
</ItemGroup> </ItemGroup>
@ -15,6 +16,7 @@
<ItemGroup> <ItemGroup>
<PackageReference Include="DotLiquid" Version="2.2.610" /> <PackageReference Include="DotLiquid" Version="2.2.610" />
<PackageReference Include="Giraffe" Version="6.0.0" /> <PackageReference Include="Giraffe" Version="6.0.0" />
<PackageReference Include="Markdig" Version="0.28.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>

View File

@ -9,19 +9,57 @@ open System
type WebLogMiddleware (next : RequestDelegate) = type WebLogMiddleware (next : RequestDelegate) =
member this.InvokeAsync (ctx : HttpContext) = task { member this.InvokeAsync (ctx : HttpContext) = task {
let host = ctx.Request.Host.ToUriComponent () match WebLogCache.exists ctx with
match WebLogCache.exists host with
| true -> return! next.Invoke ctx | true -> return! next.Invoke ctx
| false -> | false ->
let conn = ctx.RequestServices.GetRequiredService<IConnection> () let conn = ctx.RequestServices.GetRequiredService<IConnection> ()
match! Data.WebLog.findByHost host conn with match! Data.WebLog.findByHost (Cache.makeKey ctx) conn with
| Some webLog -> | Some webLog ->
WebLogCache.set host webLog WebLogCache.set ctx webLog
do! PageListCache.update ctx
return! next.Invoke ctx return! next.Invoke ctx
| None -> ctx.Response.StatusCode <- 404 | None -> ctx.Response.StatusCode <- 404
} }
/// DotLiquid filters
module DotLiquidBespoke =
open DotLiquid
open System.IO
/// A filter to generate nav links, highlighting the active link (exact match)
type NavLinkFilter () =
static member NavLink (ctx : Context, url : string, text : string) =
seq {
"<li class=\"nav-item\"><a class=\"nav-link"
if url = string ctx.Environments[0].["current_page"] then " active"
"\" href=\"/"
url
"\">"
text
"</a></li>"
}
|> Seq.fold (+) ""
/// Create links for a user to log on or off, and a dashboard link if they are logged off
type UserLinksTag () =
inherit Tag ()
override this.Render (context : Context, result : TextWriter) =
seq {
"""<ul class="navbar-nav flex-grow-1 justify-content-end">"""
match Convert.ToBoolean context.Environments[0].["logged_on"] with
| true ->
"""<li class="nav-item"><a class="nav-link" href="/admin">Dashboard</a></li>"""
"""<li class="nav-item"><a class="nav-link" href="/user/log-off">Log Off</a></li>"""
| false ->
"""<li class="nav-item"><a class="nav-link" href="/user/log-on">Log On</a></li>"""
"</ul>"
}
|> Seq.iter result.WriteLine
/// Initialize a new database /// Initialize a new database
let initDbValidated (args : string[]) (sp : IServiceProvider) = task { let initDbValidated (args : string[]) (sp : IServiceProvider) = task {
@ -140,14 +178,20 @@ let main args =
let _ = builder.Services.AddSingleton<IConnection> conn let _ = builder.Services.AddSingleton<IConnection> conn
// Set up DotLiquid // Set up DotLiquid
Template.RegisterFilter typeof<DotLiquidBespoke.NavLinkFilter>
Template.RegisterTag<DotLiquidBespoke.UserLinksTag> "user_links"
let all = [| "*" |] let all = [| "*" |]
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<DisplayPage>, all)
Template.RegisterSafeType (typeof<SettingsModel>, all) Template.RegisterSafeType (typeof<SettingsModel>, all)
Template.RegisterSafeType (typeof<EditPageModel>, all)
Template.RegisterSafeType (typeof<AntiforgeryTokenSet>, all) Template.RegisterSafeType (typeof<AntiforgeryTokenSet>, all)
Template.RegisterSafeType (typeof<Option<_>>, all) // doesn't quite get the job done.... Template.RegisterSafeType (typeof<string option>, all)
Template.RegisterSafeType (typeof<KeyValuePair>, all) Template.RegisterSafeType (typeof<KeyValuePair>, all)
let app = builder.Build () let app = builder.Build ()
@ -160,7 +204,7 @@ let main args =
let _ = app.UseAuthentication () let _ = app.UseAuthentication ()
let _ = app.UseStaticFiles () let _ = app.UseStaticFiles ()
let _ = app.UseRouting () let _ = app.UseRouting ()
let _ = app.UseEndpoints (fun endpoints -> endpoints.MapGiraffeEndpoints Handlers.endpoints) let _ = app.UseGiraffe Handlers.endpoints
app.Run() app.Run()

View File

@ -1,24 +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> ()
/// 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,4 +1,5 @@
<article class="container pt-3"> <h2 class="py-3">{{ web_log.name }} &bull; Dashboard</h2>
<article class="container">
<div class="row"> <div class="row">
<section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3"> <section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
<div class="card"> <div class="card">
@ -21,7 +22,7 @@
All <span class="badge rounded-pill bg-secondary">{{ model.pages }}</span> All <span class="badge rounded-pill bg-secondary">{{ model.pages }}</span>
&nbsp; Shown in Page List <span class="badge rounded-pill bg-secondary">{{ model.listed_pages }}</span> &nbsp; Shown in Page List <span class="badge rounded-pill bg-secondary">{{ model.listed_pages }}</span>
</h6> </h6>
<a href="/pages/list" class="btn btn-secondary me-2">View All</a> <a href="/pages" class="btn btn-secondary me-2">View All</a>
<a href="/page/new/edit" class="btn btn-primary">Create a New Page</a> <a href="/page/new/edit" class="btn btn-primary">Create a New Page</a>
</div> </div>
</div> </div>

View File

@ -17,20 +17,26 @@
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<div class="collapse navbar-collapse" id="navbarText"> <div class="collapse navbar-collapse" id="navbarText">
<span class="navbar-text">{{ page_title }}</span> {% if logged_on -%}
<ul class="navbar-nav">
{{ "admin" | nav_link: "Dashboard" }}
{{ "pages" | nav_link: "Pages" }}
{{ "posts" | nav_link: "Posts" }}
{{ "categories" | nav_link: "Categories" }}
</ul>
{%- endif %}
<ul class="navbar-nav flex-grow-1 justify-content-end"> <ul class="navbar-nav flex-grow-1 justify-content-end">
{% if logged_on -%} {% if logged_on -%}
<li class="nav-item"><a class="nav-link" href="/admin/">Dashboard</a></li> {{ "user/log-off" | nav_link: "Log Off" }}
<li class="nav-item"><a class="nav-link" href="/user/log-off">Log Off</a></li>
{%- else -%} {%- else -%}
<li class="nav-item"><a class="nav-link" href="/user/log-on">Log On</a></li> {{ "user/log-on" | nav_link: "Log On" }}
{%- endif %} {%- endif %}
</ul> </ul>
</div> </div>
</div> </div>
</nav> </nav>
</header> </header>
<main> <main class="mx-3">
{{ content }} {{ content }}
</main> </main>
<footer> <footer>

View File

@ -1,4 +1,4 @@
<h2 class="p-3 ">Log On to {{ web_log.name }}</h2> <h2 class="py-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 }}"> <input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">

View File

@ -0,0 +1,55 @@
<h2 class="py-3">{{ page_title }}</h2>
<article>
<form action="/page/save" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<input type="hidden" name="pageId" value="{{ model.page_id }}">
<div class="container">
<div class="row mb-3">
<div class="col">
<div class="form-floating">
<input type="text" name="title" id="title" class="form-control" autofocus required
value="{{ model.title }}">
<label for="title">Title</label>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-9">
<div class="form-floating">
<input type="text" name="permalink" id="permalink" class="form-control" required
value="{{ model.permalink }}">
<label for="permalink">Permalink</label>
</div>
</div>
<div class="col-3 align-self-center">
<div class="form-check form-switch">
<input type="checkbox" name="isShownInPageList" id="showList" class="form-check-input" value="true"
{%- if model.is_shown_in_page_list %} checked="checked"{% endif %}>
<label for="showList" class="form-check-label">Show in Page List</label>
</div>
</div>
</div>
<div class="row mb-2">
<div class="col">
<label for="text">Text</label> &nbsp; &nbsp;
<input type="radio" name="source" id="source_html" class="btn-check" value="HTML"
{%- if model.source == "HTML" %} checked="checked"{% endif %}>
<label class="btn btn-sm btn-outline-secondary" for="source_html">HTML</label>
<input type="radio" name="source" id="source_md" class="btn-check" value="Markdown"
{%- if model.source == "Markdown" %} checked="checked"{% endif %}>
<label class="btn btn-sm btn-outline-secondary" for="source_md">Markdown</label>
</div>
</div>
<div class="row mb-3">
<div class="col">
<textarea name="Text" id="text" class="form-control" rows="10">{{ model.text }}</textarea>
</div>
</div>
<div class="row mb-3">
<div class="col">
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</div>
</div>
</form>
</article>

View File

@ -0,0 +1,27 @@
<h2 class="py-3">{{ page_title }}</h2>
<article class="container">
<a href="/page/new/edit" class="btn btn-primary btn-sm my-3">Create a New Page</a>
<table class="table table-sm table-striped table-hover">
<thead>
<tr>
<th scope="col">Actions</th>
<th scope="col">Title</th>
<th scope="col">In List?</th>
<th scope="col">Last Updated</th>
</tr>
</thead>
<tbody>
{% for pg in pages -%}
<tr>
<td><a href="/page/{{ pg.id }}/edit">Edit</a></td>
<td>
{{ pg.title }}
{%- if pg.is_default %} &nbsp; <span class="badge bg-success">HOME PAGE</span>{% endif -%}
</td>
<td>{% if pg.show_in_page_list %} Yes {% else %} No {% endif %}</td>
<td>{{ pg.updated_on | date: "MMMM d, yyyy" }}</td>
</tr>
{%- endfor %}
</tbody>
</table>
</article>

View File

@ -1,4 +1,5 @@
<article class="pt-3"> <h2 class="py-3">{{ web_log.name }} Settings</h2>
<article>
<form action="/admin/settings" method="post"> <form action="/admin/settings" method="post">
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}"> <input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
<div class="container"> <div class="container">

View File

@ -20,21 +20,21 @@
</button> </button>
<div class="collapse navbar-collapse" id="navbarText"> <div class="collapse navbar-collapse" id="navbarText">
{% if web_log.subtitle -%} {% if web_log.subtitle -%}
<span class="navbar-text">{{ web_log.subtitle | escape }}</span> <span class="navbar-text">{{ web_log.subtitle.value | escape }}</span>
{%- endif %} {%- endif %}
<ul class="navbar-nav flex-grow-1 justify-content-end"> {% if page_list -%}
{% if logged_on %} <ul class="navbar-nav">
<li class="nav-item"><a class="nav-link" href="/admin/">Dashboard</a></li> {% for pg in page_list -%}
<li class="nav-item"><a class="nav-link" href="/user/log-off">Log Off</a></li> {{ pg.permalink | nav_link: pg.title }}
{% else %} {%- endfor %}
<li class="nav-item"><a class="nav-link" href="/user/log-on">Log On</a></li> </ul>
{% endif %} {%- endif %}
</ul> {% user_links %}
</div> </div>
</div> </div>
</nav> </nav>
</header> </header>
<main> <main class="mx-3">
{{ content }} {{ content }}
</main> </main>
<footer> <footer>

View File

@ -1,4 +1,4 @@
<h2>{{ page.title }}</h2> <h2 class="py-3">{{ page.title }}</h2>
<article> <article>
{{ page.text }} {{ page.text }}
</article> </article>