From f64955c69d91e578e580b409ba31ac4b3477da96 Mon Sep 17 00:00:00 2001 From: vnugent Date: Wed, 12 Jul 2023 01:28:23 -0400 Subject: Initial commit --- back-end/src/Model/BlogChannel.cs | 54 ++++++ back-end/src/Model/BlogPost.cs | 55 ++++++ back-end/src/Model/ChannelManager.cs | 156 +++++++++++++++++ back-end/src/Model/ContentManager.cs | 300 +++++++++++++++++++++++++++++++++ back-end/src/Model/ContentMeta.cs | 68 ++++++++ back-end/src/Model/ExtendedProperty.cs | 44 +++++ back-end/src/Model/FeedMeta.cs | 109 ++++++++++++ back-end/src/Model/IBlogFeedContext.cs | 34 ++++ back-end/src/Model/IChannelContext.cs | 36 ++++ back-end/src/Model/IRecord.cs | 40 +++++ back-end/src/Model/IRecordDb.cs | 71 ++++++++ back-end/src/Model/JsonRecordDb.cs | 167 ++++++++++++++++++ back-end/src/Model/PostManager.cs | 232 +++++++++++++++++++++++++ back-end/src/Model/PostMeta.cs | 55 ++++++ 14 files changed, 1421 insertions(+) create mode 100644 back-end/src/Model/BlogChannel.cs create mode 100644 back-end/src/Model/BlogPost.cs create mode 100644 back-end/src/Model/ChannelManager.cs create mode 100644 back-end/src/Model/ContentManager.cs create mode 100644 back-end/src/Model/ContentMeta.cs create mode 100644 back-end/src/Model/ExtendedProperty.cs create mode 100644 back-end/src/Model/FeedMeta.cs create mode 100644 back-end/src/Model/IBlogFeedContext.cs create mode 100644 back-end/src/Model/IChannelContext.cs create mode 100644 back-end/src/Model/IRecord.cs create mode 100644 back-end/src/Model/IRecordDb.cs create mode 100644 back-end/src/Model/JsonRecordDb.cs create mode 100644 back-end/src/Model/PostManager.cs create mode 100644 back-end/src/Model/PostMeta.cs (limited to 'back-end/src/Model') 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 GetValidator() + { + InlineValidator 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(); + + _indexPath = config["index_file_name"].GetString() ?? "channels.json"; + } + + /// + public async Task CreateChannelAsync(BlogChannel context, CancellationToken cancellation) + { + _ = context.Id ?? throw new ArgumentNullException(nameof(context.Id)); + + IRecordDb 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; + } + + /// + public async Task UpdateChannelAsync(BlogChannel channel, CancellationToken cancellation) + { + _ = channel.Id ?? throw new ArgumentNullException(nameof(channel.Id)); + + IRecordDb 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; + } + + /// + public async Task DeleteChannelAsync(IChannelContext context, CancellationToken cancellation) + { + _ = context?.Id ?? throw new ArgumentNullException(nameof(context)); + + IRecordDb 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); + } + + /// + /// Computes the unique id for a context + /// + /// The context to produce the context id for + /// The unique context id + 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(); + } + + /// + public async Task GetChannelAsync(string id, CancellationToken cancellation) + { + //Recover the db + IRecordDb db = await LoadDb(cancellation); + + //Get the channel + return db.GetRecord(id); + } + + /// + public async Task GetAllContextsAsync(CancellationToken cancellation) + { + //Recover the db + IRecordDb db = await LoadDb(cancellation); + + //Get the channel + return db.GetRecords().ToArray(); + } + + + private Task> LoadDb(CancellationToken cancellation) + { + return Storage.LoadDbAsync(_indexPath, cancellation); + } + + private Task StoreDb(IRecordDb 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(); + } + + /// + /// Gets the content meta object for the given content item by its id + /// + /// The channel that contains the desired content + /// The id of the object + /// A token to cancel the operation + /// The content meta item if found in the store + public async Task GetMetaAsync(IChannelContext channel, string metaId, CancellationToken cancellation) + { + //Get the content index + IRecordDb contentIndex = await Storage.LoadDbAsync(channel, ContentIndex, cancellation); + + //Get the content meta + return contentIndex.GetRecord(metaId); + } + + /// + /// Overwrites the content index with the given content index + /// + /// The channel to set the content for + /// The contne meta to update + /// A token to cancel the operation + /// A task that completes when the operation has completed + /// + public async Task SetMetaAsync(IChannelContext channel, ContentMeta meta, CancellationToken cancellation) + { + _ = meta.Id ?? throw new ArgumentNullException(nameof(meta)); + + //Get the content index + IRecordDb contentIndex = await Storage.LoadDbAsync(channel, ContentIndex, cancellation); + + //Set the content meta + contentIndex.SetRecord(meta); + + //Save the content index + await StoreContentIndex(channel, contentIndex, cancellation); + } + + /// + /// Initializes a new content meta object for a new content item + /// + /// The length of the new content item + /// An initializes ready for a new content item + 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) + }; + } + + /// + /// Gets all content items in the given channel + /// + /// The channel to get content items for + /// A token to cancel the operation + /// The collection of content items for the channel + public async Task GetAllContentItemsAsync(IChannelContext context, CancellationToken cancellation) + { + //Get the content index + IRecordDb contentIndex = await Storage.LoadDbAsync(context, ContentIndex, cancellation); + + //Return all content items + return contentIndex.GetRecords().ToArray(); + } + + /// + /// Reads content from the store and writes it to the output stream + /// + /// The channel that contains the content + /// The id of the content item to read + /// The stream to write the file data to + /// A token to cancel the operation + /// The meta object that contains the content metadata if found, null if the content was not found in the directory + public async Task GetContentAsync(IChannelContext context, string metaId, Stream output, CancellationToken cancellation) + { + //Get the content index + IRecordDb contentIndex = await Storage.LoadDbAsync(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; + } + + /// + /// Adds content to the store + /// + /// The blog channel to store the data in + /// The content meta of the data to store + /// The data stream to store + /// The content type of the data to store + /// + /// A task that complets when the content has been added to the store + 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 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); + } + + /// + /// Creates a new content item in the store with no content for a given post id + /// + /// The channel context to create the item for + /// The id of the post to create content for + /// A token to cancel the operation + /// A task that represents the async create operation + 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 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); + } + + /// + /// Deletes content from the store by its id + /// + /// The blog context to delete the item from + /// + /// + /// + public async Task DeleteContentAsync(IChannelContext context, string id, CancellationToken cancellation) + { + //get the content index + IRecordDb 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; + } + + /// + /// Gets the external path for the given item id. + /// + /// The context the item resides in + /// The id of the item to get the path for + /// A token to cancel the operation + /// The external path of the item, or null if the item does not exist + public async Task GetExternalPathForItemAsync(IChannelContext context, string metaId, CancellationToken cancellation) + { + //Get the content index + IRecordDb contentIndex = await Storage.LoadDbAsync(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> GetContentIndex(IChannelContext context, CancellationToken cancellation) + { + //Get the content index + IRecordDb contentIndex = await Storage.LoadDbAsync(context, ContentIndex, cancellation); + + //Return the content index + return contentIndex; + } + + private async Task StoreContentIndex(IChannelContext channel, IRecordDb 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 GetValidator() + { + InlineValidator 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? 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 GetValidator() + { + InlineValidator 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 +{ + /// + /// A simple record to store in a + /// + internal interface IRecord + { + /// + /// The record id + /// + string? Id { get; set; } + + /// + /// The date the record was last modified + /// + 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 +{ + /// + /// Represents a simple primary-key record based database + /// + /// The record type + internal interface IRecordDb + { + /// + /// Sets a record in the database. Adds or overwrites the entire record if it already exists. + /// + /// The record to set + void SetRecord(T record); + + /// + /// Removes a record from the database by its id + /// + /// The id of the record to delete + void RemoveRecord(string id); + + /// + /// Gets a record from the database by its id + /// + /// The id of the item to get + /// The item if found, null otherwise + T? GetRecord(string id); + + /// + /// Gets all records in the database + /// + /// A enumeration of the current collection of records + IEnumerable GetRecords(); + + /// + /// Writes the entire state of the current store to the given stream + /// + /// The stream to write the state data to + void Store(Stream stream); + + /// + /// Loads the entire state of the store from the given stream + /// + /// The stream to read the state from + 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 +{ + /// + /// A json backed record database + /// + /// The record type + internal class JsonRecordDb : IRecordDb 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 _records; + + public JsonRecordDb() + { + _lastModified = DateTimeOffset.UnixEpoch; + _records = new List(); + } + + /// + public T? GetRecord(string id) + { + return _records.SingleOrDefault(r => r.Id!.Equals(id, StringComparison.OrdinalIgnoreCase)); + } + + /// + public IEnumerable GetRecords() + { + return _records; + } + + /// + 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(); + } + + /// + 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; + } + + /// + public void Load(Stream stream) + { + if (stream.Length == 0) + { + //Set defaults + _lastModified = DateTimeOffset.UnixEpoch; + _records = new List(); + } + 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>() ?? new List(); + } + else + { + //Set defaults + _records = new List(); + } + } + } + + /// + 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(); + } + + /// + /// Create a new of a given type + /// + /// The new record store + public static JsonRecordDb Create() + { + return new JsonRecordDb(); + } + } +} 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(); + + //Get feed generator + FeedGenerator = plugin.GetOrCreateSingleton(); + + //Get content manager + ContentMan = plugin.GetOrCreateSingleton(); + } + + /// + public async Task 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 db = await GetPostIndexAsync(context, cancellation); + + //Get the post meta + return db.GetRecord(postId); + } + + /// + public async Task GetPostsAsync(IChannelContext context, CancellationToken cancellation) + { + _ = context ?? throw new ArgumentNullException(nameof(context)); + + //Read the index into memory + IRecordDb db = await GetPostIndexAsync(context, cancellation); + + //Return post metas + return db.GetRecords().ToArray(); + } + + /// + 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 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); + } + + /// + 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 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); + } + + /// + public async Task UpdatePostAsync(IChannelContext context, PostMeta post, CancellationToken cancellation) + { + _ = context ?? throw new ArgumentNullException(nameof(context)); + _ = post?.Id ?? throw new ArgumentNullException(nameof(post)); + + //Get the index + IRecordDb 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 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 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> GetPostIndexAsync(IChannelContext channel, CancellationToken cancellation) + { + //Read the index into memory + return Storage.LoadDbAsync(channel, channel.IndexPath, cancellation); + } + + private Task SetPostIndexAsync(IChannelContext channel, IRecordDb 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; } + } +} -- cgit