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:
parent
8ce2d5a2ed
commit
48e6d3edfa
1
src/MyWebLog.CS/Db/.gitignore
vendored
1
src/MyWebLog.CS/Db/.gitignore
vendored
|
@ -1 +0,0 @@
|
|||
*.db*
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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) { }
|
||||
}
|
|
@ -1,61 +0,0 @@
|
|||
@model DashboardModel
|
||||
@{
|
||||
Layout = "_AdminLayout";
|
||||
ViewBag.Title = Resources.Dashboard;
|
||||
}
|
||||
<article class="container pt-3">
|
||||
<div class="row">
|
||||
<section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
|
||||
<div class="card">
|
||||
<header class="card-header text-white bg-primary">@Resources.Posts</header>
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle text-muted pb-3">
|
||||
@Resources.Published <span class="badge rounded-pill bg-secondary">@Model.Posts</span>
|
||||
@Resources.Drafts <span class="badge rounded-pill bg-secondary">@Model.Drafts</span>
|
||||
</h6>
|
||||
<a asp-action="All" asp-controller="Post" class="btn btn-secondary me-2">@Resources.ViewAll</a>
|
||||
<a asp-action="Edit" asp-controller="Post" asp-route-id="new" class="btn btn-primary">
|
||||
@Resources.WriteANewPost
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="col-lg-5 col-xl-4 pb-3">
|
||||
<div class="card">
|
||||
<header class="card-header text-white bg-primary">@Resources.Pages</header>
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle text-muted pb-3">
|
||||
@Resources.All <span class="badge rounded-pill bg-secondary">@Model.Pages</span>
|
||||
@Resources.ShownInPageList <span class="badge rounded-pill bg-secondary">@Model.ListedPages</span>
|
||||
</h6>
|
||||
<a asp-action="All" asp-controller="Page" class="btn btn-secondary me-2">@Resources.ViewAll</a>
|
||||
<a asp-action="Edit" asp-controller="Page" asp-route-id="new" class="btn btn-primary">
|
||||
@Resources.CreateANewPage
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="row">
|
||||
<section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
|
||||
<div class="card">
|
||||
<header class="card-header text-white bg-secondary">@Resources.Categories</header>
|
||||
<div class="card-body">
|
||||
<h6 class="card-subtitle text-muted pb-3">
|
||||
@Resources.All <span class="badge rounded-pill bg-secondary">@Model.Categories</span>
|
||||
@Resources.TopLevel <span class="badge rounded-pill bg-secondary">@Model.TopLevelCategories</span>
|
||||
</h6>
|
||||
<a asp-action="All" asp-controller="Category" class="btn btn-secondary me-2">@Resources.ViewAll</a>
|
||||
<a asp-action="Edit" asp-controller="Category" asp-route-id="new" class="btn btn-secondary">
|
||||
@Resources.AddANewCategory
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div class="row pb-3">
|
||||
<div class="col text-end">
|
||||
<a asp-action="Settings" class="btn btn-secondary">@Resources.ModifySettings</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
|
@ -1,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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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 _) { }
|
||||
}
|
|
@ -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> </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>
|
|
@ -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>
|
||||
<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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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));
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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 <strong> instead of <span>
|
||||
/// </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);
|
||||
}
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
@model MyWebLogModel
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width" />
|
||||
<title>@ViewBag.Title « @Resources.Admin « @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>
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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) { }
|
||||
}
|
|
@ -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("~/");
|
||||
}
|
||||
}
|
|
@ -1,7 +0,0 @@
|
|||
@namespace MyWebLog.Features
|
||||
|
||||
@using MyWebLog
|
||||
@using MyWebLog.Properties
|
||||
|
||||
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
|
||||
@addTagHelper *, MyWebLog
|
|
@ -1,3 +0,0 @@
|
|||
global using MyWebLog.Data;
|
||||
global using MyWebLog.Features.Shared;
|
||||
global using MyWebLog.Properties;
|
|
@ -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>
|
|
@ -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]}");
|
||||
}
|
459
src/MyWebLog.CS/Properties/Resources.Designer.cs
generated
459
src/MyWebLog.CS/Properties/Resources.Designer.cs
generated
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,252 +0,0 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
<xsd:complexType>
|
||||
<xsd:choice maxOccurs="unbounded">
|
||||
<xsd:element name="metadata">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" use="required" type="xsd:string" />
|
||||
<xsd:attribute name="type" type="xsd:string" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="assembly">
|
||||
<xsd:complexType>
|
||||
<xsd:attribute name="alias" type="xsd:string" />
|
||||
<xsd:attribute name="name" type="xsd:string" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="data">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
|
||||
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
|
||||
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
|
||||
<xsd:attribute ref="xml:space" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
<xsd:element name="resheader">
|
||||
<xsd:complexType>
|
||||
<xsd:sequence>
|
||||
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
|
||||
</xsd:sequence>
|
||||
<xsd:attribute name="name" type="xsd:string" use="required" />
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:choice>
|
||||
</xsd:complexType>
|
||||
</xsd:element>
|
||||
</xsd:schema>
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<data name="Actions" xml:space="preserve">
|
||||
<value>Actions</value>
|
||||
</data>
|
||||
<data name="AddANewCategory" xml:space="preserve">
|
||||
<value>Add a New Category</value>
|
||||
</data>
|
||||
<data name="AddANewPage" xml:space="preserve">
|
||||
<value>Add a New Page</value>
|
||||
</data>
|
||||
<data name="Admin" xml:space="preserve">
|
||||
<value>Admin</value>
|
||||
</data>
|
||||
<data name="All" xml:space="preserve">
|
||||
<value>All</value>
|
||||
</data>
|
||||
<data name="Categories" xml:space="preserve">
|
||||
<value>Categories</value>
|
||||
</data>
|
||||
<data name="CreateANewPage" xml:space="preserve">
|
||||
<value>Create a New Page</value>
|
||||
</data>
|
||||
<data name="Dashboard" xml:space="preserve">
|
||||
<value>Dashboard</value>
|
||||
</data>
|
||||
<data name="DateFormatString" xml:space="preserve">
|
||||
<value>MMMM d, yyyy</value>
|
||||
</data>
|
||||
<data name="DefaultPage" xml:space="preserve">
|
||||
<value>Default Page</value>
|
||||
</data>
|
||||
<data name="Drafts" xml:space="preserve">
|
||||
<value>Drafts</value>
|
||||
</data>
|
||||
<data name="Edit" xml:space="preserve">
|
||||
<value>Edit</value>
|
||||
</data>
|
||||
<data name="EditPage" xml:space="preserve">
|
||||
<value>Edit Page</value>
|
||||
</data>
|
||||
<data name="EmailAddress" xml:space="preserve">
|
||||
<value>E-mail Address</value>
|
||||
</data>
|
||||
<data name="FirstPageOfPosts" xml:space="preserve">
|
||||
<value>First Page of Posts</value>
|
||||
</data>
|
||||
<data name="InListQuestion" xml:space="preserve">
|
||||
<value>In List?</value>
|
||||
</data>
|
||||
<data name="LastUpdated" xml:space="preserve">
|
||||
<value>Last Updated</value>
|
||||
</data>
|
||||
<data name="LogOff" xml:space="preserve">
|
||||
<value>Log Off</value>
|
||||
</data>
|
||||
<data name="LogOn" xml:space="preserve">
|
||||
<value>Log On</value>
|
||||
</data>
|
||||
<data name="LogOnTo" xml:space="preserve">
|
||||
<value>Log On to</value>
|
||||
</data>
|
||||
<data name="ModifySettings" xml:space="preserve">
|
||||
<value>Modify Settings</value>
|
||||
</data>
|
||||
<data name="Name" xml:space="preserve">
|
||||
<value>Name</value>
|
||||
</data>
|
||||
<data name="No" xml:space="preserve">
|
||||
<value>No</value>
|
||||
</data>
|
||||
<data name="Pages" xml:space="preserve">
|
||||
<value>Pages</value>
|
||||
</data>
|
||||
<data name="PageText" xml:space="preserve">
|
||||
<value>Page Text</value>
|
||||
</data>
|
||||
<data name="Password" xml:space="preserve">
|
||||
<value>Password</value>
|
||||
</data>
|
||||
<data name="Permalink" xml:space="preserve">
|
||||
<value>Permalink</value>
|
||||
</data>
|
||||
<data name="Posts" xml:space="preserve">
|
||||
<value>Posts</value>
|
||||
</data>
|
||||
<data name="PostsPerPage" xml:space="preserve">
|
||||
<value>Posts per Page</value>
|
||||
</data>
|
||||
<data name="Published" xml:space="preserve">
|
||||
<value>Published</value>
|
||||
</data>
|
||||
<data name="SaveChanges" xml:space="preserve">
|
||||
<value>Save Changes</value>
|
||||
</data>
|
||||
<data name="ShowInPageList" xml:space="preserve">
|
||||
<value>Show in Page List</value>
|
||||
</data>
|
||||
<data name="ShownInPageList" xml:space="preserve">
|
||||
<value>Shown in Page List</value>
|
||||
</data>
|
||||
<data name="Subtitle" xml:space="preserve">
|
||||
<value>Subtitle</value>
|
||||
</data>
|
||||
<data name="ThereAreXCategories" xml:space="preserve">
|
||||
<value>There are {0} categories</value>
|
||||
</data>
|
||||
<data name="ThereAreXPages" xml:space="preserve">
|
||||
<value>There are {0} pages</value>
|
||||
</data>
|
||||
<data name="ThereAreXPublishedPostsAndYDrafts" xml:space="preserve">
|
||||
<value>There are {0} published posts and {1} drafts</value>
|
||||
</data>
|
||||
<data name="TimeZone" xml:space="preserve">
|
||||
<value>Time Zone</value>
|
||||
</data>
|
||||
<data name="Title" xml:space="preserve">
|
||||
<value>Title</value>
|
||||
</data>
|
||||
<data name="TopLevel" xml:space="preserve">
|
||||
<value>Top Level</value>
|
||||
</data>
|
||||
<data name="ViewAll" xml:space="preserve">
|
||||
<value>View All</value>
|
||||
</data>
|
||||
<data name="WebLogSettings" xml:space="preserve">
|
||||
<value>Web Log Settings</value>
|
||||
</data>
|
||||
<data name="WriteANewPost" xml:space="preserve">
|
||||
<value>Write a New Post</value>
|
||||
</data>
|
||||
<data name="Yes" xml:space="preserve">
|
||||
<value>Yes</value>
|
||||
</data>
|
||||
</root>
|
|
@ -1,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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,6 +0,0 @@
|
|||
<footer>
|
||||
<hr>
|
||||
<div class="container-fluid text-end">
|
||||
<img src="~/img/logo-dark.png" alt="myWebLog">
|
||||
</div>
|
||||
</footer>
|
|
@ -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>
|
|
@ -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 « @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>
|
|
@ -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>
|
|
@ -1,3 +0,0 @@
|
|||
@namespace MyWebLog.Themes
|
||||
|
||||
@addTagHelper *, MyWebLog
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,9 +0,0 @@
|
|||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
|
@ -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 |
|
@ -83,6 +83,20 @@ module Startup =
|
|||
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 -> ()
|
||||
// Users log on with e-mail
|
||||
match Table.WebLogUser = table with
|
||||
|
@ -190,17 +204,27 @@ module Page =
|
|||
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) =
|
||||
rethink<Page list> {
|
||||
withTable Table.Page
|
||||
getAll [ webLogId ] (nameof webLogId)
|
||||
without [ "priorPermalinks", "revisions" ]
|
||||
without [ "text", "priorPermalinks", "revisions" ]
|
||||
result
|
||||
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 =
|
||||
rethink<Page> {
|
||||
withTable Table.Page
|
||||
|
@ -222,16 +246,31 @@ module Page =
|
|||
withRetryDefault
|
||||
}
|
||||
|> tryFirst
|
||||
|
||||
/// Find a page by its ID (including permalinks and revisions)
|
||||
let findByFullId (pageId : PageId) webLogId =
|
||||
rethink<Page> {
|
||||
|
||||
/// Find the current permalink for a page by a prior permalink
|
||||
let findCurrentPermalink (permalink : Permalink) (webLogId : WebLogId) =
|
||||
rethink<Permalink list> {
|
||||
withTable Table.Page
|
||||
get pageId
|
||||
resultOption
|
||||
getAll [ permalink ] "priorPermalinks"
|
||||
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
|
||||
}
|
||||
|> verifyWebLog webLogId (fun it -> it.webLogId)
|
||||
|
||||
/// Find a list of pages (displayed in admin area)
|
||||
let findPageOfPages (webLogId : WebLogId) pageNbr =
|
||||
|
@ -245,6 +284,25 @@ module Page =
|
|||
result
|
||||
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
|
||||
module Post =
|
||||
|
@ -272,6 +330,19 @@ module Post =
|
|||
}
|
||||
|> 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
|
||||
let findPageOfPublishedPosts (webLogId : WebLogId) pageNbr postsPerPage =
|
||||
rethink<Post list> {
|
||||
|
@ -300,7 +371,7 @@ module WebLog =
|
|||
ignoreResult
|
||||
}
|
||||
|
||||
/// Retrieve web log details by the URL base
|
||||
/// Retrieve a web log by the URL base
|
||||
let findByHost (url : string) =
|
||||
rethink<WebLog list> {
|
||||
withTable Table.WebLog
|
||||
|
@ -311,6 +382,15 @@ module WebLog =
|
|||
}
|
||||
|> 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
|
||||
let updateSettings (webLog : WebLog) =
|
||||
rethink {
|
||||
|
|
|
@ -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!;
|
||||
}
|
|
@ -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; } = "";
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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>
|
|
@ -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!;
|
||||
}
|
|
@ -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!;
|
||||
}
|
|
@ -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!;
|
||||
}
|
|
@ -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!;
|
||||
}
|
|
@ -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!;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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; } = "";
|
||||
}
|
|
@ -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!;
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
open System
|
||||
|
||||
/// A category under which a post may be identfied
|
||||
/// A category under which a post may be identified
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
type Category =
|
||||
{ /// The ID of the category
|
||||
|
@ -120,7 +120,7 @@ type Page =
|
|||
text : string
|
||||
|
||||
/// 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 : Revision list
|
||||
|
|
|
@ -61,6 +61,14 @@ type RevisionSource =
|
|||
/// 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
|
||||
[<CLIMutable; NoComparison; NoEquality>]
|
||||
|
|
|
@ -1,5 +1,43 @@
|
|||
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
|
||||
[<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
|
||||
[<CLIMutable>]
|
||||
type SettingsModel =
|
||||
|
|
|
@ -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
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -7,12 +7,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.Themes.BitBadger",
|
|||
EndProject
|
||||
Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog.Domain", "MyWebLog.Domain\MyWebLog.Domain.fsproj", "{8CA99122-888A-4524-8C1B-685F0A4B7B4B}"
|
||||
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}"
|
||||
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}"
|
||||
EndProject
|
||||
Global
|
||||
|
@ -29,18 +25,10 @@ Global
|
|||
{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.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.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.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.Build.0 = Debug|Any CPU
|
||||
{5655B63D-429F-4CCD-A14C-FBD74D987ECB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
|
|
74
src/MyWebLog/Caches.fs
Normal file
74
src/MyWebLog/Caches.fs
Normal 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]
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
[<RequireQualifiedAccess>]
|
||||
module MyWebLog.Handlers
|
||||
|
||||
open System.Collections.Generic
|
||||
open DotLiquid
|
||||
open Giraffe
|
||||
open Microsoft.AspNetCore.Http
|
||||
|
@ -26,12 +25,11 @@ module Error =
|
|||
>=> text ex.Message *)
|
||||
|
||||
/// Handle unauthorized actions, redirecting to log on for GETs, otherwise returning a 401 Not Authorized response
|
||||
let notAuthorized : HttpHandler =
|
||||
fun next ctx ->
|
||||
(next, ctx)
|
||||
||> match ctx.Request.Method with
|
||||
| "GET" -> redirectTo false $"/user/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}"
|
||||
| _ -> setStatusCode 401 >=> fun _ _ -> Task.FromResult<HttpContext option> None
|
||||
let notAuthorized : HttpHandler = fun next ctx ->
|
||||
(next, ctx)
|
||||
||> match ctx.Request.Method with
|
||||
| "GET" -> redirectTo false $"/user/log-on?returnUrl={WebUtility.UrlEncode ctx.Request.Path}"
|
||||
| _ -> 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
|
||||
let notFound : HttpHandler =
|
||||
|
@ -41,42 +39,27 @@ module Error =
|
|||
[<AutoOpen>]
|
||||
module private Helpers =
|
||||
|
||||
open Markdig
|
||||
open Microsoft.AspNetCore.Antiforgery
|
||||
open Microsoft.Extensions.DependencyInjection
|
||||
open System.Collections.Concurrent
|
||||
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]
|
||||
}
|
||||
|
||||
open System.Security.Claims
|
||||
|
||||
/// 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
|
||||
| true -> hash["web_log"] :?> WebLog
|
||||
| false ->
|
||||
let wl = WebLogCache.getByCtx ctx
|
||||
let wl = WebLogCache.get ctx
|
||||
hash.Add ("web_log", wl)
|
||||
wl
|
||||
|
||||
/// 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
|
||||
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
|
||||
// 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)
|
||||
|
||||
// ...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 a view for the web log's default theme
|
||||
let themedView template layout next ctx = fun (hash : Hash) -> task {
|
||||
return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template layout next ctx hash
|
||||
let themedView template next ctx = fun (hash : Hash) -> task {
|
||||
return! viewForTheme (deriveWebLogFromHash hash ctx).themePath template next ctx hash
|
||||
}
|
||||
|
||||
/// The web log ID for the current request
|
||||
let webLogId ctx = (WebLogCache.getByCtx ctx).id
|
||||
/// Get the web log ID for the current request
|
||||
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> ()
|
||||
|
||||
/// Get the Anti-CSRF service
|
||||
let private antiForgery (ctx : HttpContext) = ctx.RequestServices.GetRequiredService<IAntiforgery> ()
|
||||
|
||||
/// Get the cross-site request forgery token set
|
||||
|
@ -115,16 +104,26 @@ module private Helpers =
|
|||
|
||||
/// Require a user to be logged on
|
||||
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
|
||||
module Admin =
|
||||
|
||||
// GET /admin/
|
||||
// GET /admin
|
||||
let dashboard : HttpHandler = requireUser >=> fun next ctx -> task {
|
||||
let webLogId' = webLogId ctx
|
||||
let conn' = conn ctx
|
||||
let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId' conn'
|
||||
let webLogId = webLogId ctx
|
||||
let conn = conn ctx
|
||||
let getCount (f : WebLogId -> IConnection -> Task<int>) = f webLogId conn
|
||||
let! posts = Data.Post.countByStatus Published |> getCount
|
||||
let! drafts = Data.Post.countByStatus Draft |> getCount
|
||||
let! pages = Data.Page.countAll |> getCount
|
||||
|
@ -143,12 +142,12 @@ module Admin =
|
|||
topLevelCategories = topCats
|
||||
}
|
||||
|}
|
||||
|> viewForTheme "admin" "dashboard" None next ctx
|
||||
|> viewForTheme "admin" "dashboard" next ctx
|
||||
}
|
||||
|
||||
// GET /admin/settings
|
||||
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)
|
||||
return!
|
||||
Hash.FromAnonymousObject
|
||||
|
@ -170,14 +169,14 @@ module Admin =
|
|||
web_log = webLog
|
||||
page_title = "Web Log Settings"
|
||||
|}
|
||||
|> viewForTheme "admin" "settings" None next ctx
|
||||
|> viewForTheme "admin" "settings" next ctx
|
||||
}
|
||||
|
||||
// POST /admin/settings
|
||||
let saveSettings : HttpHandler = requireUser >=> validateCsrf >=> fun next ctx -> task {
|
||||
let conn' = conn ctx
|
||||
let conn = conn ctx
|
||||
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 ->
|
||||
let updated =
|
||||
{ webLog with
|
||||
|
@ -187,14 +186,102 @@ module Admin =
|
|||
postsPerPage = model.postsPerPage
|
||||
timeZone = model.timeZone
|
||||
}
|
||||
do! Data.WebLog.updateSettings updated conn'
|
||||
do! Data.WebLog.updateSettings updated conn
|
||||
|
||||
// Update cache
|
||||
WebLogCache.set updated.urlBase updated
|
||||
WebLogCache.set ctx updated
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
|
@ -204,21 +291,21 @@ module Post =
|
|||
|
||||
// GET /page/{pageNbr}
|
||||
let pageOfPosts (pageNbr : int) : HttpHandler = fun next ctx -> task {
|
||||
let webLog = WebLogCache.getByCtx ctx
|
||||
let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage (conn ctx)
|
||||
let hash = Hash.FromAnonymousObject {| posts = posts |}
|
||||
let title =
|
||||
let webLog = WebLogCache.get ctx
|
||||
let! posts = Data.Post.findPageOfPublishedPosts webLog.id pageNbr webLog.postsPerPage (conn ctx)
|
||||
let hash = Hash.FromAnonymousObject {| posts = posts |}
|
||||
let title =
|
||||
match pageNbr, webLog.defaultPage with
|
||||
| 1, "posts" -> None
|
||||
| _, "posts" -> Some $"Page {pageNbr}"
|
||||
| _, _ -> Some $"Page {pageNbr} « Posts"
|
||||
match title with Some ttl -> hash.Add ("page_title", ttl) | None -> ()
|
||||
return! themedView "index" None next ctx hash
|
||||
return! themedView "index" next ctx hash
|
||||
}
|
||||
|
||||
// GET /
|
||||
let home : HttpHandler = fun next ctx -> task {
|
||||
let webLog = WebLogCache.getByCtx ctx
|
||||
let webLog = WebLogCache.get ctx
|
||||
match webLog.defaultPage with
|
||||
| "posts" -> return! pageOfPosts 1 next ctx
|
||||
| pageId ->
|
||||
|
@ -226,31 +313,38 @@ module Post =
|
|||
| Some page ->
|
||||
return!
|
||||
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
|
||||
}
|
||||
|
||||
// GET *
|
||||
let catchAll (link : string) : HttpHandler = fun next ctx -> task {
|
||||
let webLog = WebLogCache.getByCtx ctx
|
||||
let conn' = conn ctx
|
||||
let permalink = Permalink link
|
||||
match! Data.Post.findByPermalink permalink webLog.id conn' with
|
||||
| Some post -> return! Error.notFound next ctx
|
||||
// GET {**link}
|
||||
let catchAll : HttpHandler = fun next ctx -> task {
|
||||
let webLog = WebLogCache.get ctx
|
||||
let conn = conn ctx
|
||||
let permalink = (string >> Permalink) ctx.Request.RouteValues["link"]
|
||||
// Current post
|
||||
match! Data.Post.findByPermalink permalink webLog.id conn with
|
||||
| Some _ -> return! Error.notFound next ctx
|
||||
// TODO: return via single-post action
|
||||
| None ->
|
||||
match! Data.Page.findByPermalink permalink webLog.id conn' with
|
||||
// Current page
|
||||
match! Data.Page.findByPermalink permalink webLog.id conn with
|
||||
| Some page ->
|
||||
return!
|
||||
Hash.FromAnonymousObject {| page = page; page_title = page.title |}
|
||||
|> themedView "single-page" page.template next ctx
|
||||
|> themedView (defaultArg page.template "single-page") next ctx
|
||||
| None ->
|
||||
|
||||
// TOOD: search prior permalinks for posts and pages
|
||||
|
||||
// We tried, we really tried...
|
||||
Console.Write($"Returning 404 for permalink |{permalink}|");
|
||||
return! Error.notFound next ctx
|
||||
// Prior post
|
||||
match! Data.Post.findCurrentPermalink permalink webLog.id conn with
|
||||
| Some link -> return! redirectTo true $"/{Permalink.toString link}" next ctx
|
||||
| None ->
|
||||
// Prior page
|
||||
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
|
||||
let hashedPassword (plainText : string) (email : string) (salt : Guid) =
|
||||
let allSalt = Array.concat [ salt.ToByteArray(); (Encoding.UTF8.GetBytes email) ]
|
||||
use alg = new Rfc2898DeriveBytes (plainText, allSalt, 2_048)
|
||||
Convert.ToBase64String(alg.GetBytes(64))
|
||||
let allSalt = Array.concat [ salt.ToByteArray (); Encoding.UTF8.GetBytes email ]
|
||||
use alg = new Rfc2898DeriveBytes (plainText, allSalt, 2_048)
|
||||
Convert.ToBase64String (alg.GetBytes 64)
|
||||
|
||||
// GET /user/log-on
|
||||
let logOn : HttpHandler = fun next ctx -> task {
|
||||
return!
|
||||
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
|
||||
|
@ -283,9 +377,9 @@ module User =
|
|||
| Some user when user.passwordHash = hashedPassword model.password user.userName user.salt ->
|
||||
let claims = seq {
|
||||
Claim (ClaimTypes.NameIdentifier, WebLogUserId.toString user.id)
|
||||
Claim (ClaimTypes.Name, $"{user.firstName} {user.lastName}")
|
||||
Claim (ClaimTypes.GivenName, user.preferredName)
|
||||
Claim (ClaimTypes.Role, user.authorizationLevel.ToString ())
|
||||
Claim (ClaimTypes.Name, $"{user.firstName} {user.lastName}")
|
||||
Claim (ClaimTypes.GivenName, user.preferredName)
|
||||
Claim (ClaimTypes.Role, user.authorizationLevel.ToString ())
|
||||
}
|
||||
let identity = ClaimsIdentity (claims, CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
|
||||
|
@ -294,7 +388,7 @@ module User =
|
|||
|
||||
// TODO: confirmation message
|
||||
|
||||
return! redirectTo false "/admin/" next ctx
|
||||
return! redirectTo false "/admin" next ctx
|
||||
| _ ->
|
||||
// TODO: make error, not 404
|
||||
return! Error.notFound next ctx
|
||||
|
@ -318,7 +412,7 @@ let endpoints = [
|
|||
]
|
||||
subRoute "/admin" [
|
||||
GET [
|
||||
route "/" Admin.dashboard
|
||||
route "" Admin.dashboard
|
||||
route "/settings" Admin.settings
|
||||
]
|
||||
POST [
|
||||
|
@ -327,7 +421,13 @@ let endpoints = [
|
|||
]
|
||||
subRoute "/page" [
|
||||
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" [
|
||||
|
@ -339,4 +439,5 @@ let endpoints = [
|
|||
route "/log-on" User.doLogOn
|
||||
]
|
||||
]
|
||||
route "{**link}" Post.catchAll
|
||||
]
|
||||
|
|
|
@ -3,11 +3,12 @@
|
|||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<NoWarn>3391</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="appsettings.json" CopyToOutputDirectory="Always" />
|
||||
<Compile Include="WebLogCache.fs" />
|
||||
<Compile Include="Caches.fs" />
|
||||
<Compile Include="Handlers.fs" />
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
|
@ -15,6 +16,7 @@
|
|||
<ItemGroup>
|
||||
<PackageReference Include="DotLiquid" Version="2.2.610" />
|
||||
<PackageReference Include="Giraffe" Version="6.0.0" />
|
||||
<PackageReference Include="Markdig" Version="0.28.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
|
|
@ -9,19 +9,57 @@ open System
|
|||
type WebLogMiddleware (next : RequestDelegate) =
|
||||
|
||||
member this.InvokeAsync (ctx : HttpContext) = task {
|
||||
let host = ctx.Request.Host.ToUriComponent ()
|
||||
match WebLogCache.exists host with
|
||||
match WebLogCache.exists ctx with
|
||||
| true -> return! next.Invoke ctx
|
||||
| false ->
|
||||
let conn = ctx.RequestServices.GetRequiredService<IConnection> ()
|
||||
match! Data.WebLog.findByHost host conn with
|
||||
match! Data.WebLog.findByHost (Cache.makeKey ctx) conn with
|
||||
| Some webLog ->
|
||||
WebLogCache.set host webLog
|
||||
WebLogCache.set ctx webLog
|
||||
do! PageListCache.update ctx
|
||||
return! next.Invoke ctx
|
||||
| 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
|
||||
let initDbValidated (args : string[]) (sp : IServiceProvider) = task {
|
||||
|
||||
|
@ -140,14 +178,20 @@ let main args =
|
|||
let _ = builder.Services.AddSingleton<IConnection> conn
|
||||
|
||||
// Set up DotLiquid
|
||||
Template.RegisterFilter typeof<DotLiquidBespoke.NavLinkFilter>
|
||||
Template.RegisterTag<DotLiquidBespoke.UserLinksTag> "user_links"
|
||||
|
||||
let all = [| "*" |]
|
||||
Template.RegisterSafeType (typeof<Page>, all)
|
||||
Template.RegisterSafeType (typeof<WebLog>, all)
|
||||
|
||||
Template.RegisterSafeType (typeof<DashboardModel>, all)
|
||||
Template.RegisterSafeType (typeof<DisplayPage>, all)
|
||||
Template.RegisterSafeType (typeof<SettingsModel>, all)
|
||||
Template.RegisterSafeType (typeof<EditPageModel>, 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)
|
||||
|
||||
let app = builder.Build ()
|
||||
|
@ -160,7 +204,7 @@ let main args =
|
|||
let _ = app.UseAuthentication ()
|
||||
let _ = app.UseStaticFiles ()
|
||||
let _ = app.UseRouting ()
|
||||
let _ = app.UseEndpoints (fun endpoints -> endpoints.MapGiraffeEndpoints Handlers.endpoints)
|
||||
let _ = app.UseGiraffe Handlers.endpoints
|
||||
|
||||
app.Run()
|
||||
|
||||
|
|
|
@ -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
|
|
@ -1,4 +1,5 @@
|
|||
<article class="container pt-3">
|
||||
<h2 class="py-3">{{ web_log.name }} • Dashboard</h2>
|
||||
<article class="container">
|
||||
<div class="row">
|
||||
<section class="col-lg-5 offset-lg-1 col-xl-4 offset-xl-2 pb-3">
|
||||
<div class="card">
|
||||
|
@ -21,7 +22,7 @@
|
|||
All <span class="badge rounded-pill bg-secondary">{{ model.pages }}</span>
|
||||
Shown in Page List <span class="badge rounded-pill bg-secondary">{{ model.listed_pages }}</span>
|
||||
</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>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -17,20 +17,26 @@
|
|||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<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">
|
||||
{% if logged_on -%}
|
||||
<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>
|
||||
{{ "user/log-off" | nav_link: "Log Off" }}
|
||||
{%- 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 %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<main class="mx-3">
|
||||
{{ content }}
|
||||
</main>
|
||||
<footer>
|
||||
|
|
|
@ -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">
|
||||
<form action="/user/log-on" method="post">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
|
|
55
src/MyWebLog/themes/admin/page-edit.liquid
Normal file
55
src/MyWebLog/themes/admin/page-edit.liquid
Normal 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>
|
||||
<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>
|
27
src/MyWebLog/themes/admin/page-list.liquid
Normal file
27
src/MyWebLog/themes/admin/page-list.liquid
Normal 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 %} <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>
|
|
@ -1,4 +1,5 @@
|
|||
<article class="pt-3">
|
||||
<h2 class="py-3">{{ web_log.name }} Settings</h2>
|
||||
<article>
|
||||
<form action="/admin/settings" method="post">
|
||||
<input type="hidden" name="{{ csrf.form_field_name }}" value="{{ csrf.request_token }}">
|
||||
<div class="container">
|
||||
|
|
|
@ -20,21 +20,21 @@
|
|||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarText">
|
||||
{% if web_log.subtitle -%}
|
||||
<span class="navbar-text">{{ web_log.subtitle | escape }}</span>
|
||||
<span class="navbar-text">{{ web_log.subtitle.value | escape }}</span>
|
||||
{%- endif %}
|
||||
<ul class="navbar-nav flex-grow-1 justify-content-end">
|
||||
{% if logged_on %}
|
||||
<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>
|
||||
{% else %}
|
||||
<li class="nav-item"><a class="nav-link" href="/user/log-on">Log On</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
{% if page_list -%}
|
||||
<ul class="navbar-nav">
|
||||
{% for pg in page_list -%}
|
||||
{{ pg.permalink | nav_link: pg.title }}
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{%- endif %}
|
||||
{% user_links %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<main class="mx-3">
|
||||
{{ content }}
|
||||
</main>
|
||||
<footer>
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
<h2>{{ page.title }}</h2>
|
||||
<h2 class="py-3">{{ page.title }}</h2>
|
||||
<article>
|
||||
{{ page.text }}
|
||||
</article>
|
||||
|
|
Loading…
Reference in New Issue
Block a user