From df3467030ae93602c6fff4cea2fdcf3a730c2709 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Sun, 27 Feb 2022 12:38:08 -0500 Subject: [PATCH] Init data, single home page, multi-tenant --- .../Extensions/PageExtensions.cs | 14 + .../Extensions/PostExtensions.cs | 17 + .../Extensions/WebLogDetailsExtensions.cs | 14 + .../20220227160816_Initial.Designer.cs | 518 ++++++++++++++++++ .../Migrations/20220227160816_Initial.cs | 398 ++++++++++++++ .../WebLogDbContextModelSnapshot.cs | 516 +++++++++++++++++ src/MyWebLog.Data/MyWebLog.Data.csproj | 11 + src/MyWebLog.Data/WebLogDbContext.cs | 12 + src/MyWebLog.Data/WebLogDetails.cs | 7 +- src/MyWebLog.Data/WebLogUser.cs | 5 + src/MyWebLog.sln | 12 +- src/MyWebLog/Db/.gitignore | 1 + src/MyWebLog/Domain.fs | 489 ----------------- src/MyWebLog/Features/FeatureSupport.cs | 67 +++ src/MyWebLog/Features/Posts/PostController.cs | 25 + .../Features/Shared/MyWebLogController.cs | 25 + src/MyWebLog/Features/ThemeSupport.cs | 28 + src/MyWebLog/Features/Users/UserController.cs | 33 ++ src/MyWebLog/GlobalUsings.cs | 2 + src/MyWebLog/MyWebLog.csproj | 24 + src/MyWebLog/MyWebLog.fsproj | 28 - src/MyWebLog/Program.cs | 131 +++++ src/MyWebLog/Program.fs | 4 - src/MyWebLog/Properties/launchSettings.json | 28 + src/MyWebLog/Resources/en-US.json | 83 --- src/MyWebLog/Strings.fs | 40 -- .../Themes/Default/Shared/_Layout.cshtml | 14 + src/MyWebLog/Themes/Default/SinglePage.cshtml | 9 + src/MyWebLog/Themes/_ViewImports.cshtml | 1 + src/MyWebLog/WebLogMiddleware.cs | 90 +++ src/MyWebLog/appsettings.Development.json | 8 + src/MyWebLog/appsettings.json | 9 + 32 files changed, 2012 insertions(+), 651 deletions(-) create mode 100644 src/MyWebLog.Data/Extensions/PageExtensions.cs create mode 100644 src/MyWebLog.Data/Extensions/PostExtensions.cs create mode 100644 src/MyWebLog.Data/Extensions/WebLogDetailsExtensions.cs create mode 100644 src/MyWebLog.Data/Migrations/20220227160816_Initial.Designer.cs create mode 100644 src/MyWebLog.Data/Migrations/20220227160816_Initial.cs create mode 100644 src/MyWebLog.Data/Migrations/WebLogDbContextModelSnapshot.cs create mode 100644 src/MyWebLog/Db/.gitignore delete mode 100644 src/MyWebLog/Domain.fs create mode 100644 src/MyWebLog/Features/FeatureSupport.cs create mode 100644 src/MyWebLog/Features/Posts/PostController.cs create mode 100644 src/MyWebLog/Features/Shared/MyWebLogController.cs create mode 100644 src/MyWebLog/Features/ThemeSupport.cs create mode 100644 src/MyWebLog/Features/Users/UserController.cs create mode 100644 src/MyWebLog/GlobalUsings.cs create mode 100644 src/MyWebLog/MyWebLog.csproj delete mode 100644 src/MyWebLog/MyWebLog.fsproj create mode 100644 src/MyWebLog/Program.cs delete mode 100644 src/MyWebLog/Program.fs create mode 100644 src/MyWebLog/Properties/launchSettings.json delete mode 100644 src/MyWebLog/Resources/en-US.json delete mode 100644 src/MyWebLog/Strings.fs create mode 100644 src/MyWebLog/Themes/Default/Shared/_Layout.cshtml create mode 100644 src/MyWebLog/Themes/Default/SinglePage.cshtml create mode 100644 src/MyWebLog/Themes/_ViewImports.cshtml create mode 100644 src/MyWebLog/WebLogMiddleware.cs create mode 100644 src/MyWebLog/appsettings.Development.json create mode 100644 src/MyWebLog/appsettings.json diff --git a/src/MyWebLog.Data/Extensions/PageExtensions.cs b/src/MyWebLog.Data/Extensions/PageExtensions.cs new file mode 100644 index 0000000..c105815 --- /dev/null +++ b/src/MyWebLog.Data/Extensions/PageExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace MyWebLog.Data; + +public static class PageExtensions +{ + /// + /// Retrieve a page by its ID (non-tracked) + /// + /// The ID of the page to retrieve + /// The requested page (or null if it is not found) + public static async Task FindById(this DbSet db, string id) => + await db.FirstOrDefaultAsync(p => p.Id == id).ConfigureAwait(false); +} diff --git a/src/MyWebLog.Data/Extensions/PostExtensions.cs b/src/MyWebLog.Data/Extensions/PostExtensions.cs new file mode 100644 index 0000000..818d3ca --- /dev/null +++ b/src/MyWebLog.Data/Extensions/PostExtensions.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; + +namespace MyWebLog.Data; + +public static class PostExtensions +{ + /// + /// Retrieve a page of published posts (non-tracked) + /// + /// The page number to retrieve + /// The number of posts per page + /// A list of posts representing the posts for the given page + public static async Task> FindPageOfPublishedPosts(this DbSet db, int pageNbr, int postsPerPage) => + await db.Where(p => p.Status == PostStatus.Published) + .Skip((pageNbr - 1) * postsPerPage).Take(postsPerPage) + .ToListAsync(); +} diff --git a/src/MyWebLog.Data/Extensions/WebLogDetailsExtensions.cs b/src/MyWebLog.Data/Extensions/WebLogDetailsExtensions.cs new file mode 100644 index 0000000..9d3f8e1 --- /dev/null +++ b/src/MyWebLog.Data/Extensions/WebLogDetailsExtensions.cs @@ -0,0 +1,14 @@ +using Microsoft.EntityFrameworkCore; + +namespace MyWebLog.Data; + +public static class WebLogDetailsExtensions +{ + /// + /// Find the details of a web log by its host + /// + /// The host + /// The web log (or null if not found) + public static async Task FindByHost(this DbSet db, string host) => + await db.FirstOrDefaultAsync(wld => wld.UrlBase == host).ConfigureAwait(false); +} diff --git a/src/MyWebLog.Data/Migrations/20220227160816_Initial.Designer.cs b/src/MyWebLog.Data/Migrations/20220227160816_Initial.Designer.cs new file mode 100644 index 0000000..f9df601 --- /dev/null +++ b/src/MyWebLog.Data/Migrations/20220227160816_Initial.Designer.cs @@ -0,0 +1,518 @@ +// +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("20220227160816_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("CategoriesId") + .HasColumnType("TEXT"); + + b.Property("PostsId") + .HasColumnType("TEXT"); + + b.HasKey("CategoriesId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("CategoryPost"); + }); + + modelBuilder.Entity("MyWebLog.Data.Category", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Slug"); + + b.ToTable("Category"); + }); + + modelBuilder.Entity("MyWebLog.Data.Comment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("InReplyToId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PostId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PostedOn") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InReplyToId"); + + b.HasIndex("PostId"); + + b.ToTable("Comment"); + }); + + modelBuilder.Entity("MyWebLog.Data.Page", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permalink") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublishedOn") + .HasColumnType("TEXT"); + + b.Property("ShowInPageList") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("Permalink"); + + b.ToTable("Page"); + }); + + modelBuilder.Entity("MyWebLog.Data.PagePermalink", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("PageId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("PagePermalink"); + }); + + modelBuilder.Entity("MyWebLog.Data.PageRevision", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AsOf") + .HasColumnType("TEXT"); + + b.Property("PageId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("PageRevision"); + }); + + modelBuilder.Entity("MyWebLog.Data.Post", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permalink") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublishedOn") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("Permalink"); + + b.ToTable("Post"); + }); + + modelBuilder.Entity("MyWebLog.Data.PostPermalink", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("PostId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.ToTable("PostPermalink"); + }); + + modelBuilder.Entity("MyWebLog.Data.PostRevision", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AsOf") + .HasColumnType("TEXT"); + + b.Property("PostId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.ToTable("PostRevision"); + }); + + modelBuilder.Entity("MyWebLog.Data.Tag", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("MyWebLog.Data.WebLogDetails", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("DefaultPage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PostsPerPage") + .HasColumnType("INTEGER"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("ThemePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UrlBase") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("WebLogDetails"); + }); + + modelBuilder.Entity("MyWebLog.Data.WebLogUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorizationLevel") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreferredName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Salt") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WebLogUser"); + }); + + modelBuilder.Entity("PostTag", b => + { + b.Property("PostsId") + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/MyWebLog.Data/Migrations/20220227160816_Initial.cs b/src/MyWebLog.Data/Migrations/20220227160816_Initial.cs new file mode 100644 index 0000000..cf155f5 --- /dev/null +++ b/src/MyWebLog.Data/Migrations/20220227160816_Initial.cs @@ -0,0 +1,398 @@ +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(type: "TEXT", nullable: false), + Name = table.Column(type: "TEXT", nullable: false), + Slug = table.Column(type: "TEXT", nullable: false), + Description = table.Column(type: "TEXT", nullable: true), + ParentId = table.Column(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(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Tag", x => x.Name); + }); + + migrationBuilder.CreateTable( + name: "WebLogDetails", + columns: table => new + { + Name = table.Column(type: "TEXT", nullable: false), + Subtitle = table.Column(type: "TEXT", nullable: true), + DefaultPage = table.Column(type: "TEXT", nullable: false), + PostsPerPage = table.Column(type: "INTEGER", nullable: false), + ThemePath = table.Column(type: "TEXT", nullable: false), + UrlBase = table.Column(type: "TEXT", nullable: false), + TimeZone = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WebLogDetails", x => x.Name); + }); + + migrationBuilder.CreateTable( + name: "WebLogUser", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + UserName = table.Column(type: "TEXT", nullable: false), + FirstName = table.Column(type: "TEXT", nullable: false), + LastName = table.Column(type: "TEXT", nullable: false), + PreferredName = table.Column(type: "TEXT", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false), + Salt = table.Column(type: "TEXT", nullable: false), + Url = table.Column(type: "TEXT", nullable: true), + AuthorizationLevel = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WebLogUser", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Page", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + AuthorId = table.Column(type: "TEXT", nullable: false), + Title = table.Column(type: "TEXT", nullable: false), + Permalink = table.Column(type: "TEXT", nullable: false), + PublishedOn = table.Column(type: "TEXT", nullable: false), + UpdatedOn = table.Column(type: "TEXT", nullable: false), + ShowInPageList = table.Column(type: "INTEGER", nullable: false), + Text = table.Column(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(type: "TEXT", nullable: false), + AuthorId = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "INTEGER", nullable: false), + Title = table.Column(type: "TEXT", nullable: false), + Permalink = table.Column(type: "TEXT", nullable: false), + PublishedOn = table.Column(type: "TEXT", nullable: true), + UpdatedOn = table.Column(type: "TEXT", nullable: false), + Text = table.Column(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(type: "TEXT", nullable: false), + PageId = table.Column(type: "TEXT", nullable: false), + Url = table.Column(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(type: "TEXT", nullable: false), + PageId = table.Column(type: "TEXT", nullable: false), + AsOf = table.Column(type: "TEXT", nullable: false), + SourceType = table.Column(type: "INTEGER", nullable: false), + Text = table.Column(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(type: "TEXT", nullable: false), + PostsId = table.Column(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(type: "TEXT", nullable: false), + PostId = table.Column(type: "TEXT", nullable: false), + InReplyToId = table.Column(type: "TEXT", nullable: true), + Name = table.Column(type: "TEXT", nullable: false), + Email = table.Column(type: "TEXT", nullable: false), + Url = table.Column(type: "TEXT", nullable: true), + Status = table.Column(type: "INTEGER", nullable: false), + PostedOn = table.Column(type: "TEXT", nullable: false), + Text = table.Column(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(type: "TEXT", nullable: false), + PostId = table.Column(type: "TEXT", nullable: false), + Url = table.Column(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(type: "TEXT", nullable: false), + PostId = table.Column(type: "TEXT", nullable: false), + AsOf = table.Column(type: "TEXT", nullable: false), + SourceType = table.Column(type: "INTEGER", nullable: false), + Text = table.Column(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(type: "TEXT", nullable: false), + TagsName = table.Column(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"); + } + } +} diff --git a/src/MyWebLog.Data/Migrations/WebLogDbContextModelSnapshot.cs b/src/MyWebLog.Data/Migrations/WebLogDbContextModelSnapshot.cs new file mode 100644 index 0000000..8b1df7b --- /dev/null +++ b/src/MyWebLog.Data/Migrations/WebLogDbContextModelSnapshot.cs @@ -0,0 +1,516 @@ +// +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("CategoriesId") + .HasColumnType("TEXT"); + + b.Property("PostsId") + .HasColumnType("TEXT"); + + b.HasKey("CategoriesId", "PostsId"); + + b.HasIndex("PostsId"); + + b.ToTable("CategoryPost"); + }); + + modelBuilder.Entity("MyWebLog.Data.Category", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParentId") + .HasColumnType("TEXT"); + + b.Property("Slug") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ParentId"); + + b.HasIndex("Slug"); + + b.ToTable("Category"); + }); + + modelBuilder.Entity("MyWebLog.Data.Comment", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("InReplyToId") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PostId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PostedOn") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("InReplyToId"); + + b.HasIndex("PostId"); + + b.ToTable("Comment"); + }); + + modelBuilder.Entity("MyWebLog.Data.Page", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permalink") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublishedOn") + .HasColumnType("TEXT"); + + b.Property("ShowInPageList") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("Permalink"); + + b.ToTable("Page"); + }); + + modelBuilder.Entity("MyWebLog.Data.PagePermalink", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("PageId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("PagePermalink"); + }); + + modelBuilder.Entity("MyWebLog.Data.PageRevision", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AsOf") + .HasColumnType("TEXT"); + + b.Property("PageId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PageId"); + + b.ToTable("PageRevision"); + }); + + modelBuilder.Entity("MyWebLog.Data.Post", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Permalink") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PublishedOn") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedOn") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.HasIndex("Permalink"); + + b.ToTable("Post"); + }); + + modelBuilder.Entity("MyWebLog.Data.PostPermalink", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("PostId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Url") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.ToTable("PostPermalink"); + }); + + modelBuilder.Entity("MyWebLog.Data.PostRevision", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AsOf") + .HasColumnType("TEXT"); + + b.Property("PostId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SourceType") + .HasColumnType("INTEGER"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PostId"); + + b.ToTable("PostRevision"); + }); + + modelBuilder.Entity("MyWebLog.Data.Tag", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("Tag"); + }); + + modelBuilder.Entity("MyWebLog.Data.WebLogDetails", b => + { + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("DefaultPage") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PostsPerPage") + .HasColumnType("INTEGER"); + + b.Property("Subtitle") + .HasColumnType("TEXT"); + + b.Property("ThemePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TimeZone") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UrlBase") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Name"); + + b.ToTable("WebLogDetails"); + }); + + modelBuilder.Entity("MyWebLog.Data.WebLogUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AuthorizationLevel") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PreferredName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Salt") + .HasColumnType("TEXT"); + + b.Property("Url") + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("WebLogUser"); + }); + + modelBuilder.Entity("PostTag", b => + { + b.Property("PostsId") + .HasColumnType("TEXT"); + + b.Property("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 + } + } +} diff --git a/src/MyWebLog.Data/MyWebLog.Data.csproj b/src/MyWebLog.Data/MyWebLog.Data.csproj index 132c02c..4231758 100644 --- a/src/MyWebLog.Data/MyWebLog.Data.csproj +++ b/src/MyWebLog.Data/MyWebLog.Data.csproj @@ -6,4 +6,15 @@ enable + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + True + diff --git a/src/MyWebLog.Data/WebLogDbContext.cs b/src/MyWebLog.Data/WebLogDbContext.cs index afbcd8f..e4fcb92 100644 --- a/src/MyWebLog.Data/WebLogDbContext.cs +++ b/src/MyWebLog.Data/WebLogDbContext.cs @@ -7,6 +7,14 @@ namespace MyWebLog.Data; /// public sealed class WebLogDbContext : DbContext { + /// + /// Create a new ID (short GUID) + /// + /// A new short GUID + /// https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID + public static string NewId() => + Convert.ToBase64String(Guid.NewGuid().ToByteArray()).Replace('/', '_').Replace('+', '-')[..22]; + /// /// The categories for the web log /// @@ -53,6 +61,10 @@ public sealed class WebLogDbContext : DbContext { 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().HasKey(t => t.Name); modelBuilder.Entity().HasKey(wld => wld.Name); diff --git a/src/MyWebLog.Data/WebLogDetails.cs b/src/MyWebLog.Data/WebLogDetails.cs index b891669..e6e1d9a 100644 --- a/src/MyWebLog.Data/WebLogDetails.cs +++ b/src/MyWebLog.Data/WebLogDetails.cs @@ -20,10 +20,15 @@ public class WebLogDetails /// public string DefaultPage { get; set; } = ""; + /// + /// The number of posts to display on pages of posts + /// + public byte PostsPerPage { get; set; } = 10; + /// /// The path of the theme (within /views/themes) /// - public string ThemePath { get; set; } = ""; + public string ThemePath { get; set; } = "Default"; /// /// The URL base diff --git a/src/MyWebLog.Data/WebLogUser.cs b/src/MyWebLog.Data/WebLogUser.cs index 075e83f..3cec46c 100644 --- a/src/MyWebLog.Data/WebLogUser.cs +++ b/src/MyWebLog.Data/WebLogUser.cs @@ -45,6 +45,11 @@ public class WebLogUser /// public string? Url { get; set; } = null; + /// + /// The user's authorization level + /// + public AuthorizationLevel AuthorizationLevel { get; set; } = AuthorizationLevel.User; + /// /// Pages written by this author /// diff --git a/src/MyWebLog.sln b/src/MyWebLog.sln index 5251070..be8542f 100644 --- a/src/MyWebLog.sln +++ b/src/MyWebLog.sln @@ -3,9 +3,9 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.1.32210.238 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{6EC3EE1D-3C4E-46DD-8F32-0CC8E7565705}") = "MyWebLog", "MyWebLog\MyWebLog.fsproj", "{2E5E2346-25FE-4CBD-89AA-6148A33DE09C}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.csproj", "{0177C744-F913-4352-A0EC-478B4B0388C3}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyWebLog.Data", "MyWebLog.Data\MyWebLog.Data.csproj", "{0177C744-F913-4352-A0EC-478B4B0388C3}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "MyWebLog", "MyWebLog\MyWebLog.csproj", "{3139DA09-C999-465A-BC98-02FEC3BD7E88}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -13,14 +13,14 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {2E5E2346-25FE-4CBD-89AA-6148A33DE09C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2E5E2346-25FE-4CBD-89AA-6148A33DE09C}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2E5E2346-25FE-4CBD-89AA-6148A33DE09C}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2E5E2346-25FE-4CBD-89AA-6148A33DE09C}.Release|Any CPU.Build.0 = Release|Any CPU {0177C744-F913-4352-A0EC-478B4B0388C3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {0177C744-F913-4352-A0EC-478B4B0388C3}.Debug|Any CPU.Build.0 = Debug|Any CPU {0177C744-F913-4352-A0EC-478B4B0388C3}.Release|Any CPU.ActiveCfg = Release|Any CPU {0177C744-F913-4352-A0EC-478B4B0388C3}.Release|Any CPU.Build.0 = Release|Any CPU + {3139DA09-C999-465A-BC98-02FEC3BD7E88}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3139DA09-C999-465A-BC98-02FEC3BD7E88}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3139DA09-C999-465A-BC98-02FEC3BD7E88}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3139DA09-C999-465A-BC98-02FEC3BD7E88}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/MyWebLog/Db/.gitignore b/src/MyWebLog/Db/.gitignore new file mode 100644 index 0000000..778e729 --- /dev/null +++ b/src/MyWebLog/Db/.gitignore @@ -0,0 +1 @@ +*.db* diff --git a/src/MyWebLog/Domain.fs b/src/MyWebLog/Domain.fs deleted file mode 100644 index b1cf41d..0000000 --- a/src/MyWebLog/Domain.fs +++ /dev/null @@ -1,489 +0,0 @@ -namespace MyWebLog.Domain - -// -- Supporting Types -- - -/// Types of markup text supported -type MarkupText = - /// Text in Markdown format - | Markdown of string - /// Text in HTML format - | Html of string - -/// Functions to support maniuplating markup text -module MarkupText = - /// Get the string representation of this markup text - let toString it = - match it with - | Markdown x -> "Markdown", x - | Html x -> "HTML", x - ||> sprintf "%s: %s" - /// Get the HTML value of the text - let toHtml = function - | Markdown it -> sprintf "TODO: convert to HTML - %s" it - | Html it -> it - /// Parse a string representation to markup text - let ofString (it : string) = - match true with - | _ when it.StartsWith "Markdown: " -> it.Substring 10 |> Markdown - | _ when it.StartsWith "HTML: " -> it.Substring 6 |> Html - | _ -> sprintf "Cannot determine text type - %s" it |> invalidOp - - -/// Authorization levels -type AuthorizationLevel = - /// Authorization to administer a weblog - | Administrator - /// Authorization to comment on a weblog - | User - -/// Functions to support authorization levels -module AuthorizationLevel = - /// Get the string reprsentation of an authorization level - let toString = function Administrator -> "Administrator" | User -> "User" - /// Create an authorization level from a string - let ofString it = - match it with - | "Administrator" -> Administrator - | "User" -> User - | _ -> sprintf "%s is not an authorization level" it |> invalidOp - - -/// Post statuses -type PostStatus = - /// Post has not been released for public consumption - | Draft - /// Post is released - | Published - -/// Functions to support post statuses -module PostStatus = - /// Get the string representation of a post status - let toString = function Draft -> "Draft" | Published -> "Published" - /// Create a post status from a string - let ofString it = - match it with - | "Draft" -> Draft - | "Published" -> Published - | _ -> sprintf "%s is not a post status" it |> invalidOp - - -/// Comment statuses -type CommentStatus = - /// Comment is approved - | Approved - /// Comment has yet to be approved - | Pending - /// Comment was flagged as spam - | Spam - -/// Functions to support comment statuses -module CommentStatus = - /// Get the string representation of a comment status - let toString = function Approved -> "Approved" | Pending -> "Pending" | Spam -> "Spam" - /// Create a comment status from a string - let ofString it = - match it with - | "Approved" -> Approved - | "Pending" -> Pending - | "Spam" -> Spam - | _ -> sprintf "%s is not a comment status" it |> invalidOp - - -/// Seconds since the Unix epoch -type UnixSeconds = UnixSeconds of int64 - -/// Functions to support Unix seconds -module UnixSeconds = - /// Get the long (int64) representation of Unix seconds - let toLong = function UnixSeconds it -> it - /// Zero seconds past the epoch - let none = UnixSeconds 0L - - -// -- IDs -- - -open System - -// See https://www.madskristensen.net/blog/A-shorter-and-URL-friendly-GUID for info on "short GUIDs" - -/// A short GUID -type ShortGuid = ShortGuid of Guid - -/// Functions to support short GUIDs -module ShortGuid = - /// Encode a GUID into a short GUID - let toString = function - | ShortGuid guid -> - Convert.ToBase64String(guid.ToByteArray ()) - .Replace("/", "_") - .Replace("+", "-") - .Substring (0, 22) - /// Decode a short GUID into a GUID - let ofString (it : string) = - it.Replace("_", "/").Replace ("-", "+") - |> (sprintf "%s==" >> Convert.FromBase64String >> Guid >> ShortGuid) - /// Create a new short GUID - let create () = (Guid.NewGuid >> ShortGuid) () - /// The empty short GUID - let empty = ShortGuid Guid.Empty - - -/// The ID of a category -type CategoryId = CategoryId of ShortGuid - -/// Functions to support category IDs -module CategoryId = - /// Get the string representation of a page ID - let toString = function CategoryId it -> ShortGuid.toString it - /// Create a category ID from its string representation - let ofString = ShortGuid.ofString >> CategoryId - /// An empty category ID - let empty = CategoryId ShortGuid.empty - - -/// The ID of a comment -type CommentId = CommentId of ShortGuid - -/// Functions to support comment IDs -module CommentId = - /// Get the string representation of a comment ID - let toString = function CommentId it -> ShortGuid.toString it - /// Create a comment ID from its string representation - let ofString = ShortGuid.ofString >> CommentId - /// An empty comment ID - let empty = CommentId ShortGuid.empty - - -/// The ID of a page -type PageId = PageId of ShortGuid - -/// Functions to support page IDs -module PageId = - /// Get the string representation of a page ID - let toString = function PageId it -> ShortGuid.toString it - /// Create a page ID from its string representation - let ofString = ShortGuid.ofString >> PageId - /// An empty page ID - let empty = PageId ShortGuid.empty - - -/// The ID of a post -type PostId = PostId of ShortGuid - -/// Functions to support post IDs -module PostId = - /// Get the string representation of a post ID - let toString = function PostId it -> ShortGuid.toString it - /// Create a post ID from its string representation - let ofString = ShortGuid.ofString >> PostId - /// An empty post ID - let empty = PostId ShortGuid.empty - - -/// The ID of a user -type UserId = UserId of ShortGuid - -/// Functions to support user IDs -module UserId = - /// Get the string representation of a user ID - let toString = function UserId it -> ShortGuid.toString it - /// Create a user ID from its string representation - let ofString = ShortGuid.ofString >> UserId - /// An empty user ID - let empty = UserId ShortGuid.empty - - -/// The ID of a web log -type WebLogId = WebLogId of ShortGuid - -/// Functions to support web log IDs -module WebLogId = - /// Get the string representation of a web log ID - let toString = function WebLogId it -> ShortGuid.toString it - /// Create a web log ID from its string representation - let ofString = ShortGuid.ofString >> WebLogId - /// An empty web log ID - let empty = WebLogId ShortGuid.empty - - -// -- Domain Entities -- -// fsharplint:disable RecordFieldNames - -/// A revision of a post or page -type Revision = { - /// The instant which this revision was saved - asOf : UnixSeconds - /// The text - text : MarkupText - } -with - /// An empty revision - static member empty = - { asOf = UnixSeconds.none - text = Markdown "" - } - - -/// A page with static content -[] -type Page = { - /// The Id - id : PageId - /// The Id of the web log to which this page belongs - webLogId : WebLogId - /// The Id of the author of this page - authorId : UserId - /// The title of the page - title : string - /// The link at which this page is displayed - permalink : string - /// The instant this page was published - publishedOn : UnixSeconds - /// The instant this page was last updated - updatedOn : UnixSeconds - /// Whether this page shows as part of the web log's navigation - showInPageList : bool - /// The current text of the page - text : MarkupText - /// Revisions of this page - revisions : Revision list - } -with - static member empty = - { id = PageId.empty - webLogId = WebLogId.empty - authorId = UserId.empty - title = "" - permalink = "" - publishedOn = UnixSeconds.none - updatedOn = UnixSeconds.none - showInPageList = false - text = Markdown "" - revisions = [] - } - - -/// An entry in the list of pages displayed as part of the web log (derived via query) -type PageListEntry = { - /// The permanent link for the page - permalink : string - /// The title of the page - title : string - } - - -/// A web log -[] -type WebLog = { - /// The Id - id : WebLogId - /// The name - name : string - /// The subtitle - subtitle : string option - /// The default page ("posts" or a page Id) - defaultPage : string - /// The path of the theme (within /views/themes) - themePath : string - /// The URL base - urlBase : string - /// The time zone in which dates/times should be displayed - timeZone : string - /// A list of pages to be rendered as part of the site navigation (not stored) - pageList : PageListEntry list - } -with - /// An empty web log - static member empty = - { id = WebLogId.empty - name = "" - subtitle = None - defaultPage = "" - themePath = "default" - urlBase = "" - timeZone = "America/New_York" - pageList = [] - } - - -/// An authorization between a user and a web log -type Authorization = { - /// The Id of the web log to which this authorization grants access - webLogId : WebLogId - /// The level of access granted by this authorization - level : AuthorizationLevel -} - - -/// A user of myWebLog -[] -type User = { - /// The Id - id : UserId - /// The user name (e-mail address) - userName : string - /// The first name - firstName : string - /// The last name - lastName : string - /// The user's preferred name - preferredName : string - /// The hash of the user's password - passwordHash : string - /// The URL of the user's personal site - url : string option - /// The user's authorizations - authorizations : Authorization list - } -with - /// An empty user - static member empty = - { id = UserId.empty - userName = "" - firstName = "" - lastName = "" - preferredName = "" - passwordHash = "" - url = None - authorizations = [] - } - -/// Functions supporting users -module User = - /// Claims for this user - let claims user = - user.authorizations - |> List.map (fun a -> sprintf "%s|%s" (WebLogId.toString a.webLogId) (AuthorizationLevel.toString a.level)) - - -/// A category to which posts may be assigned -[] -type Category = { - /// The Id - id : CategoryId - /// The Id of the web log to which this category belongs - webLogId : WebLogId - /// The displayed name - name : string - /// The slug (used in category URLs) - slug : string - /// A longer description of the category - description : string option - /// The parent Id of this category (if a subcategory) - parentId : CategoryId option - /// The categories for which this category is the parent - children : CategoryId list - } -with - /// An empty category - static member empty = - { id = CategoryId.empty - webLogId = WebLogId.empty - name = "" - slug = "" - description = None - parentId = None - children = [] - } - - -/// A comment (applies to a post) -[] -type Comment = { - /// The Id - id : CommentId - /// The Id of the post to which this comment applies - postId : PostId - /// The Id of the comment to which this comment is a reply - inReplyToId : CommentId option - /// The name of the commentor - name : string - /// The e-mail address of the commentor - email : string - /// The URL of the commentor's personal website - url : string option - /// The status of the comment - status : CommentStatus - /// The instant the comment was posted - postedOn : UnixSeconds - /// The text of the comment - text : string - } -with - static member empty = - { id = CommentId.empty - postId = PostId.empty - inReplyToId = None - name = "" - email = "" - url = None - status = Pending - postedOn = UnixSeconds.none - text = "" - } - - -/// A post -[] -type Post = { - /// The Id - id : PostId - /// The Id of the web log to which this post belongs - webLogId : WebLogId - /// The Id of the author of this post - authorId : UserId - /// The status - status : PostStatus - /// The title - title : string - /// The link at which the post resides - permalink : string - /// The instant on which the post was originally published - publishedOn : UnixSeconds - /// The instant on which the post was last updated - updatedOn : UnixSeconds - /// The text of the post - text : MarkupText - /// The Ids of the categories to which this is assigned - categoryIds : CategoryId list - /// The tags for the post - tags : string list - /// The permalinks at which this post may have once resided - priorPermalinks : string list - /// Revisions of this post - revisions : Revision list - /// The categories to which this is assigned (not stored in database) - categories : Category list - /// The comments (not stored in database) - comments : Comment list - } -with - static member empty = - { id = PostId.empty - webLogId = WebLogId.empty - authorId = UserId.empty - status = Draft - title = "" - permalink = "" - publishedOn = UnixSeconds.none - updatedOn = UnixSeconds.none - text = Markdown "" - categoryIds = [] - tags = [] - priorPermalinks = [] - revisions = [] - categories = [] - comments = [] - } - -// --- UI Support --- - -/// Counts of items displayed on the admin dashboard -type DashboardCounts = { - /// The number of pages for the web log - pages : int - /// The number of pages for the web log - posts : int - /// The number of categories for the web log - categories : int - } diff --git a/src/MyWebLog/Features/FeatureSupport.cs b/src/MyWebLog/Features/FeatureSupport.cs new file mode 100644 index 0000000..041ebcb --- /dev/null +++ b/src/MyWebLog/Features/FeatureSupport.cs @@ -0,0 +1,67 @@ +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; + +/// +/// A controller model convention that identifies the feature in which a controller exists +/// +public class FeatureControllerModelConvention : IControllerModelConvention +{ + /// + /// A cache of controller types to features + /// + private static readonly ConcurrentDictionary _features = new(); + + /// + /// Derive the feature name from the controller's type + /// + 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; + } + + /// + public void Apply(ControllerModel controller) => + controller.Properties.Add("feature", GetFeatureName(controller.ControllerType)); + +} + +/// +/// Expand the location token with the feature name +/// +public class FeatureViewLocationExpander : IViewLocationExpander +{ + /// + public IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, + IEnumerable 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); + } + + /// + public void PopulateValues(ViewLocationExpanderContext _) { } +} diff --git a/src/MyWebLog/Features/Posts/PostController.cs b/src/MyWebLog/Features/Posts/PostController.cs new file mode 100644 index 0000000..123d6c8 --- /dev/null +++ b/src/MyWebLog/Features/Posts/PostController.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; + +namespace MyWebLog.Features.Posts; + +/// +/// Handle post-related requests +/// +public class PostController : MyWebLogController +{ + /// + public PostController(WebLogDbContext db) : base(db) { } + + [HttpGet("~/")] + public async Task Index() + { + var webLog = WebLogCache.Get(HttpContext); + if (webLog.DefaultPage == "posts") + { + var posts = await Db.Posts.FindPageOfPublishedPosts(1, webLog.PostsPerPage); + return ThemedView("Index", posts); + } + var page = await Db.Pages.FindById(webLog.DefaultPage); + return page is null ? NotFound() : ThemedView("SinglePage", page); + } +} diff --git a/src/MyWebLog/Features/Shared/MyWebLogController.cs b/src/MyWebLog/Features/Shared/MyWebLogController.cs new file mode 100644 index 0000000..a6bd7b3 --- /dev/null +++ b/src/MyWebLog/Features/Shared/MyWebLogController.cs @@ -0,0 +1,25 @@ +using Microsoft.AspNetCore.Mvc; + +namespace MyWebLog.Features.Shared; + +public abstract class MyWebLogController : Controller +{ + /// + /// The data context to use to fulfil this request + /// + protected WebLogDbContext Db { get; init; } + + /// + /// Constructor + /// + /// The data context to use to fulfil this request + protected MyWebLogController(WebLogDbContext db) + { + Db = db; + } + + protected ViewResult ThemedView(string template, object model) + { + return View(template, model); + } +} diff --git a/src/MyWebLog/Features/ThemeSupport.cs b/src/MyWebLog/Features/ThemeSupport.cs new file mode 100644 index 0000000..1e495e8 --- /dev/null +++ b/src/MyWebLog/Features/ThemeSupport.cs @@ -0,0 +1,28 @@ +using Microsoft.AspNetCore.Mvc.Razor; + +namespace MyWebLog.Features; + +/// +/// Expand the location token with the theme path +/// +public class ThemeViewLocationExpander : IViewLocationExpander +{ + /// + public IEnumerable ExpandViewLocations(ViewLocationExpanderContext context, + IEnumerable 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"]!); + } + + /// + public void PopulateValues(ViewLocationExpanderContext context) + { + _ = context ?? throw new ArgumentNullException(nameof(context)); + + context.Values["theme"] = WebLogCache.Get(context.ActionContext.HttpContext).ThemePath; + } +} diff --git a/src/MyWebLog/Features/Users/UserController.cs b/src/MyWebLog/Features/Users/UserController.cs new file mode 100644 index 0000000..7123c8a --- /dev/null +++ b/src/MyWebLog/Features/Users/UserController.cs @@ -0,0 +1,33 @@ +using Microsoft.AspNetCore.Mvc; +using System.Security.Cryptography; +using System.Text; + +namespace MyWebLog.Features.Users; + +/// +/// Controller for the users feature +/// +public class UserController : MyWebLogController +{ + /// + /// Hash a password for a given user + /// + /// The plain-text password + /// The user's e-mail address + /// The user-specific salt + /// + internal static string HashedPassword(string plainText, string email, Guid salt) + { + var allSalt = salt.ToByteArray().Concat(Encoding.UTF8.GetBytes(email)).ToArray(); + using var alg = new Rfc2898DeriveBytes(plainText, allSalt, 2_048); + return Convert.ToBase64String(alg.GetBytes(64)); + } + + /// + public UserController(WebLogDbContext db) : base(db) { } + + public IActionResult Index() + { + return View(); + } +} diff --git a/src/MyWebLog/GlobalUsings.cs b/src/MyWebLog/GlobalUsings.cs new file mode 100644 index 0000000..f3f143e --- /dev/null +++ b/src/MyWebLog/GlobalUsings.cs @@ -0,0 +1,2 @@ +global using MyWebLog.Data; +global using MyWebLog.Features.Shared; diff --git a/src/MyWebLog/MyWebLog.csproj b/src/MyWebLog/MyWebLog.csproj new file mode 100644 index 0000000..c0b846f --- /dev/null +++ b/src/MyWebLog/MyWebLog.csproj @@ -0,0 +1,24 @@ + + + + net6.0 + enable + enable + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + diff --git a/src/MyWebLog/MyWebLog.fsproj b/src/MyWebLog/MyWebLog.fsproj deleted file mode 100644 index e11ac96..0000000 --- a/src/MyWebLog/MyWebLog.fsproj +++ /dev/null @@ -1,28 +0,0 @@ - - - - Exe - net6.0 - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/MyWebLog/Program.cs b/src/MyWebLog/Program.cs new file mode 100644 index 0000000..6f3b234 --- /dev/null +++ b/src/MyWebLog/Program.cs @@ -0,0 +1,131 @@ +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.EntityFrameworkCore; +using MyWebLog; +using MyWebLog.Features; +using MyWebLog.Features.Users; + +if (args.Length > 0 && args[0] == "init") +{ + await InitDb(); + return; +} + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddMvc(opts => opts.Conventions.Add(new FeatureControllerModelConvention())) + .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(); +builder.Services.AddDbContext(o => +{ + // TODO: can get from DI? + var db = WebLogCache.HostToDb(new HttpContextAccessor().HttpContext!); + // "Data Source=Db/empty.db" + o.UseSqlite($"Data Source=Db/{db}.db"); +}); + +var app = builder.Build(); + +app.UseCookiePolicy(new CookiePolicyOptions { MinimumSameSitePolicy = SameSiteMode.Strict }); +app.UseMiddleware(); +app.UseAuthentication(); +app.UseStaticFiles(); +app.UseRouting(); +app.UseAuthorization(); +app.UseEndpoints(endpoints => endpoints.MapControllers()); + +app.Run(); + +/// +/// Initialize a new database +/// +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() + .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 = "

This is your default home page.

", + Revisions = new[] + { + new PageRevision + { + Id = WebLogDbContext.NewId(), + AsOf = DateTime.UtcNow, + SourceType = RevisionSource.Html, + Text = "

This is your default home page.

" + } + } + }; + 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]}"); +} diff --git a/src/MyWebLog/Program.fs b/src/MyWebLog/Program.fs deleted file mode 100644 index 139a10e..0000000 --- a/src/MyWebLog/Program.fs +++ /dev/null @@ -1,4 +0,0 @@ -open MyWebLog -open Suave - -startWebServer defaultConfig (Successful.OK (Strings.get "LastUpdated")) diff --git a/src/MyWebLog/Properties/launchSettings.json b/src/MyWebLog/Properties/launchSettings.json new file mode 100644 index 0000000..7d7face --- /dev/null +++ b/src/MyWebLog/Properties/launchSettings.json @@ -0,0 +1,28 @@ +{ + "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" + } + } + } +} diff --git a/src/MyWebLog/Resources/en-US.json b/src/MyWebLog/Resources/en-US.json deleted file mode 100644 index be2715a..0000000 --- a/src/MyWebLog/Resources/en-US.json +++ /dev/null @@ -1,83 +0,0 @@ -{ - "Action": "Action", - "Added": "Added", - "AddNew": "Add New", - "AddNewCategory": "Add New Category", - "AddNewPage": "Add New Page", - "AddNewPost": "Add New Post", - "Admin": "Admin", - "AndPublished": " and Published", - "andXMore": "and {0} more...", - "at": "at", - "BackToCategoryList": "Back to Category List", - "BackToPageList": "Back to Page List", - "BackToPostList": "Back to Post List", - "Categories": "Categories", - "Category": "Category", - "CategoryDeleteWarning": "Are you sure you wish to delete the category", - "Close": "Close", - "Comments": "Comments", - "Dashboard": "Dashboard", - "Date": "Date", - "Delete": "Delete", - "Description": "Description", - "Edit": "Edit", - "EditCategory": "Edit Category", - "EditPage": "Edit Page", - "EditPost": "Edit Post", - "EmailAddress": "E-mail Address", - "ErrBadAppConfig": "Could not convert config.json to myWebLog configuration", - "ErrBadLogOnAttempt": "Invalid e-mail address or password", - "ErrDataConfig": "Could not convert data-config.json to RethinkDB connection", - "ErrNotConfigured": "is not properly configured for myWebLog", - "Error": "Error", - "LastUpdated": "Last Updated", - "LastUpdatedDate": "Last Updated Date", - "ListAll": "List All", - "LoadedIn": "Loaded in", - "LogOff": "Log Off", - "LogOn": "Log On", - "MsgCategoryDeleted": "Deleted category {0} successfully", - "MsgCategoryEditSuccess": "{0} category successfully", - "MsgLogOffSuccess": "Log off successful | Have a nice day!", - "MsgLogOnSuccess": "Log on successful | Welcome to myWebLog!", - "MsgPageDeleted": "Deleted page successfully", - "MsgPageEditSuccess": "{0} page successfully", - "MsgPostEditSuccess": "{0}{1} post successfully", - "Name": "Name", - "NewerPosts": "Newer Posts", - "NextPost": "Next Post", - "NoComments": "No Comments", - "NoParent": "No Parent", - "OlderPosts": "Older Posts", - "OneComment": "1 Comment", - "PageDeleteWarning": "Are you sure you wish to delete the page", - "PageDetails": "Page Details", - "PageHash": "Page #", - "Pages": "Pages", - "ParentCategory": "Parent Category", - "Password": "Password", - "Permalink": "Permalink", - "PermanentLinkTo": "Permanent Link to", - "PostDetails": "Post Details", - "Posts": "Posts", - "PostsTagged": "Posts Tagged", - "PostStatus": "Post Status", - "PoweredBy": "Powered by", - "PreviousPost": "Previous Post", - "PublishedDate": "Published Date", - "PublishThisPost": "Publish This Post", - "Save": "Save", - "Seconds": "Seconds", - "ShowInPageList": "Show in Page List", - "Slug": "Slug", - "startingWith": "starting with", - "Status": "Status", - "Tags": "Tags", - "Time": "Time", - "Title": "Title", - "Updated": "Updated", - "View": "View", - "Warning": "Warning", - "XComments": "{0} Comments" -} diff --git a/src/MyWebLog/Strings.fs b/src/MyWebLog/Strings.fs deleted file mode 100644 index 55a725b..0000000 --- a/src/MyWebLog/Strings.fs +++ /dev/null @@ -1,40 +0,0 @@ -module MyWebLog.Strings - -open System.Collections.Generic -open System.Globalization -open System.IO -open System.Reflection -open System.Text.Json - -/// The locales we'll try to load -let private supportedLocales = [ "en-US" ] - -/// The fallback locale, if a key is not found in a non-default locale -let private fallbackLocale = "en-US" - -/// Get an embedded JSON file as a string -let private getEmbedded locale = - let str = sprintf "MyWebLog.Resources.%s.json" locale |> Assembly.GetExecutingAssembly().GetManifestResourceStream - use rdr = new StreamReader (str) - rdr.ReadToEnd() - -/// The dictionary of localized strings -let private strings = - supportedLocales - |> List.map (fun loc -> loc, getEmbedded loc |> JsonSerializer.Deserialize>) - |> dict - -/// Get a key from the resources file for the given locale -let getForLocale locale key = - let getString thisLocale = - match strings.ContainsKey thisLocale && strings.[thisLocale].ContainsKey key with - | true -> Some strings.[thisLocale].[key] - | false -> None - match getString locale with - | Some xlat -> Some xlat - | None when locale <> fallbackLocale -> getString fallbackLocale - | None -> None - |> function Some xlat -> xlat | None -> sprintf "%s.%s" locale key - -/// Translate the key for the current locale -let get key = getForLocale CultureInfo.CurrentCulture.Name key diff --git a/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml b/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml new file mode 100644 index 0000000..52c9886 --- /dev/null +++ b/src/MyWebLog/Themes/Default/Shared/_Layout.cshtml @@ -0,0 +1,14 @@ +@inject IHttpContextAccessor ctxAcc + + + + + + @ViewBag.Title « @WebLogCache.Get(ctxAcc.HttpContext!).Name + + +
+ @RenderBody() +
+ + diff --git a/src/MyWebLog/Themes/Default/SinglePage.cshtml b/src/MyWebLog/Themes/Default/SinglePage.cshtml new file mode 100644 index 0000000..51c3352 --- /dev/null +++ b/src/MyWebLog/Themes/Default/SinglePage.cshtml @@ -0,0 +1,9 @@ +@model Page +@{ + Layout = "_Layout"; + ViewBag.Title = Model.Title; +} +

@Model.Title

+
+ @Html.Raw(Model.Text) +
diff --git a/src/MyWebLog/Themes/_ViewImports.cshtml b/src/MyWebLog/Themes/_ViewImports.cshtml new file mode 100644 index 0000000..eaf3b3e --- /dev/null +++ b/src/MyWebLog/Themes/_ViewImports.cshtml @@ -0,0 +1 @@ +@namespace MyWebLog.Themes diff --git a/src/MyWebLog/WebLogMiddleware.cs b/src/MyWebLog/WebLogMiddleware.cs new file mode 100644 index 0000000..c285b79 --- /dev/null +++ b/src/MyWebLog/WebLogMiddleware.cs @@ -0,0 +1,90 @@ +using System.Collections.Concurrent; + +namespace MyWebLog; + +/// +/// In-memory cache of web log details +/// +/// This is filled by the middleware via the first request for each host, and can be updated via the web log +/// settings update page +public static class WebLogCache +{ + /// + /// The cache of web log details + /// + private static readonly ConcurrentDictionary _cache = new(); + + /// + /// Transform a hostname to a database name + /// + /// The current HTTP context + /// The hostname, with an underscore replacing a colon + public static string HostToDb(HttpContext ctx) => ctx.Request.Host.ToUriComponent().Replace(':', '_'); + + /// + /// Does a host exist in the cache? + /// + /// The host in question + /// True if it exists, false if not + public static bool Exists(string host) => _cache.ContainsKey(host); + + /// + /// Get the details for a web log via its host + /// + /// The host which should be retrieved + /// The web log details + public static WebLogDetails Get(string host) => _cache[host]; + + /// + /// Get the details for a web log via its host + /// + /// The HTTP context for the request + /// The web log details + public static WebLogDetails Get(HttpContext ctx) => _cache[HostToDb(ctx)]; + + /// + /// Set the details for a particular host + /// + /// The host for which details should be set + /// The details to be set + public static void Set(string host, WebLogDetails details) => _cache[host] = details; +} + +/// +/// Middleware to derive the current web log +/// +public class WebLogMiddleware +{ + /// + /// The next action in the pipeline + /// + private readonly RequestDelegate _next; + + /// + /// Constructor + /// + /// The next action in the pipeline + public WebLogMiddleware(RequestDelegate next) + { + _next = next; + } + + public async Task InvokeAsync(HttpContext context) + { + var host = WebLogCache.HostToDb(context); + + if (WebLogCache.Exists(host)) return; + + var db = context.RequestServices.GetRequiredService(); + 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); + } +} diff --git a/src/MyWebLog/appsettings.Development.json b/src/MyWebLog/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/MyWebLog/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/MyWebLog/appsettings.json b/src/MyWebLog/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/MyWebLog/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +}