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/README.md | 1 + back-end/src/CMNextEntry.cs | 61 ++++ back-end/src/Content.Publishing.Blog.Admin.csproj | 50 +++ back-end/src/Endpoints/ChannelEndpoint.cs | 257 +++++++++++++++ back-end/src/Endpoints/ContentEndpoint.cs | 360 ++++++++++++++++++++++ back-end/src/Endpoints/PostsEndpoint.cs | 271 ++++++++++++++++ back-end/src/FeedGenerator.cs | 210 +++++++++++++ back-end/src/IBlogPostManager.cs | 41 +++ back-end/src/IChannelContextManager.cs | 68 ++++ back-end/src/IRssFeedGenerator.cs | 43 +++ 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 ++++ back-end/src/Storage/FtpStorageManager.cs | 136 ++++++++ back-end/src/Storage/IStorageFacade.cs | 71 +++++ back-end/src/Storage/ManagedStorage.cs | 96 ++++++ back-end/src/Storage/MinioClientManager.cs | 135 ++++++++ back-end/src/Storage/StorageBase.cs | 57 ++++ back-end/src/StorageExtensions.cs | 181 +++++++++++ 30 files changed, 3459 insertions(+) create mode 100644 back-end/README.md create mode 100644 back-end/src/CMNextEntry.cs create mode 100644 back-end/src/Content.Publishing.Blog.Admin.csproj create mode 100644 back-end/src/Endpoints/ChannelEndpoint.cs create mode 100644 back-end/src/Endpoints/ContentEndpoint.cs create mode 100644 back-end/src/Endpoints/PostsEndpoint.cs create mode 100644 back-end/src/FeedGenerator.cs create mode 100644 back-end/src/IBlogPostManager.cs create mode 100644 back-end/src/IChannelContextManager.cs create mode 100644 back-end/src/IRssFeedGenerator.cs 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 create mode 100644 back-end/src/Storage/FtpStorageManager.cs create mode 100644 back-end/src/Storage/IStorageFacade.cs create mode 100644 back-end/src/Storage/ManagedStorage.cs create mode 100644 back-end/src/Storage/MinioClientManager.cs create mode 100644 back-end/src/Storage/StorageBase.cs create mode 100644 back-end/src/StorageExtensions.cs (limited to 'back-end') diff --git a/back-end/README.md b/back-end/README.md new file mode 100644 index 0000000..53830f9 --- /dev/null +++ b/back-end/README.md @@ -0,0 +1 @@ +# CMNext/Back-End \ No newline at end of file diff --git a/back-end/src/CMNextEntry.cs b/back-end/src/CMNextEntry.cs new file mode 100644 index 0000000..2382b33 --- /dev/null +++ b/back-end/src/CMNextEntry.cs @@ -0,0 +1,61 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: CMNextEntry.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 VNLib.Plugins; +using VNLib.Utils.Logging; +using VNLib.Plugins.Extensions.Loading.Routing; + +using Content.Publishing.Blog.Admin.Endpoints; + +namespace Content.Publishing.Blog.Admin +{ + + public sealed class CMNextEntry : PluginBase + { + public override string PluginName { get; } = "CMNext.Admin"; + + protected override void OnLoad() + { + //Route blog endpoints + this.Route(); + + //Route posts endpoint + this.Route(); + + //Route content endpoint + this.Route(); + + Log.Information("Plugin loaded"); + } + + protected override void OnUnLoad() + { + Log.Information("Plugin unloaded"); + } + + protected override void ProcessHostCommand(string cmd) + { + throw new NotImplementedException(); + } + } +} \ No newline at end of file diff --git a/back-end/src/Content.Publishing.Blog.Admin.csproj b/back-end/src/Content.Publishing.Blog.Admin.csproj new file mode 100644 index 0000000..df7f780 --- /dev/null +++ b/back-end/src/Content.Publishing.Blog.Admin.csproj @@ -0,0 +1,50 @@ + + + + net6.0 + enable + true + README.md + Content.Publishing.Blog.Admin + CMNext + + + + Vaughn Nugent + Vaughn Nugent + CMNext.Admin + A VNLib.Plugins.Essentials administration plugin for the CMNext content publishing platform. + Copyright © 2023 Vaughn Nugent + https://www.vaughnnugent.com/resources/software/modules/CMNext.Admin + https://github.com/VnUgE/CMNext/tree/master/ + + + + + True + \ + + + + + + + + + + + + + Always + + + + + + + + + + + + diff --git a/back-end/src/Endpoints/ChannelEndpoint.cs b/back-end/src/Endpoints/ChannelEndpoint.cs new file mode 100644 index 0000000..c63d093 --- /dev/null +++ b/back-end/src/Endpoints/ChannelEndpoint.cs @@ -0,0 +1,257 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: ChannelEndpoint.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.Net; +using System.Threading.Tasks; +using System.Text.Json.Serialization; +using System.Text.RegularExpressions; + +using FluentValidation; + +using VNLib.Plugins; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Validation; + +using Content.Publishing.Blog.Admin.Model; + +namespace Content.Publishing.Blog.Admin.Endpoints +{ + [ConfigurationName("channel_endpoint")] + internal sealed class ChannelEndpoint : ProtectedWebEndpoint + { + private static readonly IValidator ChannelValidator = ChannelRequest.GetValidator(); + private static readonly IValidator FeedValidator = FeedMeta.GetValidator(); + + private readonly IChannelContextManager ContentManager; + + + public ChannelEndpoint(PluginBase plugin, IConfigScope config) + { + string? path = config["path"].GetString(); + + InitPathAndLog(path, plugin.Log); + + ContentManager = plugin.GetOrCreateSingleton(); + } + + protected override async ValueTask GetAsync(HttpEntity entity) + { + //Check user read-permissions + if (!entity.Session.CanRead()) + { + return VfReturnType.Forbidden; + } + + //Get the blog context list + object[] contexts = await ContentManager.GetAllContextsAsync(entity.EventCancellation); + + //Return the list to the client + entity.CloseResponseJson(HttpStatusCode.OK, contexts); + return VfReturnType.VirtualSkip; + } + + protected override async ValueTask PostAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + //Check user write-permissions + if (webm.Assert(entity.Session.CanWrite() == true, "You do not have permission to add channels")) + { + entity.CloseResponseJson(HttpStatusCode.Forbidden, webm); + return VfReturnType.VirtualSkip; + } + + //Get the blog context from the request body + ChannelRequest? channel = await entity.GetJsonFromFileAsync(); + + if (webm.Assert(channel != null, "You must specify a new blog channel")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Validate the blog context + if (!ChannelValidator.Validate(channel, webm)) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Validate the feed if its defined + if (channel.Feed != null && !FeedValidator.Validate(channel.Feed, webm)) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Add the blog context to the manager + bool result = await ContentManager.CreateChannelAsync(channel, entity.EventCancellation); + + if (webm.Assert(result, "A blog with the given name already exists")) + { + entity.CloseResponseJson(HttpStatusCode.Conflict, webm); + return VfReturnType.VirtualSkip; + } + + //Return the new blog context to the client + entity.CloseResponse(HttpStatusCode.Created); + return VfReturnType.VirtualSkip; + } + + protected override async ValueTask PatchAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + //Check user write-permissions + if (webm.Assert(entity.Session.CanWrite() == true, "You do not have permission to add channels")) + { + entity.CloseResponseJson(HttpStatusCode.Forbidden, webm); + return VfReturnType.VirtualSkip; + } + + //Get the blog context from the request body + ChannelRequest? channel = await entity.GetJsonFromFileAsync(); + + if (webm.Assert(channel?.Id != null, "You must specify a new blog channel")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Validate the blog context + if (!ChannelValidator.Validate(channel, webm)) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Validate the feed if its defined + if (channel.Feed != null && !FeedValidator.Validate(channel.Feed, webm)) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Make sure the blog context exists + IChannelContext? context = await ContentManager.GetChannelAsync(channel.Id, entity.EventCancellation); + + if (webm.Assert(context != null, "The specified blog channel does not exist")) + { + entity.CloseResponseJson(HttpStatusCode.NotFound, webm); + return VfReturnType.VirtualSkip; + } + + //Update the context + bool result = await ContentManager.UpdateChannelAsync(channel, entity.EventCancellation); + + if (webm.Assert(result, "Failed to update the channel setting")) + { + entity.CloseResponseJson(HttpStatusCode.Conflict, webm); + return VfReturnType.VirtualSkip; + } + + //Return the new blog context to the client + entity.CloseResponse(HttpStatusCode.Created); + return VfReturnType.VirtualSkip; + } + + protected override async ValueTask DeleteAsync(HttpEntity entity) + { + //Check for user write-permissions + if (!entity.Session.CanDelete()) + { + return VfReturnType.Forbidden; + } + + if (!entity.QueryArgs.TryGetNonEmptyValue("channel", out string? channelId)) + { + return VfReturnType.BadRequest; + } + + //Try to get the blog context from the id + IChannelContext? context = await ContentManager.GetChannelAsync(channelId, entity.EventCancellation); + + if (context == null) + { + return VfReturnType.NotFound; + } + + //Delete the blog context + await ContentManager.DeleteChannelAsync(context, entity.EventCancellation); + + //Return the new blog context to the client + entity.CloseResponse(HttpStatusCode.NoContent); + return VfReturnType.VirtualSkip; + } + + private sealed class ChannelRequest : BlogChannel, IJsonOnDeserialized + { + private static readonly Regex FileNameRegex = new(@"^[a-zA-Z0-9_\-.]+$", RegexOptions.Compiled); + private static readonly Regex DirectoryPathRegex = new(@"^[a-zA-Z0-9_\-/]+$", RegexOptions.Compiled); + + public static IValidator GetValidator() + { + InlineValidator validationRules = new (); + + validationRules.RuleFor(x => x.BlogName) + .NotEmpty() + .AlphaNumericOnly() + .MaximumLength(64); + + validationRules.RuleFor(x => x.BaseDir) + .NotEmpty() + .MaximumLength(100) + //Must not start with a forward slash + .Must(static p => !p.StartsWith('/') && !p.StartsWith('\\')) + .WithMessage("Channel directory must not start with a forward slash") + .Matches(DirectoryPathRegex) + .WithMessage("Channel directory must be a valid directory path"); + + validationRules.RuleFor(x => x.IndexPath) + .NotEmpty() + .MaximumLength(100) + .Must(static p => !p.StartsWith('/') && !p.StartsWith('\\')) + .WithMessage("Channel catalog file must not start with a forward slash") + //Must be a file path + .Matches(FileNameRegex) + .WithMessage("Channel catalog file path is not a valid file name"); + + validationRules.RuleFor(x => x.ContentDir) + .NotEmpty() + .Must(static p => !p.StartsWith('/') && !p.StartsWith('\\')) + .WithMessage("Channel content directory must not start with a forward slash") + .MaximumLength(100); + + return validationRules; + } + + public void OnDeserialized() + { + //Compute the uniqe id of the channel + Id = ChannelManager.ComputeContextId(this); + } + } + } +} diff --git a/back-end/src/Endpoints/ContentEndpoint.cs b/back-end/src/Endpoints/ContentEndpoint.cs new file mode 100644 index 0000000..e1e1344 --- /dev/null +++ b/back-end/src/Endpoints/ContentEndpoint.cs @@ -0,0 +1,360 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: ContentEndpoint.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.Net; +using System.Threading.Tasks; + +using FluentValidation; + +using VNLib.Utils.IO; +using VNLib.Net.Http; +using VNLib.Plugins; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Validation; + +using Content.Publishing.Blog.Admin.Model; + +namespace Content.Publishing.Blog.Admin.Endpoints +{ + + [ConfigurationName("content_endpoint")] + internal sealed class ContentEndpoint : ProtectedWebEndpoint + { + private static readonly IValidator MetaValidator = ContentMeta.GetValidator(); + + private readonly ContentManager _content; + private readonly IChannelContextManager _blogContextManager; + + private readonly int MaxContentLength; + + public ContentEndpoint(PluginBase plugin, IConfigScope config) + { + string? path = config["path"].GetString(); + + InitPathAndLog(path, plugin.Log); + + //Get the max content length + MaxContentLength = (int)config["max_content_length"].GetUInt32(); + + _content = plugin.GetOrCreateSingleton(); + _blogContextManager = plugin.GetOrCreateSingleton(); + } + + + protected override async ValueTask GetAsync(HttpEntity entity) + { + if (!entity.Session.CanRead()) + { + return VfReturnType.Forbidden; + } + + //Get the channel id + if (!entity.QueryArgs.TryGetNonEmptyValue("channel", out string? channelId)) + { + entity.CloseResponse(HttpStatusCode.BadRequest); + return VfReturnType.VirtualSkip; + } + + //Get the channel + IChannelContext? channel = await _blogContextManager.GetChannelAsync(channelId, entity.EventCancellation); + + if (channel == null) + { + entity.CloseResponse(HttpStatusCode.NotFound); + return VfReturnType.VirtualSkip; + } + + //Get the content id, if not set get all content meta items + if (!entity.QueryArgs.TryGetNonEmptyValue("id", out string? contentId)) + { + //Get all content items + ContentMeta[] items = await _content.GetAllContentItemsAsync(channel, entity.EventCancellation); + + //Return the items + entity.CloseResponseJson(HttpStatusCode.OK, items); + return VfReturnType.VirtualSkip; + + } + + //See if the user wants to get a link to the content + if (entity.QueryArgs.IsArgumentSet("getlink", "true")) + { + WebMessage webm = new() + { + //Get the content link + Result = await _content.GetExternalPathForItemAsync(channel, contentId, entity.EventCancellation) + }; + + //Set success if the link exists + webm.Success = webm.Result != null; + webm.Result ??= "The requested content item was not found in the database"; + + //Return the link + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + else + { + //Get content for single item + VnMemoryStream vms = new(); + try + { + //Get the content from the store + ContentMeta? meta = await _content.GetContentAsync(channel, contentId, vms, entity.EventCancellation); + + //it may not exist, cleanuup + if (meta?.ContentType == null) + { + vms.Dispose(); + entity.CloseResponse(HttpStatusCode.NotFound); + return VfReturnType.VirtualSkip; + } + else + { + //rewind the stream + vms.Seek(0, SeekOrigin.Begin); + + //Return the content + entity.CloseResponse(HttpStatusCode.OK, HttpHelpers.GetContentType(meta.ContentType), vms); + return VfReturnType.VirtualSkip; + } + } + catch + { + vms.Dispose(); + throw; + } + } + } + + /* + * Patch allows updating content meta data without having to upload the content again + */ + protected override async ValueTask PatchAsync(HttpEntity entity) + { + + //Get channel id + if (!entity.QueryArgs.TryGetNonEmptyValue("channel", out string? channelId)) + { + return VfReturnType.NotFound; + } + + ValErrWebMessage webm = new(); + + if (webm.Assert(entity.Session.CanWrite(), "You do not have permissions to update content")) + { + entity.CloseResponseJson(HttpStatusCode.Forbidden, webm); + return VfReturnType.VirtualSkip; + } + + //Make sure there is content attached + if (webm.Assert(entity.Files.Count > 0, "No content was attached to the entity body")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Get the channel + IChannelContext? channel = await _blogContextManager.GetChannelAsync(channelId, entity.EventCancellation); + + if (webm.Assert(channel != null, "The channel does not exist")) + { + entity.CloseResponseJson(HttpStatusCode.NotFound, webm); + return VfReturnType.VirtualSkip; + } + + //Read meta from request + ContentMeta? requestedMeta = await entity.GetJsonFromFileAsync(); + + if (webm.Assert(requestedMeta?.Id != null, "You must supply a content id")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Validate the meta + if (!MetaValidator.Validate(requestedMeta, webm)) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Get the original content meta + ContentMeta? meta = await _content.GetMetaAsync(channel, requestedMeta.Id, entity.EventCancellation); + if (webm.Assert(meta != null, "The requested content item does not exist")) + { + entity.CloseResponseJson(HttpStatusCode.NotFound, webm); + return VfReturnType.VirtualSkip; + } + + //Currently only allow chaning the file name + meta.FileName = requestedMeta.FileName; + + //Set the meta item + await _content.SetMetaAsync(channel, meta, entity.EventCancellation); + + //Return the updated meta + webm.Result = meta; + webm.Success = true; + + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + /* + * Put adds or updates content + */ + protected override async ValueTask PutAsync(HttpEntity entity) + { + //Get channel id + if (!entity.QueryArgs.TryGetNonEmptyValue("channel", out string? channelId)) + { + return VfReturnType.NotFound; + } + + ValErrWebMessage webm = new(); + + if (webm.Assert(entity.Session.CanWrite(), "You do not have permissions to update content")) + { + entity.CloseResponseJson(HttpStatusCode.Forbidden, webm); + return VfReturnType.VirtualSkip; + } + + //Make sure there is content attached + if (webm.Assert(entity.Files.Count > 0, "No content was attached to the entity body")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Check content length + if (webm.Assert(entity.Files[0].FileData.Length <= MaxContentLength, $"The content length is too long, max length is {MaxContentLength} bytes")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Get the first file + FileUpload file = entity.Files[0]; + + //Get the channel + IChannelContext? channel = await _blogContextManager.GetChannelAsync(channelId, entity.EventCancellation); + + if (webm.Assert(channel != null, "The channel does not exist")) + { + entity.CloseResponseJson(HttpStatusCode.NotFound, webm); + return VfReturnType.VirtualSkip; + } + + ContentMeta? meta; + + //Get the content id if its an update + if (entity.QueryArgs.TryGetNonEmptyValue("id", out string? contentId)) + { + //Get the original content meta + meta = await _content.GetMetaAsync(channel, contentId, entity.EventCancellation); + + if (webm.Assert(meta != null, "The request item does not exist")) + { + entity.CloseResponseJson(HttpStatusCode.NotFound, webm); + return VfReturnType.VirtualSkip; + } + + //May want to change the content name + meta.FileName = entity.Server.Headers["X-Content-Name"]; + } + else + { + //Get the content name, may be null + string? cName = entity.Server.Headers["X-Content-Name"]; + + //New item + meta = _content.GetNewMetaObject(file.FileData.Length, cName, file.ContentType); + } + + //Validate the meta after updating file name + if (!MetaValidator.Validate(meta, webm)) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Add or update the content + await _content.SetContentAsync(channel, meta, file.FileData, file.ContentType, entity.EventCancellation); + + //Return the meta + webm.Result = meta; + webm.Success = true; + + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + protected override async ValueTask DeleteAsync(HttpEntity entity) + { + if (!entity.Session.CanRead()) + { + return VfReturnType.Forbidden; + } + + //Get the channel id + if (!entity.QueryArgs.TryGetNonEmptyValue("channel", out string? channelId)) + { + entity.CloseResponse(HttpStatusCode.BadRequest); + return VfReturnType.VirtualSkip; + } + + //get the content id + if (!entity.QueryArgs.TryGetNonEmptyValue("id", out string? contentId)) + { + entity.CloseResponse(HttpStatusCode.BadRequest); + return VfReturnType.VirtualSkip; + } + + //Get channel + IChannelContext? channel = await _blogContextManager.GetChannelAsync(channelId, entity.EventCancellation); + if (channel == null) + { + return VfReturnType.NotFound; + } + + //Try to delete the content + bool deleted = await _content.DeleteContentAsync(channel, contentId, entity.EventCancellation); + + if (deleted) + { + entity.CloseResponse(HttpStatusCode.OK); + return VfReturnType.VirtualSkip; + } + else + { + return VfReturnType.NotFound; + } + } + } + +} diff --git a/back-end/src/Endpoints/PostsEndpoint.cs b/back-end/src/Endpoints/PostsEndpoint.cs new file mode 100644 index 0000000..fe7a310 --- /dev/null +++ b/back-end/src/Endpoints/PostsEndpoint.cs @@ -0,0 +1,271 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: PostsEndpoint.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.Net; +using System.Threading.Tasks; +using Content.Publishing.Blog.Admin.Model; + +using FluentValidation; + +using VNLib.Plugins; +using VNLib.Plugins.Essentials; +using VNLib.Plugins.Essentials.Accounts; +using VNLib.Plugins.Essentials.Endpoints; +using VNLib.Plugins.Essentials.Extensions; +using VNLib.Plugins.Extensions.Loading; +using VNLib.Plugins.Extensions.Validation; + +namespace Content.Publishing.Blog.Admin.Endpoints +{ + + [ConfigurationName("post_endpoint")] + internal sealed class PostsEndpoint : ProtectedWebEndpoint + { + private static readonly IValidator PostValidator = BlogPost.GetValidator(); + + private readonly IBlogPostManager PostManager; + private readonly IChannelContextManager ContentManager; + + public PostsEndpoint(PluginBase plugin, IConfigScope config) + { + string? path = config["path"].GetString(); + + InitPathAndLog(path, plugin.Log); + + //Get post manager and context manager + PostManager = plugin.GetOrCreateSingleton(); + ContentManager = plugin.GetOrCreateSingleton(); + } + + protected override async ValueTask GetAsync(HttpEntity entity) + { + //Check for read permissions + if (!entity.Session.CanRead()) + { + WebMessage webm = new() + { + Result = "You do not have permission to read content" + }; + entity.CloseResponseJson(HttpStatusCode.Forbidden, webm); + return VfReturnType.VirtualSkip; + } + + //Try to get the blog id from the query + if (!entity.QueryArgs.TryGetNonEmptyValue("channel", out string? contextId)) + { + entity.CloseResponse(HttpStatusCode.BadRequest); + return VfReturnType.VirtualSkip; + } + + //Try to get the blog context from the id + IChannelContext? context = await ContentManager.GetChannelAsync(contextId, entity.EventCancellation); + if (context == null) + { + entity.CloseResponse(HttpStatusCode.NotFound); + return VfReturnType.VirtualSkip; + } + + //Try to get the post id from the query + if (entity.QueryArgs.TryGetNonEmptyValue("post", out string? postId)) + { + //Try to get single post + PostMeta? post = await PostManager.GetPostAsync(context, postId, entity.EventCancellation); + + if (post != null) + { + entity.CloseResponseJson(HttpStatusCode.OK, post); + } + else + { + entity.CloseResponse(HttpStatusCode.NotFound); + } + + return VfReturnType.VirtualSkip; + } + + //Get the post meta list + PostMeta[] posts = await PostManager.GetPostsAsync(context, entity.EventCancellation); + entity.CloseResponseJson(HttpStatusCode.OK, posts); + return VfReturnType.VirtualSkip; + } + + protected override async ValueTask PostAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + //Check for write permissions + if (webm.Assert(entity.Session.CanWrite() == true, "You do not have permission to publish posts")) + { + entity.CloseResponseJson(HttpStatusCode.Forbidden, webm); + return VfReturnType.VirtualSkip; + } + + if (!entity.QueryArgs.TryGetNonEmptyValue("channel", out string? contextId)) + { + webm.Result = "No blog channel was selected"; + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Try to get the blog context from the id + IChannelContext? context = await ContentManager.GetChannelAsync(contextId, entity.EventCancellation); + + if (webm.Assert(context != null, "A blog with the given id does not exist")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Get the post from the request body + BlogPost? post = await entity.GetJsonFromFileAsync(); + + if (webm.Assert(post != null, "Message body was empty")) + { + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Validate post + if (!PostValidator.Validate(post, webm)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Publish post to the blog + await PostManager.PublishPostAsync(context, post, entity.EventCancellation); + + //Success + webm.Result = post; + webm.Success = true; + + //Return updated post to client + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + protected override async ValueTask PatchAsync(HttpEntity entity) + { + ValErrWebMessage webm = new(); + + //Check for write permissions + if (webm.Assert(entity.Session.CanWrite() == true, "You do not have permissions to update posts")) + { + entity.CloseResponseJson(HttpStatusCode.Forbidden, webm); + return VfReturnType.VirtualSkip; + } + + //Try to get the blog id from the query + if (!entity.QueryArgs.TryGetNonEmptyValue("channel", out string? contextId)) + { + webm.Result = "You must select a blog channel to update posts"; + entity.CloseResponseJson(HttpStatusCode.BadRequest, webm); + return VfReturnType.VirtualSkip; + } + + //Try to get the blog context from the id + IChannelContext? channel = await ContentManager.GetChannelAsync(contextId, entity.EventCancellation); + + if (webm.Assert(channel != null, "The channel you selected does not exist")) + { + entity.CloseResponseJson(HttpStatusCode.NotFound, webm); + return VfReturnType.VirtualSkip; + } + + //Get the blog post object + BlogPost? post = await entity.GetJsonFromFileAsync(); + + if (webm.Assert(post != null, "Message body was empty")) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Validate post + if (!PostValidator.Validate(post, webm)) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Update post against manager + bool result = await PostManager.UpdatePostAsync(channel, post, entity.EventCancellation); + + if (webm.Assert(result, "Failed to update post because it does not exist or the blog channel was not found")) + { + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + //Success + webm.Result = post; + webm.Success = true; + + entity.CloseResponse(webm); + return VfReturnType.VirtualSkip; + } + + protected override async ValueTask DeleteAsync(HttpEntity entity) + { + //Check for delete permissions + if (!entity.Session.CanDelete()) + { + WebMessage webm = new() + { + Result = "You do not have permission to delete content" + }; + entity.CloseResponseJson(HttpStatusCode.Forbidden, webm); + return VfReturnType.VirtualSkip; + } + + //Try to get the blog id from the query + if (!entity.QueryArgs.TryGetNonEmptyValue("channel", out string? contextId)) + { + entity.CloseResponse(HttpStatusCode.BadRequest); + return VfReturnType.VirtualSkip; + } + + //Try to get the blog context from the id + IChannelContext? context = await ContentManager.GetChannelAsync(contextId, entity.EventCancellation); + if (context == null) + { + entity.CloseResponse(HttpStatusCode.NotFound); + return VfReturnType.VirtualSkip; + } + + //Try to get the post id from the query + if (!entity.QueryArgs.TryGetNonEmptyValue("post", out string? postId)) + { + entity.CloseResponse(HttpStatusCode.NotFound); + return VfReturnType.VirtualSkip; + } + + //Delete post + await PostManager.DeletePostAsync(context, postId, entity.EventCancellation); + + //Success + entity.CloseResponse(HttpStatusCode.OK); + return VfReturnType.VirtualSkip; + } + + } +} diff --git a/back-end/src/FeedGenerator.cs b/back-end/src/FeedGenerator.cs new file mode 100644 index 0000000..de4a1e2 --- /dev/null +++ b/back-end/src/FeedGenerator.cs @@ -0,0 +1,210 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: FeedGenerator.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.Xml; +using System.Linq; +using System.Text; +using System.Collections.Generic; + +using VNLib.Utils.IO; +using VNLib.Plugins; + +using Content.Publishing.Blog.Admin.Model; + +namespace Content.Publishing.Blog.Admin +{ + internal sealed class FeedGenerator : IRssFeedGenerator + { + const int defaultMaxItems = 20; + const string ITUNES_XML_ATTR = "http://www.itunes.com/dtds/podcast-1.0.dtd"; + const string CONTENT_XML_ATTR = "http://purl.org/rss/1.0/modules/content/"; + + public FeedGenerator(PluginBase pbase) + { } + + public void BuildFeed(IChannelContext context, IEnumerable posts, VnMemoryStream output) + { + _ = context.Feed ?? throw new ArgumentNullException(nameof(context.Feed)); + + //Build the feed + using XmlWriter writer = XmlWriter.Create(output, new XmlWriterSettings + { + Encoding = Encoding.UTF8, + Indent = true, + IndentChars = " ", + NewLineChars = "\n", + NewLineHandling = NewLineHandling.Entitize, + NewLineOnAttributes = false, + CloseOutput = false, + NamespaceHandling = NamespaceHandling.OmitDuplicates, + }); + + string currentTime = DateTime.UtcNow.ToString("R"); + + //Write the feed + writer.WriteStartDocument(); + writer.WriteStartElement("rss"); + writer.WriteAttributeString("version", "2.0"); + writer.WriteAttributeString("xmlns", "itunes", null, ITUNES_XML_ATTR); + writer.WriteAttributeString("xmlns", "content", null, CONTENT_XML_ATTR); + + + //Channel element + writer.WriteStartElement("channel"); + + writer.WriteElementString("title", context.BlogName); + + writer.WriteElementString("link", context.Feed.PublihUrl); + + //Description/summary + writer.WriteElementString("description", context.Feed.Description); + writer.WriteElementString("itunes", "summary", null, context.Feed.Description); + + writer.WriteElementString("itunes", "author", null, context.Feed.Author); + + //Itunes owner tag + writer.WriteStartElement("itunes", "owner", null); + writer.WriteElementString("itunes", "email", null, context.Feed.WebMaster); + writer.WriteElementString("itunes", "name", null, context.Feed.Author); + writer.WriteEndElement(); + + //Write extended properties + if (context.Feed.ExtendedProperties != null) + { + foreach (ExtendedProperty prop in context.Feed.ExtendedProperties) + { + PrintExtendedProps(prop, writer); + } + } + + //Author + writer.WriteElementString("itunes", "author", null, context.Feed.Author); + + //Itunes image url + if (context.Feed.ImageUrl != null) + { + WriteImageTag(writer, context.Feed.ImageUrl); + } + + writer.WriteElementString("language", "en-us"); + + writer.WriteElementString("pubDate", currentTime); + writer.WriteElementString("lastBuildDate", currentTime); + + int maxItems = context.Feed.MaxItems ?? defaultMaxItems; + //Take only the latest max items + posts = posts.OrderByDescending(static p => p.Date).Take(maxItems); + + //Write the posts as items but sort in order of their pub date + foreach (PostMeta post in posts) + { + writer.WriteStartElement("item"); + + writer.WriteElementString("title", post.Title); + writer.WriteElementString("itunes","title", null, post.Title); + + writer.WriteElementString("link", $"{context.Feed.PublihUrl}/{post.Id}"); + + writer.WriteElementString("itunes", "author", null, post.Author); + + //Description is just the post summary + writer.WriteElementString("description", post.Summary); + writer.WriteElementString("itunes", "summary", null, post.Summary); + + //Time as iso string from unix seconds timestamp + string pubDate = DateTimeOffset.FromUnixTimeSeconds(post.Date).ToString("R"); + + writer.WriteElementString("pubDate", pubDate); + writer.WriteElementString("published", pubDate); + + if (post.Image != null) + { + WriteImageTag(writer, post.Image); + } + + //Add extended properties as itunes tags + if (post.ExtendedProperties != null) + { + //Recursivley add extended properties + foreach (ExtendedProperty prop in post.ExtendedProperties) + { + PrintExtendedProps(prop, writer); + } + } + + //Set post id as the guid + writer.WriteElementString("guid", post.Id); + + writer.WriteEndElement(); + } + + //End the feed + writer.WriteEndElement(); + writer.WriteEndElement(); + writer.WriteEndDocument(); + } + + private static void PrintExtendedProps(ExtendedProperty? prop, XmlWriter writer) + { + if (prop?.Name == null) + { + return; + } + + //Open the element + writer.WriteStartElement(prop.NameSpace, prop.Name, null); + + //Write the attributes + if (prop.Attributes != null) + { + foreach (KeyValuePair attr in prop.Attributes) + { + writer.WriteAttributeString(attr.Key, attr.Value); + } + } + + //nested child elements before closing + if (prop.Children != null) + { + foreach (ExtendedProperty child in prop.Children) + { + PrintExtendedProps(child, writer); + } + } + else + { + //Write the value + writer.WriteString(prop.Value); + } + + //Close the element + writer.WriteEndElement(); + } + + private static void WriteImageTag(XmlWriter writer, string imageUrl) + { + writer.WriteStartElement("itunes", "image",null); + writer.WriteAttributeString("href", imageUrl); + writer.WriteEndElement(); + } + } +} diff --git a/back-end/src/IBlogPostManager.cs b/back-end/src/IBlogPostManager.cs new file mode 100644 index 0000000..6a2ec68 --- /dev/null +++ b/back-end/src/IBlogPostManager.cs @@ -0,0 +1,41 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: IBlogPostManager.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.Threading; +using System.Threading.Tasks; + +using Content.Publishing.Blog.Admin.Model; + +namespace Content.Publishing.Blog.Admin +{ + interface IBlogPostManager + { + Task PublishPostAsync(IChannelContext context, PostMeta post, CancellationToken cancellation); + + Task GetPostAsync(IChannelContext context, string postId, CancellationToken cancellation); + + Task GetPostsAsync(IChannelContext context, CancellationToken cancellation); + + Task UpdatePostAsync(IChannelContext context, PostMeta post, CancellationToken cancellation); + + Task DeletePostAsync(IChannelContext context, string postId, CancellationToken cancellation); + } +} diff --git a/back-end/src/IChannelContextManager.cs b/back-end/src/IChannelContextManager.cs new file mode 100644 index 0000000..55a7cd8 --- /dev/null +++ b/back-end/src/IChannelContextManager.cs @@ -0,0 +1,68 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: IChannelContextManager.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.Threading; +using System.Threading.Tasks; + +using Content.Publishing.Blog.Admin.Model; + +namespace Content.Publishing.Blog.Admin +{ + internal interface IChannelContextManager + { + /// + /// Gets a channel context by id. + /// + /// The id of the context to get + /// + Task GetChannelAsync(string id, CancellationToken cancellation); + + /// + /// Gets the entire channel collection. + /// + /// Opaque objects that represent channel context objects + Task GetAllContextsAsync(CancellationToken cancellation); + + /// + /// Creates a new channel context. + /// + /// The new blog channel to create + /// A token to cancel the operation + /// The result of the operation + Task CreateChannelAsync(BlogChannel context, CancellationToken cancellation); + + /// + /// Updates an existing channel context. + /// + /// The channel context to update + /// A token to cancel the operation + /// The result of the operation + Task UpdateChannelAsync(BlogChannel context, CancellationToken cancellation); + + /// + /// Delets a channel context from the store. + /// + /// The context to delete + /// A token to cancel the operation + /// A task that completes when the channel was deleted + Task DeleteChannelAsync(IChannelContext context, CancellationToken cancellation); + } +} diff --git a/back-end/src/IRssFeedGenerator.cs b/back-end/src/IRssFeedGenerator.cs new file mode 100644 index 0000000..fcf4bf7 --- /dev/null +++ b/back-end/src/IRssFeedGenerator.cs @@ -0,0 +1,43 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: IRssFeedGenerator.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 VNLib.Utils.IO; + +using Content.Publishing.Blog.Admin.Model; + +namespace Content.Publishing.Blog.Admin +{ + /// + /// Represents a class that can generate an RSS feed from a collection of posts. + /// + internal interface IRssFeedGenerator + { + /// + /// Builds an XML RSS feed from the given posts and writes it to the given output stream. + /// + /// The channel context containing feed information + /// The collection of posts to publish to the feed + /// The output stream + void BuildFeed(IChannelContext context, IEnumerable posts, VnMemoryStream output); + } +} 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; } + } +} diff --git a/back-end/src/Storage/FtpStorageManager.cs b/back-end/src/Storage/FtpStorageManager.cs new file mode 100644 index 0000000..abcf5e1 --- /dev/null +++ b/back-end/src/Storage/FtpStorageManager.cs @@ -0,0 +1,136 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: FtpStorageManager.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.Net; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; + +using FluentFTP; +using FluentFTP.Exceptions; + +using VNLib.Net.Http; +using VNLib.Utils.Logging; +using VNLib.Utils.Resources; +using VNLib.Plugins; +using VNLib.Plugins.Extensions.Loading; + +namespace Content.Publishing.Blog.Admin.Storage +{ + [ConfigurationName("ftp_config")] + internal class FtpStorageManager : StorageBase, IDisposable + { + private readonly AsyncFtpClient _client; + private readonly string _username; + private readonly string? _baasePath; + + protected override string? BasePath => _baasePath; + + public FtpStorageManager(PluginBase plugin, IConfigScope config) + { + string? url = config["url"].GetString(); + _username = config["username"].GetString() ?? throw new KeyNotFoundException("Missing required username in config"); + _baasePath = config["base_path"].GetString(); + + Uri uri = new (url!); + + //Init new client + _client = new( + uri.Host, + uri.Port, + //Logger in debug mode + logger:plugin.IsDebug() ? new FtpDebugLogger(plugin.Log) : null + ); + } + + public override async Task ConfigureServiceAsync(PluginBase plugin) + { + using ISecretResult password = await plugin.GetSecretAsync("ftp_password"); + + //Init client credentials + _client.Credentials = new NetworkCredential(_username, password?.Result.ToString()); + _client.Config.EncryptionMode = FtpEncryptionMode.Auto; + _client.Config.ValidateAnyCertificate = true; + + plugin.Log.Information("Connecting to ftp server"); + + await _client.AutoConnect(CancellationToken.None); + plugin.Log.Information("Successfully connected to ftp server"); + } + + + /// + public override Task DeleteFileAsync(string filePath, CancellationToken cancellation) + { + return _client.DeleteFile(GetExternalFilePath(filePath), cancellation); + } + + /// + public override async Task ReadFileAsync(string filePath, Stream output, CancellationToken cancellation) + { + try + { + //Read the file + await _client.DownloadStream(output, GetExternalFilePath(filePath), token: cancellation); + return output.Position; + } + catch (FtpMissingObjectException) + { + //File not found + return -1; + } + } + + /// + public override async Task SetFileAsync(string filePath, Stream data, ContentType ct, CancellationToken cancellation) + { + //Upload the file to the server + FtpStatus status = await _client.UploadStream(data, GetExternalFilePath(filePath), FtpRemoteExists.Overwrite, true, token: cancellation); + + if (status == FtpStatus.Failed) + { + throw new ResourceUpdateFailedException($"Failed to update the remote resource {filePath}"); + } + } + + /// + public override string GetExternalFilePath(string filePath) + { + return string.IsNullOrWhiteSpace(_baasePath) ? filePath : $"{_baasePath}/{filePath}"; + } + + public void Dispose() + { + _client?.Dispose(); + } + + sealed record class FtpDebugLogger(ILogProvider Log) : IFtpLogger + { + void IFtpLogger.Log(FtpLogEntry entry) + { + Log.Debug("FTP [{lvl}] -> {cnt}", entry.Severity.ToString(), entry.Message); + } + } + + } +} diff --git a/back-end/src/Storage/IStorageFacade.cs b/back-end/src/Storage/IStorageFacade.cs new file mode 100644 index 0000000..703394b --- /dev/null +++ b/back-end/src/Storage/IStorageFacade.cs @@ -0,0 +1,71 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: IStorageFacade.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.Threading; +using System.Threading.Tasks; + +using VNLib.Net.Http; + +namespace Content.Publishing.Blog.Admin.Storage +{ + /// + /// Represents an opaque storage interface that abstracts simple storage operations + /// ignorant of the underlying storage system. + /// + internal interface IStorageFacade + { + /// + /// Gets the full public file path for the given relative file path + /// + /// The relative file path of the item to get the full path for + /// The full relative file path + string GetExternalFilePath(string filePath); + + /// + /// Deletes a file from the storage system asynchronously + /// + /// The path to the file to delete + /// A token to cancel the operation + /// A task that represents and asynchronous work + Task DeleteFileAsync(string filePath, CancellationToken cancellation); + + /// + /// Writes a file from the stream to the given file location + /// + /// The path to the file to write to + /// The file data to stream + /// The content type of the file to write + /// A token to cancel the operation + /// A task that represents and asynchronous work + Task SetFileAsync(string filePath, Stream data, ContentType ct, CancellationToken cancellation); + + /// + /// Reads a file from the storage system at the given path asynchronously + /// + /// The file to read + /// The stream to write the file output to + /// A token to cancel the operation + /// The number of bytes read, -1 if the operation failed + Task ReadFileAsync(string filePath, Stream output, CancellationToken cancellation); + } +} diff --git a/back-end/src/Storage/ManagedStorage.cs b/back-end/src/Storage/ManagedStorage.cs new file mode 100644 index 0000000..654695f --- /dev/null +++ b/back-end/src/Storage/ManagedStorage.cs @@ -0,0 +1,96 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: ManagedStorage.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.Threading; +using System.Threading.Tasks; + +using VNLib.Net.Http; +using VNLib.Plugins; +using VNLib.Plugins.Extensions.Loading; + +namespace Content.Publishing.Blog.Admin.Storage +{ + internal sealed class ManagedStorage : IStorageFacade + { + private readonly IStorageFacade _backingStorage; + + public ManagedStorage(PluginBase plugin) + { + if (plugin.HasConfigForType()) + { + //Use minio storage + _backingStorage = plugin.GetOrCreateSingleton(); + } + else if (plugin.HasConfigForType()) + { + //Use ftp storage + _backingStorage = plugin.GetOrCreateSingleton(); + } + else + { + throw new ArgumentException("No storage providers were found, cannot continue!"); + } + } + + /// + public Task DeleteFileAsync(string filePath, CancellationToken cancellation) + { + return _backingStorage.DeleteFileAsync(filePath, cancellation); + } + + /// + public string GetExternalFilePath(string filePath) + { + return _backingStorage.GetExternalFilePath(filePath); + } + + /// + public async Task ReadFileAsync(string filePath, Stream output, CancellationToken cancellation) + { + //Read the file from backing storage + long result = await _backingStorage.ReadFileAsync(filePath, output, cancellation); + + //Try to reset the stream if allowed + if (output.CanSeek) + { + //Reset stream + output.Seek(0, SeekOrigin.Begin); + } + + return result; + } + + /// + public Task SetFileAsync(string filePath, Stream data, ContentType ct, CancellationToken cancellation) + { + //Try to reset the stream if allowed + if (data.CanSeek) + { + //Reset stream + data.Seek(0, SeekOrigin.Begin); + } + + return _backingStorage.SetFileAsync(filePath, data, ct, cancellation); + } + } +} diff --git a/back-end/src/Storage/MinioClientManager.cs b/back-end/src/Storage/MinioClientManager.cs new file mode 100644 index 0000000..e9f7c9a --- /dev/null +++ b/back-end/src/Storage/MinioClientManager.cs @@ -0,0 +1,135 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: MinioClientManager.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.Threading; +using System.Threading.Tasks; + +using Minio; +using Minio.DataModel; + +using VNLib.Net.Http; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Plugins; +using VNLib.Plugins.Extensions.Loading; + +using static Content.Publishing.Blog.Admin.Model.PostManager; + +namespace Content.Publishing.Blog.Admin.Storage +{ + + [ConfigurationName("s3_config")] + internal sealed class MinioClientManager : StorageBase + { + private readonly MinioClient Client; + private readonly S3Config Config; + + public MinioClientManager(PluginBase pbase, IConfigScope s3Config) + { + //Deserialize the config + Config = s3Config.Deserialze(); + Client = new(); + } + + /// + protected override string? BasePath => Config.BaseBucket; + + /// + public override async Task ConfigureServiceAsync(PluginBase plugin) + { + using ISecretResult? secret = await plugin.GetSecretAsync("s3_secret"); + + Client.WithEndpoint(Config.ServerAddress) + .WithCredentials(Config.ClientId, secret.Result.ToString()); + + Client.WithSSL(Config.UseSsl.HasValue && Config.UseSsl.Value); + + //Accept optional region + if (!string.IsNullOrWhiteSpace(Config.Region)) + { + Client.WithRegion(Config.Region); + } + + //10 second timeout + Client.WithTimeout(10 * 1000); + + //Setup debug trace + if (plugin.IsDebug()) + { + Client.SetTraceOn(new ReqLogger(plugin.Log)); + } + + //Build client + Client.Build(); + } + + /// + public override Task DeleteFileAsync(string filePath, CancellationToken cancellation) + { + RemoveObjectArgs args = new(); + args.WithBucket(Config.BaseBucket) + .WithObject(filePath); + + //Remove the object + return Client.RemoveObjectAsync(args, cancellation); + } + + /// + public override Task SetFileAsync(string filePath, Stream data, ContentType ct, CancellationToken cancellation) + { + PutObjectArgs args = new(); + args.WithBucket(Config.BaseBucket) + .WithContentType(HttpHelpers.GetContentTypeString(ct)) + .WithObject(filePath) + .WithObjectSize(data.Length) + .WithStreamData(data); + + //Upload the object + return Client.PutObjectAsync(args, cancellation); + } + + /// + public override async Task ReadFileAsync(string filePath, Stream output, CancellationToken cancellation) + { + //Get the item + GetObjectArgs args = new(); + args.WithBucket(Config.BaseBucket) + .WithObject(filePath) + .WithCallbackStream(async (stream, cancellation) => + { + //Read the objec to memory + await stream.CopyToAsync(output, 4096, MemoryUtil.Shared, cancellation); + }); + try + { + //Get the post content file + ObjectStat stat = await Client.GetObjectAsync(args, cancellation); + } + catch (Minio.Exceptions.ObjectNotFoundException) + { + //File not found + return -1; + } + return output.Position; + } + } +} diff --git a/back-end/src/Storage/StorageBase.cs b/back-end/src/Storage/StorageBase.cs new file mode 100644 index 0000000..b33d6f1 --- /dev/null +++ b/back-end/src/Storage/StorageBase.cs @@ -0,0 +1,57 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: StorageBase.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.Threading; +using System.Threading.Tasks; + +using VNLib.Net.Http; +using VNLib.Plugins; +using VNLib.Plugins.Extensions.Loading; + +namespace Content.Publishing.Blog.Admin.Storage +{ + internal abstract class StorageBase : IAsyncConfigurable, IStorageFacade + { + /// + /// The base file path within the remote file system to use for external urls + /// + protected abstract string? BasePath { get; } + + /// + public abstract Task ConfigureServiceAsync(PluginBase plugin); + + /// + public abstract Task DeleteFileAsync(string filePath, CancellationToken cancellation); + + /// + public abstract Task ReadFileAsync(string filePath, Stream output, CancellationToken cancellation); + + /// + public abstract Task SetFileAsync(string filePath, Stream data, ContentType ct, CancellationToken cancellation); + + /// + public virtual string GetExternalFilePath(string filePath) + { + return string.IsNullOrWhiteSpace(BasePath) ? filePath : $"{BasePath}/{filePath}"; + } + } +} diff --git a/back-end/src/StorageExtensions.cs b/back-end/src/StorageExtensions.cs new file mode 100644 index 0000000..cb4ba5a --- /dev/null +++ b/back-end/src/StorageExtensions.cs @@ -0,0 +1,181 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: StorageExtensions.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.Threading; +using System.Threading.Tasks; + +using VNLib.Utils.IO; +using VNLib.Net.Http; + +using Content.Publishing.Blog.Admin.Model; +using Content.Publishing.Blog.Admin.Storage; + +namespace Content.Publishing.Blog.Admin +{ + internal static class StorageExtensions + { + /// + /// Writes a file of the given content type to the given path in the given channel context. + /// + /// + /// The channel context to write the file in + /// The stream containing the file data to write to the storage layer + /// The relative path to the file within the context to write + /// The file content type + /// A token to cancel the operation + /// A task that completes when the file data has been written to the storage layer + public static Task SetObjectDataAsync(this IStorageFacade storage, IChannelContext context, Stream data, string path, ContentType ct, CancellationToken cancellation) + { + return storage.SetFileAsync($"{context.BaseDir}/{path}", data, ct, cancellation); + } + + /// + /// Removes an item from a pauth scoped within the given channel context + /// + /// + /// The channel to scope the item into + /// The item path within the channel to delete + /// A token to cancel the operation + /// A task that completes when the deletion operation has completed + public static Task RemoveObjectAsync(this IStorageFacade storage, IChannelContext context, string path, CancellationToken cancellation) + { + return storage.DeleteFileAsync($"{context.BaseDir}/{path}", cancellation); + } + + /// + /// Creates and loads a new with the file data from the given path + /// that resides in the given channel context + /// + /// + /// + /// The channel context to get the file data from + /// The relative path inside the channel to load the database from + /// A token to cancel the operation + /// A task that resolves the new from file + public static Task> LoadDbAsync(this IStorageFacade storage, IChannelContext context, string fileName, CancellationToken cancellation) where T : IRecord + { + return storage.LoadDbAsync($"{context.BaseDir}/{fileName}", cancellation); + } + + /// + /// Stores the given in the given channel context at the given file path + /// + /// + /// + /// The channel context to write the database file inside + /// The path to the database file to overwrite + /// The database to capture the file data from + /// A token to cancel the operation + /// A task that completes when the database has been stored + public static Task StoreAsync(this IStorageFacade storage, IChannelContext context, string fileName, IRecordDb store, CancellationToken cancellation) + { + return storage.StoreAsync($"{context.BaseDir}/{fileName}", store, cancellation); + } + + /// + /// Reads the file data from the given path that resides in the given channel context + /// and writes it to the given stream + /// + /// + /// The channel context to read the file from + /// The realtive path within the channel to the file + /// The stream to write the file data to from the storage layer + /// A token to cancel the operation + /// A task that resolves the number of bytes read into the output stream + public static Task ReadFileAsync(this IStorageFacade storage, IChannelContext context, string fileName, Stream stream, CancellationToken cancellation) + { + return storage.ReadFileAsync($"{context.BaseDir}/{fileName}", stream, cancellation); + } + + /// + /// Gets the external file path for the given path that exists in the given channel context + /// + /// + /// The channel context that contains the item + /// The realtive path inside the channel to the item to get the path for + /// The full external path of the item + public static string GetExternalFilePath(this IStorageFacade storage, IChannelContext context, string path) + { + return storage.GetExternalFilePath($"{context.BaseDir}/{path}"); + } + + /// + /// Stores the at the given file path async + /// + /// The record type + /// + /// The database to store + /// The file path to store the record at + /// A token to cancel the operation + /// A task that completes when the operation has completed + public static async Task StoreAsync(this IStorageFacade storage, string path, IRecordDb store, CancellationToken cancellation) + { + //Alloc ms to write records to + using VnMemoryStream ms = new(); + + //Write the records to the stream + store.Store(ms); + + await storage.SetFileAsync(path, ms, ContentType.Json, cancellation); + } + + /// + /// Creates a new from the given object path and populates + /// it with records from the file + /// + /// + /// + /// The path to the stored database file + /// A token to cancel the operation + /// The populated , if loading fails (file not found etc) the store will be returned empty + public static async Task> LoadDbAsync(this IStorageFacade storage, string objPath, CancellationToken cancellation) where T : IRecord + { + //Create the db + IRecordDb db = JsonRecordDb.Create(); + + await storage.LoadDbAsync(objPath, db, cancellation); + + return db; + } + + /// + /// Populates the given with records from the file at the given path + /// + /// + /// + /// The path to the database file + /// The record database store ready to accept the database content + /// A token to cancel the operation + /// A task that completes when the database has been populated + public static async Task LoadDbAsync(this IStorageFacade storage, string objPath, IRecordDb db, CancellationToken cancellation) where T : IRecord + { + //Mem stream to read the object into + using VnMemoryStream ms = new(); + + await storage.ReadFileAsync(objPath, ms, cancellation); + + //Load the db from the stream + db.Load(ms); + } + + } +} -- cgit