aboutsummaryrefslogtreecommitdiff
path: root/back-end/src/Model
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2023-07-12 01:28:23 -0400
committerLibravatar vnugent <public@vaughnnugent.com>2023-07-12 01:28:23 -0400
commitf64955c69d91e578e580b409ba31ac4b3477da96 (patch)
tree16f01392ddf1abfea13d7d1ede3bfb0459fe8f0d /back-end/src/Model
Initial commit
Diffstat (limited to 'back-end/src/Model')
-rw-r--r--back-end/src/Model/BlogChannel.cs54
-rw-r--r--back-end/src/Model/BlogPost.cs55
-rw-r--r--back-end/src/Model/ChannelManager.cs156
-rw-r--r--back-end/src/Model/ContentManager.cs300
-rw-r--r--back-end/src/Model/ContentMeta.cs68
-rw-r--r--back-end/src/Model/ExtendedProperty.cs44
-rw-r--r--back-end/src/Model/FeedMeta.cs109
-rw-r--r--back-end/src/Model/IBlogFeedContext.cs34
-rw-r--r--back-end/src/Model/IChannelContext.cs36
-rw-r--r--back-end/src/Model/IRecord.cs40
-rw-r--r--back-end/src/Model/IRecordDb.cs71
-rw-r--r--back-end/src/Model/JsonRecordDb.cs167
-rw-r--r--back-end/src/Model/PostManager.cs232
-rw-r--r--back-end/src/Model/PostMeta.cs55
14 files changed, 1421 insertions, 0 deletions
diff --git a/back-end/src/Model/BlogChannel.cs b/back-end/src/Model/BlogChannel.cs
new file mode 100644
index 0000000..28beeec
--- /dev/null
+++ b/back-end/src/Model/BlogChannel.cs
@@ -0,0 +1,54 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: BlogChannel.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal class BlogChannel : IChannelContext
+ {
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("name")]
+ public string BlogName { get; set; } = "";
+
+ [JsonPropertyName("path")]
+ public string BaseDir { get; set; } = "";
+
+ /*
+ * Configure some defaults if the user does not
+ * specify them during channel creation.
+ */
+
+ [JsonPropertyName("index")]
+ public string IndexPath { get; set; } = "index.json";
+
+ [JsonPropertyName("content")]
+ public string? ContentDir { get; set; } = "/content";
+
+ [JsonPropertyName("feed")]
+ public FeedMeta? Feed { get; set; }
+
+ [JsonIgnore]
+ public long Date { get; set; }
+ }
+}
diff --git a/back-end/src/Model/BlogPost.cs b/back-end/src/Model/BlogPost.cs
new file mode 100644
index 0000000..0adea40
--- /dev/null
+++ b/back-end/src/Model/BlogPost.cs
@@ -0,0 +1,55 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: BlogPost.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+
+using FluentValidation;
+
+using VNLib.Plugins.Extensions.Validation;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal class BlogPost : PostMeta
+ {
+
+ public static IValidator<BlogPost> GetValidator()
+ {
+ InlineValidator<BlogPost> validator = new();
+
+ validator.RuleFor(x => x.Title!)
+ .NotEmpty()
+ .IllegalCharacters()
+ .MaximumLength(200);
+
+ validator.RuleFor(x => x.Summary!)
+ .NotEmpty()
+ .IllegalCharacters()
+ .MaximumLength(200);
+
+ validator.RuleFor(x => x.Author!)
+ .NotEmpty()
+ .IllegalCharacters()
+ .MaximumLength(64);
+
+ return validator;
+ }
+ }
+}
diff --git a/back-end/src/Model/ChannelManager.cs b/back-end/src/Model/ChannelManager.cs
new file mode 100644
index 0000000..adcc3b2
--- /dev/null
+++ b/back-end/src/Model/ChannelManager.cs
@@ -0,0 +1,156 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: ChannelManager.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+using VNLib.Hashing;
+using VNLib.Plugins;
+using VNLib.Plugins.Extensions.Loading;
+
+using Content.Publishing.Blog.Admin.Storage;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+
+ [ConfigurationName("blog_channels")]
+ internal sealed class ChannelManager : IChannelContextManager
+ {
+ private readonly IStorageFacade Storage;
+ private readonly string _indexPath;
+
+
+ public ChannelManager(PluginBase plugin, IConfigScope config)
+ {
+ //Init minio client
+ Storage = plugin.GetOrCreateSingleton<ManagedStorage>();
+
+ _indexPath = config["index_file_name"].GetString() ?? "channels.json";
+ }
+
+ ///<inheritdoc/>
+ public async Task<bool> CreateChannelAsync(BlogChannel context, CancellationToken cancellation)
+ {
+ _ = context.Id ?? throw new ArgumentNullException(nameof(context.Id));
+
+ IRecordDb<BlogChannel> db = await LoadDb(cancellation);
+
+ BlogChannel? existing = db.GetRecord(context.Id);
+
+ //Make sure the id is unique
+ if (existing != null)
+ {
+ return false;
+ }
+
+ //Add to the index
+ db.SetRecord(context);
+
+ //Publish updated index to storage
+ await StoreDb(db, cancellation);
+
+ return true;
+ }
+
+ ///<inheritdoc/>
+ public async Task<bool> UpdateChannelAsync(BlogChannel channel, CancellationToken cancellation)
+ {
+ _ = channel.Id ?? throw new ArgumentNullException(nameof(channel.Id));
+
+ IRecordDb<BlogChannel> db = await LoadDb(cancellation);
+
+ //Get the original context for the channel
+ _ = db.GetRecord(channel.Id) ?? throw new KeyNotFoundException("The requested channel does not exist");
+
+ //Add the updated context to the index
+ db.SetRecord(channel);
+
+ //Publish updated index to storage
+ await StoreDb(db, cancellation);
+
+ return true;
+ }
+
+ ///<inheritdoc/>
+ public async Task DeleteChannelAsync(IChannelContext context, CancellationToken cancellation)
+ {
+ _ = context?.Id ?? throw new ArgumentNullException(nameof(context));
+
+ IRecordDb<BlogChannel> db = await LoadDb(cancellation);
+
+ //Remove from the index
+ db.RemoveRecord(context.Id);
+
+ //Delete the channel dir
+ await Storage.DeleteFileAsync(context.BaseDir, cancellation);
+
+ //Publish updated index to storage
+ await StoreDb(db, cancellation);
+ }
+
+ /// <summary>
+ /// Computes the unique id for a context
+ /// </summary>
+ /// <param name="context">The context to produce the context id for</param>
+ /// <returns>The unique context id</returns>
+ public static string ComputeContextId(IChannelContext context)
+ {
+ //Context-id is the hash of the base dir and index file path
+ return ManagedHash.ComputeHexHash($"{context.BaseDir}/{context.IndexPath}", HashAlg.SHA1).ToLowerInvariant();
+ }
+
+ ///<inheritdoc/>
+ public async Task<IChannelContext?> GetChannelAsync(string id, CancellationToken cancellation)
+ {
+ //Recover the db
+ IRecordDb<BlogChannel> db = await LoadDb(cancellation);
+
+ //Get the channel
+ return db.GetRecord(id);
+ }
+
+ ///<inheritdoc/>
+ public async Task<object[]> GetAllContextsAsync(CancellationToken cancellation)
+ {
+ //Recover the db
+ IRecordDb<BlogChannel> db = await LoadDb(cancellation);
+
+ //Get the channel
+ return db.GetRecords().ToArray();
+ }
+
+
+ private Task<IRecordDb<BlogChannel>> LoadDb(CancellationToken cancellation)
+ {
+ return Storage.LoadDbAsync<BlogChannel>(_indexPath, cancellation);
+ }
+
+ private Task StoreDb(IRecordDb<BlogChannel> db, CancellationToken cancellation)
+ {
+ return Storage.StoreAsync(_indexPath, db, cancellation);
+ }
+
+ }
+}
diff --git a/back-end/src/Model/ContentManager.cs b/back-end/src/Model/ContentManager.cs
new file mode 100644
index 0000000..a0ed94f
--- /dev/null
+++ b/back-end/src/Model/ContentManager.cs
@@ -0,0 +1,300 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: ContentManager.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Content.Publishing.Blog.Admin.Storage;
+
+using VNLib.Hashing;
+using VNLib.Net.Http;
+using VNLib.Plugins;
+using VNLib.Plugins.Extensions.Loading;
+
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal sealed class ContentManager
+ {
+ private const string ContentIndex = "content.json";
+
+ private readonly IStorageFacade Storage;
+
+ public ContentManager(PluginBase plugin)
+ {
+ //Load the minio client manager
+ Storage = plugin.GetOrCreateSingleton<ManagedStorage>();
+ }
+
+ /// <summary>
+ /// Gets the content meta object for the given content item by its id
+ /// </summary>
+ /// <param name="channel">The channel that contains the desired content</param>
+ /// <param name="metaId">The id of the object</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>The content meta item if found in the store</returns>
+ public async Task<ContentMeta?> GetMetaAsync(IChannelContext channel, string metaId, CancellationToken cancellation)
+ {
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await Storage.LoadDbAsync<ContentMeta>(channel, ContentIndex, cancellation);
+
+ //Get the content meta
+ return contentIndex.GetRecord(metaId);
+ }
+
+ /// <summary>
+ /// Overwrites the content index with the given content index
+ /// </summary>
+ /// <param name="channel">The channel to set the content for</param>
+ /// <param name="meta">The contne meta to update</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that completes when the operation has completed</returns>
+ /// <exception cref="ArgumentNullException"></exception>
+ public async Task SetMetaAsync(IChannelContext channel, ContentMeta meta, CancellationToken cancellation)
+ {
+ _ = meta.Id ?? throw new ArgumentNullException(nameof(meta));
+
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await Storage.LoadDbAsync<ContentMeta>(channel, ContentIndex, cancellation);
+
+ //Set the content meta
+ contentIndex.SetRecord(meta);
+
+ //Save the content index
+ await StoreContentIndex(channel, contentIndex, cancellation);
+ }
+
+ /// <summary>
+ /// Initializes a new content meta object for a new content item
+ /// </summary>
+ /// <param name="length">The length of the new content item</param>
+ /// <returns>An initializes <see cref="ContentMeta"/> ready for a new content item</returns>
+ public ContentMeta GetNewMetaObject(long length, string? fileName, ContentType ct)
+ {
+ string fileId = RandomHash.GetRandomBase32(16).ToLowerInvariant();
+
+ return new()
+ {
+ Id = fileId,
+ Length = length,
+ FileName = fileName,
+ //File path from ct
+ FilePath = GetFileNameFromType(fileId, ct)
+ };
+ }
+
+ /// <summary>
+ /// Gets all content items in the given channel
+ /// </summary>
+ /// <param name="context">The channel to get content items for</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>The collection of content items for the channel</returns>
+ public async Task<ContentMeta[]> GetAllContentItemsAsync(IChannelContext context, CancellationToken cancellation)
+ {
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await Storage.LoadDbAsync<ContentMeta>(context, ContentIndex, cancellation);
+
+ //Return all content items
+ return contentIndex.GetRecords().ToArray();
+ }
+
+ /// <summary>
+ /// Reads content from the store and writes it to the output stream
+ /// </summary>
+ /// <param name="channel">The channel that contains the content</param>
+ /// <param name="metaId">The id of the content item to read</param>
+ /// <param name="output">The stream to write the file data to</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>The meta object that contains the content metadata if found, null if the content was not found in the directory</returns>
+ public async Task<ContentMeta?> GetContentAsync(IChannelContext context, string metaId, Stream output, CancellationToken cancellation)
+ {
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await Storage.LoadDbAsync<ContentMeta>(context, ContentIndex, cancellation);
+
+ //Get the content meta
+ ContentMeta? meta = contentIndex.GetRecord(metaId);
+
+ //Read the content
+ if (meta?.Id != null)
+ {
+ await Storage.ReadFileAsync(context, GetFilePath(context, meta), output, cancellation);
+ }
+
+ return meta;
+ }
+
+ /// <summary>
+ /// Adds content to the store
+ /// </summary>
+ /// <param name="context">The blog channel to store the data in</param>
+ /// <param name="meta">The content meta of the data to store</param>
+ /// <param name="data">The data stream to store</param>
+ /// <param name="ct">The content type of the data to store</param>
+ /// <param name="cancellation"></param>
+ /// <returns>A task that complets when the content has been added to the store</returns>
+ public async Task SetContentAsync(IChannelContext context, ContentMeta meta, Stream data, ContentType ct, CancellationToken cancellation)
+ {
+ //Update content type
+ meta.ContentType = HttpHelpers.GetContentTypeString(ct);
+
+ //update time
+ meta.Date = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ //update length
+ meta.Length = data.Length;
+
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await GetContentIndex(context, cancellation);
+
+ //Add the content meta to the store
+ contentIndex.SetRecord(meta);
+
+ //try to update the index before writing content
+ await StoreContentIndex(context, contentIndex, cancellation);
+
+ //Write the content
+ await Storage.SetObjectDataAsync(context, data, GetFilePath(context, meta), ct, cancellation);
+ }
+
+ /// <summary>
+ /// Creates a new content item in the store with no content for a given post id
+ /// </summary>
+ /// <param name="context">The channel context to create the item for</param>
+ /// <param name="postId">The id of the post to create content for</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that represents the async create operation</returns>
+ public async Task CreateNewPostContent(IChannelContext context, string postId, CancellationToken cancellation)
+ {
+ //Create the content meta for the post as an empty html file
+ ContentMeta meta = new()
+ {
+ ContentType = HttpHelpers.GetContentTypeString(ContentType.Html),
+ Date = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
+ FileName = $"Content for post {postId}",
+ Id = postId,
+ Length = 0,
+ FilePath = GetFileNameFromType(postId, ContentType.Html),
+ };
+
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await GetContentIndex(context, cancellation);
+
+ //Add the content meta to the store
+ contentIndex.SetRecord(meta);
+
+ //try to update the index before writing content
+ await StoreContentIndex(context, contentIndex, cancellation);
+ }
+
+ /// <summary>
+ /// Deletes content from the store by its id
+ /// </summary>
+ /// <param name="context">The blog context to delete the item from</param>
+ /// <param name="id"></param>
+ /// <param name="cancellation"></param>
+ /// <returns></returns>
+ public async Task<bool> DeleteContentAsync(IChannelContext context, string id, CancellationToken cancellation)
+ {
+ //get the content index
+ IRecordDb<ContentMeta> contentIndex = await GetContentIndex(context, cancellation);
+
+ //Get the post meta
+ ContentMeta? meta = contentIndex.GetRecord(id);
+
+ //Delete content before deleting the meta
+ if (meta?.Id == null)
+ {
+ return false;
+ }
+
+ //Remove the content meta from the store
+ contentIndex.RemoveRecord(id);
+
+ //Remove the content from storage first
+ await Storage.RemoveObjectAsync(context, GetFilePath(context, meta), cancellation);
+
+ //Overwrite the content index
+ await StoreContentIndex(context, contentIndex, cancellation);
+
+ return true;
+ }
+
+ /// <summary>
+ /// Gets the external path for the given item id.
+ /// </summary>
+ /// <param name="context">The context the item resides in</param>
+ /// <param name="id">The id of the item to get the path for</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>The external path of the item, or null if the item does not exist</returns>
+ public async Task<string?> GetExternalPathForItemAsync(IChannelContext context, string metaId, CancellationToken cancellation)
+ {
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await Storage.LoadDbAsync<ContentMeta>(context, ContentIndex, cancellation);
+
+ //Get the content meta
+ ContentMeta? meta = contentIndex.GetRecord(metaId);
+
+ //Read the content
+ if (meta?.Id == null)
+ {
+ return null;
+ }
+
+ //Get the full item path
+ return Storage.GetExternalFilePath(context, GetFilePath(context, meta));
+ }
+
+
+ private async Task<IRecordDb<ContentMeta>> GetContentIndex(IChannelContext context, CancellationToken cancellation)
+ {
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await Storage.LoadDbAsync<ContentMeta>(context, ContentIndex, cancellation);
+
+ //Return the content index
+ return contentIndex;
+ }
+
+ private async Task StoreContentIndex(IChannelContext channel, IRecordDb<ContentMeta> contentIndex, CancellationToken cancellation)
+ {
+ //Store the content index
+ await Storage.StoreAsync(channel, ContentIndex, contentIndex, cancellation);
+ }
+
+
+ private static string GetFilePath(IChannelContext context, ContentMeta meta)
+ {
+ return $"{context.ContentDir}/{meta.FilePath}";
+ }
+
+ private static string GetFileNameFromType(string fileId, ContentType type)
+ {
+ //Create file path from its id and file extension
+ return type switch
+ {
+ ContentType.Javascript => $"{fileId}.js",
+ _ => $"{fileId}.{type.ToString().ToLowerInvariant()}",
+ };
+ }
+ }
+}
diff --git a/back-end/src/Model/ContentMeta.cs b/back-end/src/Model/ContentMeta.cs
new file mode 100644
index 0000000..ee35d3f
--- /dev/null
+++ b/back-end/src/Model/ContentMeta.cs
@@ -0,0 +1,68 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: ContentMeta.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Text.Json.Serialization;
+
+using FluentValidation;
+
+using VNLib.Plugins.Extensions.Validation;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal class ContentMeta : IRecord
+ {
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("date")]
+ public long Date { get; set; }
+
+ [JsonPropertyName("name")]
+ public string? FileName { get; set; }
+
+ [JsonPropertyName("content_type")]
+ public string? ContentType { get; set; }
+
+ [JsonPropertyName("length")]
+ public long Length { get; set; }
+
+ [JsonPropertyName("path")]
+ public string? FilePath { get; set; }
+
+ public static IValidator<ContentMeta> GetValidator()
+ {
+ InlineValidator<ContentMeta> validationRules = new ();
+
+ validationRules.RuleFor(meta => meta.Id)
+ .NotEmpty()
+ .MaximumLength(64);
+
+ //Check filename
+ validationRules.RuleFor(meta => meta.FileName)
+ .NotEmpty()
+ .MaximumLength(200)
+ .IllegalCharacters();
+
+ return validationRules;
+ }
+ }
+}
diff --git a/back-end/src/Model/ExtendedProperty.cs b/back-end/src/Model/ExtendedProperty.cs
new file mode 100644
index 0000000..83f245b
--- /dev/null
+++ b/back-end/src/Model/ExtendedProperty.cs
@@ -0,0 +1,44 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: ExtendedProperty.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal class ExtendedProperty
+ {
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("attributes")]
+ public Dictionary<string, string>? Attributes { get; set; }
+
+ [JsonPropertyName("value")]
+ public string? Value { get; set; }
+
+ [JsonPropertyName("namespace")]
+ public string? NameSpace { get; set; }
+
+ [JsonPropertyName("properties")]
+ public ExtendedProperty[]? Children { get; set; }
+ }
+}
diff --git a/back-end/src/Model/FeedMeta.cs b/back-end/src/Model/FeedMeta.cs
new file mode 100644
index 0000000..7cd788a
--- /dev/null
+++ b/back-end/src/Model/FeedMeta.cs
@@ -0,0 +1,109 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: FeedMeta.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
+
+using FluentValidation;
+
+using VNLib.Plugins.Extensions.Validation;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal class FeedMeta : IBlogFeedContext
+ {
+ private static readonly Regex FileNameRegex = new(@"^[a-zA-Z0-9_.\-]+$", RegexOptions.Compiled);
+ private static readonly Regex HttpUrlRegex = new(@"^https?://", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ [JsonPropertyName("url")]
+ public string PublihUrl { get; set; } = string.Empty;
+
+ [JsonPropertyName("path")]
+ public string FeedPath { get; set; } = string.Empty;
+
+ [JsonPropertyName("image")]
+ public string ImageUrl { get; set; } = string.Empty;
+
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ [JsonPropertyName("maxItems")]
+ public int? MaxItems { get; set; }
+
+ [JsonPropertyName("author")]
+ public string? Author { get; set; }
+
+ [JsonPropertyName("contact")]
+ public string? WebMaster { get; set; }
+
+ [JsonPropertyName("properties")]
+ public ExtendedProperty[]? ExtendedProperties { get; set; }
+
+
+ public static IValidator<FeedMeta> GetValidator()
+ {
+ InlineValidator<FeedMeta> validator = new();
+
+ validator.RuleFor(x => x.PublihUrl)
+ .NotEmpty()
+ .MaximumLength(200)
+ .Matches(HttpUrlRegex)
+ .WithMessage("Your feed url is not a valid http url");
+
+ validator.RuleFor(x => x.FeedPath)
+ .NotEmpty()
+ .MaximumLength(200)
+ .Must(static p => !p.StartsWith('/') && !p.StartsWith('\\'))
+ .WithMessage("The feed file path must not contain a leading slash")
+ .Matches(FileNameRegex)
+ .WithMessage("The feed file name is not valid");
+
+ validator.RuleFor(x => x.ImageUrl)
+ .MaximumLength(200)
+ .Must(static x => string.IsNullOrWhiteSpace(x) || HttpUrlRegex.IsMatch(x))
+ .WithMessage("The image url is not a valid http url");
+
+ validator.RuleFor(x => x.Description!)
+ .NotEmpty()
+ .IllegalCharacters()
+ .MaximumLength(200);
+
+ //Cap max items at 100
+ validator.RuleFor(x => x.MaxItems)
+ .InclusiveBetween(1, 100);
+
+ validator.RuleFor(x => x.Author!)
+ .SpecialCharacters()
+ .MaximumLength(200);
+
+ validator.RuleFor(x => x.WebMaster!)
+ .IllegalCharacters()
+ .MaximumLength(200);
+
+ //Make sure the keys for each propery are valid
+ validator.RuleForEach(x => x.ExtendedProperties)
+ .ChildRules(r => r.RuleFor(k => k.Name!).IllegalCharacters())
+ .When(x => x.ExtendedProperties != null);
+
+ return validator;
+ }
+ }
+}
diff --git a/back-end/src/Model/IBlogFeedContext.cs b/back-end/src/Model/IBlogFeedContext.cs
new file mode 100644
index 0000000..9a51429
--- /dev/null
+++ b/back-end/src/Model/IBlogFeedContext.cs
@@ -0,0 +1,34 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: IBlogFeedContext.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal interface IBlogFeedContext
+ {
+ string PublihUrl { get; }
+ string FeedPath { get; }
+ string? ImageUrl { get; }
+ int? MaxItems { get; }
+ string? WebMaster { get; }
+ string? Author { get; }
+ string? Description { get; }
+ }
+}
diff --git a/back-end/src/Model/IChannelContext.cs b/back-end/src/Model/IChannelContext.cs
new file mode 100644
index 0000000..f96858d
--- /dev/null
+++ b/back-end/src/Model/IChannelContext.cs
@@ -0,0 +1,36 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: IChannelContext.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal interface IChannelContext : IRecord
+ {
+ string BlogName { get; }
+
+ string BaseDir { get; }
+
+ string IndexPath { get; }
+
+ string ContentDir { get; }
+
+ FeedMeta? Feed { get; }
+ }
+}
diff --git a/back-end/src/Model/IRecord.cs b/back-end/src/Model/IRecord.cs
new file mode 100644
index 0000000..1a6fd11
--- /dev/null
+++ b/back-end/src/Model/IRecord.cs
@@ -0,0 +1,40 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: IRecord.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ /// <summary>
+ /// A simple record to store in a <see cref="IRecordDb{T}"/>
+ /// </summary>
+ internal interface IRecord
+ {
+ /// <summary>
+ /// The record id
+ /// </summary>
+ string? Id { get; set; }
+
+ /// <summary>
+ /// The date the record was last modified
+ /// </summary>
+ long Date { get; set; }
+ }
+}
diff --git a/back-end/src/Model/IRecordDb.cs b/back-end/src/Model/IRecordDb.cs
new file mode 100644
index 0000000..ccab318
--- /dev/null
+++ b/back-end/src/Model/IRecordDb.cs
@@ -0,0 +1,71 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: IRecordDb.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+
+using System.IO;
+using System.Collections.Generic;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ /// <summary>
+ /// Represents a simple primary-key record based database
+ /// </summary>
+ /// <typeparam name="T">The record type</typeparam>
+ internal interface IRecordDb<T>
+ {
+ /// <summary>
+ /// Sets a record in the database. Adds or overwrites the entire record if it already exists.
+ /// </summary>
+ /// <param name="record">The record to set</param>
+ void SetRecord(T record);
+
+ /// <summary>
+ /// Removes a record from the database by its id
+ /// </summary>
+ /// <param name="id">The id of the record to delete</param>
+ void RemoveRecord(string id);
+
+ /// <summary>
+ /// Gets a record from the database by its id
+ /// </summary>
+ /// <param name="id">The id of the item to get</param>
+ /// <returns>The item if found, null otherwise</returns>
+ T? GetRecord(string id);
+
+ /// <summary>
+ /// Gets all records in the database
+ /// </summary>
+ /// <returns>A enumeration of the current collection of records</returns>
+ IEnumerable<T> GetRecords();
+
+ /// <summary>
+ /// Writes the entire state of the current store to the given stream
+ /// </summary>
+ /// <param name="stream">The stream to write the state data to</param>
+ void Store(Stream stream);
+
+ /// <summary>
+ /// Loads the entire state of the store from the given stream
+ /// </summary>
+ /// <param name="stream">The stream to read the state from</param>
+ void Load(Stream stream);
+ }
+}
diff --git a/back-end/src/Model/JsonRecordDb.cs b/back-end/src/Model/JsonRecordDb.cs
new file mode 100644
index 0000000..e7f2884
--- /dev/null
+++ b/back-end/src/Model/JsonRecordDb.cs
@@ -0,0 +1,167 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: JsonRecordDb.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Collections.Generic;
+
+using VNLib.Utils.IO;
+using VNLib.Utils.Extensions;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ /// <summary>
+ /// A json backed record database
+ /// </summary>
+ /// <typeparam name="T">The record type</typeparam>
+ internal class JsonRecordDb<T> : IRecordDb<T> where T : IRecord
+ {
+ private static readonly Version CurrentVersion = new (0, 1, 0);
+
+
+ private DateTimeOffset _lastModified;
+ private Version? _version;
+
+ /*
+ * Records in the list are only ever read from, any changes are made by
+ * creating a new list and re-ordering it.
+ *
+ * We dont need any synchronization in this case
+ */
+ private IReadOnlyList<T> _records;
+
+ public JsonRecordDb()
+ {
+ _lastModified = DateTimeOffset.UnixEpoch;
+ _records = new List<T>();
+ }
+
+ ///<inheritdoc/>
+ public T? GetRecord(string id)
+ {
+ return _records.SingleOrDefault(r => r.Id!.Equals(id, StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///<inheritdoc/>
+ public IEnumerable<T> GetRecords()
+ {
+ return _records;
+ }
+
+ ///<inheritdoc/>
+ public void RemoveRecord(string id)
+ {
+ _ = id ?? throw new ArgumentNullException(nameof(id));
+
+ //Create new list without the record and re-order
+ _records = _records.Where(r => !id.Equals(r.Id, StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(static r => r.Date)
+ .ToList();
+ }
+
+ ///<inheritdoc/>
+ public void SetRecord(T record)
+ {
+ _ = record?.Id ?? throw new ArgumentNullException(nameof(record));
+
+ //Remove record if it already exists
+ RemoveRecord(record.Id);
+
+ //Add the record and re-order
+ _records = _records.Append(record)
+ .OrderByDescending(static r => r.Date)
+ .ToList();
+
+ //Update last modified time
+ _lastModified = DateTimeOffset.UtcNow;
+ }
+
+ ///<inheritdoc/>
+ public void Load(Stream stream)
+ {
+ if (stream.Length == 0)
+ {
+ //Set defaults
+ _lastModified = DateTimeOffset.UnixEpoch;
+ _records = new List<T>();
+ }
+ else
+ {
+ //Read stream into a doc
+ using JsonDocument doc = JsonDocument.Parse(stream);
+
+ //Read the last modified time
+ _lastModified = DateTimeOffset.FromUnixTimeSeconds(doc.RootElement.GetProperty("last_modified").GetInt64());
+
+ //Try to read the version
+ if (doc.RootElement.TryGetProperty("version", out JsonElement versionEl))
+ {
+ _ = Version.TryParse(versionEl.GetString(), out _version);
+ }
+
+ if (doc.RootElement.TryGetProperty("records", out JsonElement el))
+ {
+ //Read the records array
+ _records = el.Deserialize<List<T>>() ?? new List<T>();
+ }
+ else
+ {
+ //Set defaults
+ _records = new List<T>();
+ }
+ }
+ }
+
+ ///<inheritdoc/>
+ public void Store(Stream stream)
+ {
+ using Utf8JsonWriter writer = new(stream);
+ writer.WriteStartObject();
+
+ //Write last modified time
+ writer.WriteNumber("last_modified", _lastModified.ToUnixTimeSeconds());
+
+ //Set version if not already set
+ _version ??= CurrentVersion;
+
+ //Write version
+ writer.WriteString("version", _version.ToString());
+
+ //Write the records array
+ writer.WritePropertyName("records");
+
+ JsonSerializer.Serialize(writer, _records, VNLib.Plugins.Essentials.Statics.SR_OPTIONS);
+
+ writer.WriteEndObject();
+ }
+
+ /// <summary>
+ /// Create a new <see cref="JsonRecordDb{T}"/> of a given type
+ /// </summary>
+ /// <returns>The new record store</returns>
+ public static JsonRecordDb<T> Create()
+ {
+ return new JsonRecordDb<T>();
+ }
+ }
+}
diff --git a/back-end/src/Model/PostManager.cs b/back-end/src/Model/PostManager.cs
new file mode 100644
index 0000000..22e3682
--- /dev/null
+++ b/back-end/src/Model/PostManager.cs
@@ -0,0 +1,232 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: PostManager.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+using Minio;
+using Minio.DataModel.Tracing;
+
+using VNLib.Hashing;
+using VNLib.Utils.IO;
+using VNLib.Utils.Logging;
+using VNLib.Net.Http;
+using VNLib.Plugins;
+using VNLib.Plugins.Extensions.Loading;
+
+using Content.Publishing.Blog.Admin.Storage;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+
+ internal sealed class PostManager : IBlogPostManager
+ {
+ private readonly IStorageFacade Storage;
+ private readonly IRssFeedGenerator FeedGenerator;
+ private readonly ContentManager ContentMan;
+
+ public PostManager(PluginBase plugin)
+ {
+ //Get minio client
+ Storage = plugin.GetOrCreateSingleton<ManagedStorage>();
+
+ //Get feed generator
+ FeedGenerator = plugin.GetOrCreateSingleton<FeedGenerator>();
+
+ //Get content manager
+ ContentMan = plugin.GetOrCreateSingleton<ContentManager>();
+ }
+
+ ///<inheritdoc/>
+ public async Task<PostMeta?> GetPostAsync(IChannelContext context, string postId, CancellationToken cancellation)
+ {
+ _ = context ?? throw new ArgumentNullException(nameof(context));
+ _ = postId ?? throw new ArgumentNullException(nameof(postId));
+
+ //Read the index into memory
+ IRecordDb<PostMeta> db = await GetPostIndexAsync(context, cancellation);
+
+ //Get the post meta
+ return db.GetRecord(postId);
+ }
+
+ ///<inheritdoc/>
+ public async Task<PostMeta[]> GetPostsAsync(IChannelContext context, CancellationToken cancellation)
+ {
+ _ = context ?? throw new ArgumentNullException(nameof(context));
+
+ //Read the index into memory
+ IRecordDb<PostMeta> db = await GetPostIndexAsync(context, cancellation);
+
+ //Return post metas
+ return db.GetRecords().ToArray();
+ }
+
+ ///<inheritdoc/>
+ public async Task PublishPostAsync(IChannelContext context, PostMeta post, CancellationToken cancellation)
+ {
+ _ = context ?? throw new ArgumentNullException(nameof(context));
+ _ = post ?? throw new ArgumentNullException(nameof(post));
+
+ //Read the index into memory
+ IRecordDb<PostMeta> db = await GetPostIndexAsync(context, cancellation);
+
+ //Update index modifed time and post date
+ post.Date = post.Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ //Compute post id before publishing to storage
+ ComputePostId(post);
+
+ //Add post to the index
+ db.SetRecord(post);
+
+ //Update the index
+ await SetPostIndexAsync(context, db, cancellation);
+
+ //Create empty post content
+ await ContentMan.CreateNewPostContent(context, post.Id!, cancellation);
+ }
+
+ ///<inheritdoc/>
+ public async Task DeletePostAsync(IChannelContext context, string postId, CancellationToken cancellation)
+ {
+ _ = context ?? throw new ArgumentNullException(nameof(context));
+ _ = postId ?? throw new ArgumentNullException(nameof(postId));
+
+ //Get the index
+ IRecordDb<PostMeta> db = await GetPostIndexAsync(context, cancellation);
+
+ //Remove the post from the index if it exists
+ PostMeta? post = db.GetRecord(postId);
+
+ if (post == null)
+ {
+ return;
+ }
+
+ db.RemoveRecord(postId);
+
+ //Remove post content before flushing db changes
+ await ContentMan.DeleteContentAsync(context, postId, cancellation);
+
+ //update feed after post deletion
+ await UpdateIndexAndFeed(context, db, cancellation);
+ }
+
+ ///<inheritdoc/>
+ public async Task<bool> UpdatePostAsync(IChannelContext context, PostMeta post, CancellationToken cancellation)
+ {
+ _ = context ?? throw new ArgumentNullException(nameof(context));
+ _ = post?.Id ?? throw new ArgumentNullException(nameof(post));
+
+ //Get the index
+ IRecordDb<PostMeta> db = await GetPostIndexAsync(context, cancellation);
+
+ //Try to get the post by its id
+ PostMeta? oldMeta = db.GetRecord(post.Id);
+
+ if (oldMeta == null)
+ {
+ return false;
+ }
+
+ //Update modified time
+ post.Date = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ //Save old time
+ post.Created = oldMeta.Created;
+
+ //Remove the old post meta
+ db.SetRecord(post);
+
+ //Update the index and feed after post update
+ await UpdateIndexAndFeed(context, db, cancellation);
+
+ return true;
+ }
+
+
+ private async Task UpdateIndexAndFeed(IChannelContext context, IRecordDb<PostMeta> index, CancellationToken cancellation)
+ {
+ //Write the index back to the bucket
+ await Storage.StoreAsync(context, context.IndexPath, index, cancellation);
+
+ //Update feed
+ if (context.Feed != null)
+ {
+ await UpdateRssFeed(context, index.GetRecords(), cancellation);
+ }
+ }
+
+ private async Task UpdateRssFeed(IChannelContext context, IEnumerable<PostMeta> meta, CancellationToken cancellation)
+ {
+ using VnMemoryStream feedData = new();
+
+ //Build the feed from posts
+ FeedGenerator.BuildFeed(context, meta, feedData);
+
+ //Rewind the feed stream
+ feedData.Seek(0, System.IO.SeekOrigin.Begin);
+
+ //Write the feed to the bucket
+ await Storage.SetObjectDataAsync(context, feedData, context.Feed!.FeedPath, ContentType.Rss, cancellation);
+ }
+
+ #region Load/Store Db
+
+ private Task<IRecordDb<PostMeta>> GetPostIndexAsync(IChannelContext channel, CancellationToken cancellation)
+ {
+ //Read the index into memory
+ return Storage.LoadDbAsync<PostMeta>(channel, channel.IndexPath, cancellation);
+ }
+
+ private Task SetPostIndexAsync(IChannelContext channel, IRecordDb<PostMeta> db, CancellationToken cancellation)
+ {
+ //Read the index into memory
+ return Storage.StoreAsync(channel, channel.IndexPath, db, cancellation);
+ }
+
+ #endregion
+
+ /*
+ * Computes a post id based on its meta information and produces a sha1 hash
+ * to use as a unique id for the post
+ */
+ static void ComputePostId(PostMeta post)
+ {
+ post.Id = ManagedHash.ComputeHexHash($"{post.Title}.{post.Author}.{post.Summary}.{post.Date}", HashAlg.SHA1).ToLowerInvariant();
+ }
+
+ internal record class ReqLogger(ILogProvider Log) : IRequestLogger
+ {
+ public void LogRequest(RequestToLog requestToLog, ResponseToLog responseToLog, double durationMs)
+ {
+ Log.Debug("S3 result\n{method} {uri} HTTP {ms}ms\nHTTP {status} {message}\n{content}",
+ requestToLog.Method, requestToLog.Resource, durationMs,
+ responseToLog.StatusCode, responseToLog.ErrorMessage, responseToLog.Content
+ );
+ }
+ }
+ }
+}
diff --git a/back-end/src/Model/PostMeta.cs b/back-end/src/Model/PostMeta.cs
new file mode 100644
index 0000000..2bb963e
--- /dev/null
+++ b/back-end/src/Model/PostMeta.cs
@@ -0,0 +1,55 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: PostMeta.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal class PostMeta : IRecord
+ {
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ [JsonPropertyName("date")]
+ public long Date { get; set; }
+
+ [JsonPropertyName("created")]
+ public long Created { get; set; }
+
+ [JsonPropertyName("author")]
+ public string? Author { get; set; }
+
+ [JsonPropertyName("summary")]
+ public string? Summary { get; set; }
+
+ [JsonPropertyName("tags")]
+ public string[]? Tags { get; set; }
+
+ [JsonPropertyName("image")]
+ public string? Image { get; set; }
+
+ [JsonPropertyName("properties")]
+ public ExtendedProperty[]? ExtendedProperties { get; set; }
+ }
+}