diff --git a/OliverBooth/Controllers/BlogApiController.cs b/OliverBooth.Blog/Controllers/BlogApiController.cs
similarity index 59%
rename from OliverBooth/Controllers/BlogApiController.cs
rename to OliverBooth.Blog/Controllers/BlogApiController.cs
index 800eab6..3f24192 100644
--- a/OliverBooth/Controllers/BlogApiController.cs
+++ b/OliverBooth.Blog/Controllers/BlogApiController.cs
@@ -1,58 +1,66 @@
using Humanizer;
using Microsoft.AspNetCore.Cors;
using Microsoft.AspNetCore.Mvc;
-using OliverBooth.Data.Blog;
-using OliverBooth.Services;
+using OliverBooth.Blog.Data;
+using OliverBooth.Blog.Services;
-namespace OliverBooth.Controllers;
+namespace OliverBooth.Blog.Controllers;
///
/// Represents a controller for the blog API.
///
[ApiController]
-[Route("api/blog")]
+[Route("api")]
[Produces("application/json")]
-[EnableCors("BlogApi")]
+[EnableCors("OliverBooth")]
public sealed class BlogApiController : ControllerBase
{
- private readonly BlogService _blogService;
- private readonly BlogUserService _blogUserService;
+ private readonly IBlogPostService _blogPostService;
+ private readonly IUserService _userService;
///
/// Initializes a new instance of the class.
///
- /// The .
- /// The .
- public BlogApiController(BlogService blogService, BlogUserService blogUserService)
+ /// The .
+ /// The .
+ public BlogApiController(IBlogPostService blogPostService, IUserService userService)
{
- _blogService = blogService;
- _blogUserService = blogUserService;
+ _blogPostService = blogPostService;
+ _userService = userService;
}
[Route("count")]
public IActionResult Count()
{
if (!ValidateReferer()) return NotFound();
- return Ok(new { count = _blogService.AllPosts.Count });
+ return Ok(new { count = _blogPostService.GetAllBlogPosts().Count });
}
[HttpGet("all/{skip:int?}/{take:int?}")]
public IActionResult GetAllBlogPosts(int skip = 0, int take = -1)
{
if (!ValidateReferer()) return NotFound();
- if (take == -1) take = _blogService.AllPosts.Count;
- return Ok(_blogService.AllPosts.Skip(skip).Take(take).Select(post => new
+
+ // TODO yes I'm aware I can use the new pagination I wrote, this will be added soon.
+ IReadOnlyList allPosts = _blogPostService.GetAllBlogPosts();
+
+ if (take == -1)
+ {
+ take = allPosts.Count;
+ }
+
+ return Ok(allPosts.Skip(skip).Take(take).Select(post => new
{
id = post.Id,
commentsEnabled = post.EnableComments,
identifier = post.GetDisqusIdentifier(),
- author = post.AuthorId,
+ author = post.Author.Id,
title = post.Title,
published = post.Published.ToUnixTimeSeconds(),
formattedDate = post.Published.ToString("dddd, d MMMM yyyy HH:mm"),
updated = post.Updated?.ToUnixTimeSeconds(),
humanizedTimestamp = post.Updated?.Humanize() ?? post.Published.Humanize(),
- excerpt = _blogService.GetExcerpt(post, out bool trimmed),
+ excerpt = _blogPostService.RenderExcerpt(post, out bool trimmed),
trimmed,
url = Url.Page("/Article",
new
@@ -70,19 +78,19 @@ public sealed class BlogApiController : ControllerBase
public IActionResult GetAuthor(Guid id)
{
if (!ValidateReferer()) return NotFound();
- if (!_blogUserService.TryGetUser(id, out User? author)) return NotFound();
+ if (!_userService.TryGetUser(id, out IUser? author)) return NotFound();
return Ok(new
{
id = author.Id,
name = author.DisplayName,
- avatarHash = author.AvatarHash,
+ avatarUrl = author.AvatarUrl,
});
}
private bool ValidateReferer()
{
var referer = Request.Headers["Referer"].ToString();
- return referer.StartsWith(Url.PageLink("/index", values: new { area = "blog" })!);
+ return referer.StartsWith(Url.PageLink("/index")!);
}
}
diff --git a/OliverBooth/Data/BlogContext.cs b/OliverBooth.Blog/Data/BlogContext.cs
similarity index 70%
rename from OliverBooth/Data/BlogContext.cs
rename to OliverBooth.Blog/Data/BlogContext.cs
index f5af0e0..cb3ea4e 100644
--- a/OliverBooth/Data/BlogContext.cs
+++ b/OliverBooth.Blog/Data/BlogContext.cs
@@ -1,13 +1,12 @@
using Microsoft.EntityFrameworkCore;
-using OliverBooth.Data.Blog;
-using OliverBooth.Data.Blog.Configuration;
+using OliverBooth.Blog.Data.Configuration;
-namespace OliverBooth.Data;
+namespace OliverBooth.Blog.Data;
///
/// Represents a session with the blog database.
///
-public sealed class BlogContext : DbContext
+internal sealed class BlogContext : DbContext
{
private readonly IConfiguration _configuration;
@@ -21,16 +20,16 @@ public sealed class BlogContext : DbContext
}
///
- /// Gets the set of blog posts.
+ /// Gets the collection of blog posts in the database.
///
- /// The set of blog posts.
- public DbSet BlogPosts { get; internal set; } = null!;
+ /// The collection of blog posts.
+ public DbSet BlogPosts { get; private set; } = null!;
///
- /// Gets the set of users.
+ /// Gets the collection of users in the database.
///
- /// The set of users.
- public DbSet Users { get; internal set; } = null!;
+ /// The collection of users.
+ public DbSet Users { get; private set; } = null!;
///
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
diff --git a/OliverBooth.Blog/Data/BlogPost.cs b/OliverBooth.Blog/Data/BlogPost.cs
new file mode 100644
index 0000000..ccea0d5
--- /dev/null
+++ b/OliverBooth.Blog/Data/BlogPost.cs
@@ -0,0 +1,102 @@
+using System.ComponentModel.DataAnnotations.Schema;
+using SmartFormat;
+
+namespace OliverBooth.Blog.Data;
+
+///
+internal sealed class BlogPost : IBlogPost
+{
+ ///
+ [NotMapped]
+ public IBlogAuthor Author { get; internal set; } = null!;
+
+ ///
+ public string Body { get; internal set; } = string.Empty;
+
+ ///
+ public bool EnableComments { get; internal set; }
+
+ ///
+ public Guid Id { get; private set; } = Guid.NewGuid();
+
+ ///
+ public bool IsRedirect { get; internal set; }
+
+ ///
+ public DateTimeOffset Published { get; internal set; }
+
+ ///
+ public Uri? RedirectUrl { get; internal set; }
+
+ ///
+ public string Slug { get; internal set; } = string.Empty;
+
+ ///
+ public string Title { get; internal set; } = string.Empty;
+
+ ///
+ public DateTimeOffset? Updated { get; internal set; }
+
+ ///
+ /// Gets or sets the ID of the author of this blog post.
+ ///
+ /// The ID of the author of this blog post.
+ internal Guid AuthorId { get; set; }
+
+ ///
+ /// Gets or sets the base URL of the Disqus comments for the blog post.
+ ///
+ /// The Disqus base URL.
+ internal string? DisqusDomain { get; set; }
+
+ ///
+ /// Gets or sets the identifier of the Disqus comments for the blog post.
+ ///
+ /// The Disqus identifier.
+ internal string? DisqusIdentifier { get; set; }
+
+ ///
+ /// Gets or sets the URL path of the Disqus comments for the blog post.
+ ///
+ /// The Disqus URL path.
+ internal string? DisqusPath { get; set; }
+
+ ///
+ /// Gets or sets the WordPress ID of this blog post.
+ ///
+ /// The WordPress ID of this blog post.
+ internal int? WordPressId { get; set; }
+
+ ///
+ /// Gets the Disqus domain for the blog post.
+ ///
+ /// The Disqus domain.
+ public string GetDisqusDomain()
+ {
+ return string.IsNullOrWhiteSpace(DisqusDomain)
+ ? "https://oliverbooth.dev/blog"
+ : Smart.Format(DisqusDomain, this);
+ }
+
+ ///
+ public string GetDisqusIdentifier()
+ {
+ return string.IsNullOrWhiteSpace(DisqusIdentifier) ? $"post-{Id}" : Smart.Format(DisqusIdentifier, this);
+ }
+
+ ///
+ public string GetDisqusUrl()
+ {
+ string path = string.IsNullOrWhiteSpace(DisqusPath)
+ ? $"{Published:yyyy/MM/dd}/{Slug}/"
+ : Smart.Format(DisqusPath, this);
+
+ return $"{GetDisqusDomain()}/{path}";
+ }
+
+ ///
+ public string GetDisqusPostId()
+ {
+ return WordPressId?.ToString() ?? Id.ToString();
+ }
+}
diff --git a/OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs b/OliverBooth.Blog/Data/Configuration/BlogPostConfiguration.cs
similarity index 83%
rename from OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs
rename to OliverBooth.Blog/Data/Configuration/BlogPostConfiguration.cs
index e11c48c..90a0bb2 100644
--- a/OliverBooth/Data/Blog/Configuration/BlogPostConfiguration.cs
+++ b/OliverBooth.Blog/Data/Configuration/BlogPostConfiguration.cs
@@ -1,11 +1,9 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
-namespace OliverBooth.Data.Blog.Configuration;
+namespace OliverBooth.Blog.Data.Configuration;
-///
-/// Represents the configuration for the entity.
-///
internal sealed class BlogPostConfiguration : IEntityTypeConfiguration
{
///
@@ -23,7 +21,7 @@ internal sealed class BlogPostConfiguration : IEntityTypeConfiguration
builder.Property(e => e.Title).HasMaxLength(255).IsRequired();
builder.Property(e => e.Body).IsRequired();
builder.Property(e => e.IsRedirect).IsRequired();
- builder.Property(e => e.RedirectUrl).IsRequired(false);
+ builder.Property(e => e.RedirectUrl).HasConversion().HasMaxLength(255).IsRequired(false);
builder.Property(e => e.EnableComments).IsRequired();
builder.Property(e => e.DisqusDomain).IsRequired(false);
builder.Property(e => e.DisqusIdentifier).IsRequired(false);
diff --git a/OliverBooth.Blog/Data/Configuration/UserConfiguration.cs b/OliverBooth.Blog/Data/Configuration/UserConfiguration.cs
new file mode 100644
index 0000000..68fa803
--- /dev/null
+++ b/OliverBooth.Blog/Data/Configuration/UserConfiguration.cs
@@ -0,0 +1,21 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata.Builders;
+
+namespace OliverBooth.Blog.Data.Configuration;
+
+internal sealed class UserConfiguration : IEntityTypeConfiguration
+{
+ ///
+ public void Configure(EntityTypeBuilder builder)
+ {
+ RelationalEntityTypeBuilderExtensions.ToTable((EntityTypeBuilder)builder, "User");
+ builder.HasKey(e => e.Id);
+
+ builder.Property(e => e.Id).IsRequired();
+ builder.Property(e => e.DisplayName).HasMaxLength(50).IsRequired();
+ builder.Property(e => e.EmailAddress).HasMaxLength(255).IsRequired();
+ builder.Property(e => e.Password).HasMaxLength(255).IsRequired();
+ builder.Property(e => e.Salt).HasMaxLength(255).IsRequired();
+ builder.Property(e => e.Registered).IsRequired();
+ }
+}
diff --git a/OliverBooth.Blog/Data/IBlogAuthor.cs b/OliverBooth.Blog/Data/IBlogAuthor.cs
new file mode 100644
index 0000000..cf9b4c0
--- /dev/null
+++ b/OliverBooth.Blog/Data/IBlogAuthor.cs
@@ -0,0 +1,32 @@
+namespace OliverBooth.Blog.Data;
+
+///
+/// Represents the author of a blog post.
+///
+public interface IBlogAuthor
+{
+ ///
+ /// Gets the URL of the author's avatar.
+ ///
+ /// The URL of the author's avatar.
+ Uri AvatarUrl { get; }
+
+ ///
+ /// Gets the display name of the author.
+ ///
+ /// The display name of the author.
+ string DisplayName { get; }
+
+ ///
+ /// Gets the unique identifier of the author.
+ ///
+ /// The unique identifier of the author.
+ Guid Id { get; }
+
+ ///
+ /// Gets the URL of the author's avatar.
+ ///
+ /// The size of the avatar.
+ /// The URL of the author's avatar.
+ Uri GetAvatarUrl(int size = 28);
+}
diff --git a/OliverBooth.Blog/Data/IBlogPost.cs b/OliverBooth.Blog/Data/IBlogPost.cs
new file mode 100644
index 0000000..af79935
--- /dev/null
+++ b/OliverBooth.Blog/Data/IBlogPost.cs
@@ -0,0 +1,89 @@
+namespace OliverBooth.Blog.Data;
+
+///
+/// Represents a blog post.
+///
+public interface IBlogPost
+{
+ ///
+ /// Gets the author of the post.
+ ///
+ /// The author of the post.
+ IBlogAuthor Author { get; }
+
+ ///
+ /// Gets the body of the post.
+ ///
+ /// The body of the post.
+ string Body { get; }
+
+ ///
+ /// Gets a value indicating whether comments are enabled for the post.
+ ///
+ ///
+ /// if comments are enabled for the post; otherwise, .
+ ///
+ bool EnableComments { get; }
+
+ ///
+ /// Gets the ID of the post.
+ ///
+ /// The ID of the post.
+ Guid Id { get; }
+
+ ///
+ /// Gets a value indicating whether the post redirects to another URL.
+ ///
+ ///
+ /// if the post redirects to another URL; otherwise, .
+ ///
+ bool IsRedirect { get; }
+
+ ///
+ /// Gets the date and time the post was published.
+ ///
+ /// The publication date and time.
+ DateTimeOffset Published { get; }
+
+ ///
+ /// Gets the URL to which the post redirects.
+ ///
+ /// The URL to which the post redirects, or if the post does not redirect.
+ Uri? RedirectUrl { get; }
+
+ ///
+ /// Gets the slug of the post.
+ ///
+ /// The slug of the post.
+ string Slug { get; }
+
+ ///
+ /// Gets the title of the post.
+ ///
+ /// The title of the post.
+ string Title { get; }
+
+ ///
+ /// Gets the date and time the post was last updated.
+ ///
+ /// The update date and time, or if the post has not been updated.
+ DateTimeOffset? Updated { get; }
+
+ ///
+ /// Gets the Disqus identifier for the post.
+ ///
+ /// The Disqus identifier for the post.
+ string GetDisqusIdentifier();
+
+ ///
+ /// Gets the Disqus URL for the post.
+ ///
+ /// The Disqus URL for the post.
+ string GetDisqusUrl();
+
+ ///
+ /// Gets the Disqus post ID for the post.
+ ///
+ /// The Disqus post ID for the post.
+ string GetDisqusPostId();
+}
diff --git a/OliverBooth.Blog/Data/IUser.cs b/OliverBooth.Blog/Data/IUser.cs
new file mode 100644
index 0000000..c9999dd
--- /dev/null
+++ b/OliverBooth.Blog/Data/IUser.cs
@@ -0,0 +1,54 @@
+namespace OliverBooth.Blog.Data;
+
+///
+/// Represents a user which can log in to the blog.
+///
+public interface IUser
+{
+ ///
+ /// Gets the URL of the user's avatar.
+ ///
+ /// The URL of the user's avatar.
+ Uri AvatarUrl { get; }
+
+ ///
+ /// Gets the email address of the user.
+ ///
+ /// The email address of the user.
+ string EmailAddress { get; }
+
+ ///
+ /// Gets the display name of the author.
+ ///
+ /// The display name of the author.
+ string DisplayName { get; }
+
+ ///
+ /// Gets the unique identifier of the user.
+ ///
+ /// The unique identifier of the user.
+ Guid Id { get; }
+
+ ///
+ /// Gets the date and time the user registered.
+ ///
+ /// The registration date and time.
+ DateTimeOffset Registered { get; }
+
+ ///
+ /// Gets the URL of the user's avatar.
+ ///
+ /// The size of the avatar.
+ /// The URL of the user's avatar.
+ Uri GetAvatarUrl(int size = 28);
+
+ ///
+ /// Returns a value indicating whether the specified password is valid for the user.
+ ///
+ /// The password to test.
+ ///
+ /// if the specified password is valid for the user; otherwise,
+ /// .
+ ///
+ bool TestCredentials(string password);
+}
diff --git a/OliverBooth/Data/Rss/AtomLink.cs b/OliverBooth.Blog/Data/Rss/AtomLink.cs
similarity index 100%
rename from OliverBooth/Data/Rss/AtomLink.cs
rename to OliverBooth.Blog/Data/Rss/AtomLink.cs
diff --git a/OliverBooth/Data/Rss/BlogChannel.cs b/OliverBooth.Blog/Data/Rss/BlogChannel.cs
similarity index 100%
rename from OliverBooth/Data/Rss/BlogChannel.cs
rename to OliverBooth.Blog/Data/Rss/BlogChannel.cs
diff --git a/OliverBooth/Data/Rss/BlogItem.cs b/OliverBooth.Blog/Data/Rss/BlogItem.cs
similarity index 100%
rename from OliverBooth/Data/Rss/BlogItem.cs
rename to OliverBooth.Blog/Data/Rss/BlogItem.cs
diff --git a/OliverBooth/Data/Rss/BlogRoot.cs b/OliverBooth.Blog/Data/Rss/BlogRoot.cs
similarity index 100%
rename from OliverBooth/Data/Rss/BlogRoot.cs
rename to OliverBooth.Blog/Data/Rss/BlogRoot.cs
diff --git a/OliverBooth.Blog/Data/User.cs b/OliverBooth.Blog/Data/User.cs
new file mode 100644
index 0000000..c374982
--- /dev/null
+++ b/OliverBooth.Blog/Data/User.cs
@@ -0,0 +1,73 @@
+using System.ComponentModel.DataAnnotations.Schema;
+using System.Security.Cryptography;
+using System.Text;
+using Cysharp.Text;
+
+namespace OliverBooth.Blog.Data;
+
+///
+/// Represents a user.
+///
+internal sealed class User : IUser, IBlogAuthor
+{
+ ///
+ [NotMapped]
+ public Uri AvatarUrl => GetAvatarUrl();
+
+ ///
+ public string EmailAddress { get; set; } = string.Empty;
+
+ ///
+ public string DisplayName { get; set; } = string.Empty;
+
+ ///
+ public Guid Id { get; private set; } = Guid.NewGuid();
+
+ ///
+ public DateTimeOffset Registered { get; private set; } = DateTimeOffset.UtcNow;
+
+ ///
+ /// Gets or sets the password hash.
+ ///
+ /// The password hash.
+ internal string Password { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the salt used to hash the password.
+ ///
+ /// The salt used to hash the password.
+ internal string Salt { get; set; } = string.Empty;
+
+ ///
+ public Uri GetAvatarUrl(int size = 28)
+ {
+ if (string.IsNullOrWhiteSpace(EmailAddress))
+ {
+ return new Uri($"https://www.gravatar.com/avatar/0?size={size}");
+ }
+
+ ReadOnlySpan span = EmailAddress.AsSpan();
+ int byteCount = Encoding.UTF8.GetByteCount(span);
+ Span bytes = stackalloc byte[byteCount];
+ Encoding.UTF8.GetBytes(span, bytes);
+
+ Span hash = stackalloc byte[16];
+ MD5.TryHashData(bytes, hash, out _);
+
+ using Utf8ValueStringBuilder builder = ZString.CreateUtf8StringBuilder();
+ Span hex = stackalloc char[2];
+ for (var index = 0; index < hash.Length; index++)
+ {
+ if (hash[index].TryFormat(hex, out _, "x2")) builder.Append(hex);
+ else builder.Append("00");
+ }
+
+ return new Uri($"https://www.gravatar.com/avatar/{builder}?size={size}");
+ }
+
+ ///
+ public bool TestCredentials(string password)
+ {
+ return false;
+ }
+}
diff --git a/OliverBooth/Middleware/RssEndpointExtensions.cs b/OliverBooth.Blog/Middleware/RssEndpointExtensions.cs
similarity index 90%
rename from OliverBooth/Middleware/RssEndpointExtensions.cs
rename to OliverBooth.Blog/Middleware/RssEndpointExtensions.cs
index 8b0d501..412657a 100644
--- a/OliverBooth/Middleware/RssEndpointExtensions.cs
+++ b/OliverBooth.Blog/Middleware/RssEndpointExtensions.cs
@@ -1,4 +1,4 @@
-namespace OliverBooth.Middleware;
+namespace OliverBooth.Blog.Middleware;
internal static class RssEndpointExtensions
{
diff --git a/OliverBooth/Middleware/RssMiddleware.cs b/OliverBooth.Blog/Middleware/RssMiddleware.cs
similarity index 53%
rename from OliverBooth/Middleware/RssMiddleware.cs
rename to OliverBooth.Blog/Middleware/RssMiddleware.cs
index 9e6ac6b..57a52ff 100644
--- a/OliverBooth/Middleware/RssMiddleware.cs
+++ b/OliverBooth.Blog/Middleware/RssMiddleware.cs
@@ -1,35 +1,18 @@
using System.Diagnostics.CodeAnalysis;
using System.Xml.Serialization;
-using OliverBooth.Data.Blog;
+using OliverBooth.Blog.Data;
+using OliverBooth.Blog.Services;
using OliverBooth.Data.Rss;
-using OliverBooth.Services;
-namespace OliverBooth.Middleware;
+namespace OliverBooth.Blog.Middleware;
-///
-/// Represents the RSS middleware.
-///
internal sealed class RssMiddleware
{
- private readonly BlogService _blogService;
- private readonly BlogUserService _userService;
- private readonly ConfigurationService _configurationService;
+ private readonly IBlogPostService _blogPostService;
- ///
- /// Initializes a new instance of the class.
- ///
- /// The request delegate.
- /// The blog service.
- /// The user service.
- /// The configuration service.
- public RssMiddleware(RequestDelegate _,
- BlogService blogService,
- BlogUserService userService,
- ConfigurationService configurationService)
+ public RssMiddleware(RequestDelegate _, IBlogPostService blogPostService)
{
- _blogService = blogService;
- _userService = userService;
- _configurationService = configurationService;
+ _blogPostService = blogPostService;
}
[SuppressMessage("Performance", "CA1822:Mark members as static", Justification = "Middleware")]
@@ -40,28 +23,25 @@ internal sealed class RssMiddleware
var baseUrl = $"https://{context.Request.Host}/blog";
var blogItems = new List();
- foreach (BlogPost blogPost in _blogService.AllPosts.OrderByDescending(p => p.Published))
+ foreach (IBlogPost post in _blogPostService.GetAllBlogPosts())
{
- var url = $"{baseUrl}/{blogPost.Published:yyyy/MM/dd}/{blogPost.Slug}";
- string excerpt = _blogService.GetExcerpt(blogPost, out _);
+ var url = $"{baseUrl}/{post.Published:yyyy/MM/dd}/{post.Slug}";
+ string excerpt = _blogPostService.RenderExcerpt(post, out _);
var description = $"{excerpt}