aboutsummaryrefslogtreecommitdiff
path: root/back-end
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2023-07-12 01:28:23 -0400
committerLibravatar vnugent <public@vaughnnugent.com>2023-07-12 01:28:23 -0400
commitf64955c69d91e578e580b409ba31ac4b3477da96 (patch)
tree16f01392ddf1abfea13d7d1ede3bfb0459fe8f0d /back-end
Initial commit
Diffstat (limited to 'back-end')
-rw-r--r--back-end/README.md1
-rw-r--r--back-end/src/CMNextEntry.cs61
-rw-r--r--back-end/src/Content.Publishing.Blog.Admin.csproj50
-rw-r--r--back-end/src/Endpoints/ChannelEndpoint.cs257
-rw-r--r--back-end/src/Endpoints/ContentEndpoint.cs360
-rw-r--r--back-end/src/Endpoints/PostsEndpoint.cs271
-rw-r--r--back-end/src/FeedGenerator.cs210
-rw-r--r--back-end/src/IBlogPostManager.cs41
-rw-r--r--back-end/src/IChannelContextManager.cs68
-rw-r--r--back-end/src/IRssFeedGenerator.cs43
-rw-r--r--back-end/src/Model/BlogChannel.cs54
-rw-r--r--back-end/src/Model/BlogPost.cs55
-rw-r--r--back-end/src/Model/ChannelManager.cs156
-rw-r--r--back-end/src/Model/ContentManager.cs300
-rw-r--r--back-end/src/Model/ContentMeta.cs68
-rw-r--r--back-end/src/Model/ExtendedProperty.cs44
-rw-r--r--back-end/src/Model/FeedMeta.cs109
-rw-r--r--back-end/src/Model/IBlogFeedContext.cs34
-rw-r--r--back-end/src/Model/IChannelContext.cs36
-rw-r--r--back-end/src/Model/IRecord.cs40
-rw-r--r--back-end/src/Model/IRecordDb.cs71
-rw-r--r--back-end/src/Model/JsonRecordDb.cs167
-rw-r--r--back-end/src/Model/PostManager.cs232
-rw-r--r--back-end/src/Model/PostMeta.cs55
-rw-r--r--back-end/src/Storage/FtpStorageManager.cs136
-rw-r--r--back-end/src/Storage/IStorageFacade.cs71
-rw-r--r--back-end/src/Storage/ManagedStorage.cs96
-rw-r--r--back-end/src/Storage/MinioClientManager.cs135
-rw-r--r--back-end/src/Storage/StorageBase.cs57
-rw-r--r--back-end/src/StorageExtensions.cs181
30 files changed, 3459 insertions, 0 deletions
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<ChannelEndpoint>();
+
+ //Route posts endpoint
+ this.Route<PostsEndpoint>();
+
+ //Route content endpoint
+ this.Route<ContentEndpoint>();
+
+ 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 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <TargetFramework>net6.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <EnableDynamicLoading>true</EnableDynamicLoading>
+ <PackageReadmeFile>README.md</PackageReadmeFile>
+ <RootNamespace>Content.Publishing.Blog.Admin</RootNamespace>
+ <AssemblyName>CMNext</AssemblyName>
+ </PropertyGroup>
+
+ <PropertyGroup>
+ <Authors>Vaughn Nugent</Authors>
+ <Company>Vaughn Nugent</Company>
+ <Product>CMNext.Admin</Product>
+ <Description>A VNLib.Plugins.Essentials administration plugin for the CMNext content publishing platform.</Description>
+ <Copyright>Copyright © 2023 Vaughn Nugent</Copyright>
+ <PackageProjectUrl>https://www.vaughnnugent.com/resources/software/modules/CMNext.Admin</PackageProjectUrl>
+ <RepositoryUrl>https://github.com/VnUgE/CMNext/tree/master/</RepositoryUrl>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <None Include="..\README.md">
+ <Pack>True</Pack>
+ <PackagePath>\</PackagePath>
+ </None>
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="FluentFTP" Version="46.0.2" />
+ <PackageReference Include="Minio" Version="5.0.0" />
+ <PackageReference Include="VNLib.Plugins.Extensions.Loading" Version="0.1.0-ci0028" />
+ <PackageReference Include="VNLib.Plugins.Extensions.Validation" Version="0.1.0-ci0028" />
+ </ItemGroup>
+
+ <ItemGroup>
+ <None Update="CMNExt.json">
+ <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+ </None>
+ </ItemGroup>
+
+ <ItemGroup>
+ <Folder Include="Validation\" />
+ </ItemGroup>
+
+ <Target Condition="'$(BuildingInsideVisualStudio)' == true" Name="PostBuild" AfterTargets="PostBuildEvent">
+ <Exec Command="start xcopy &quot;$(TargetDir)&quot; &quot;f:\Programming\vnlib\devplugins\$(TargetName)&quot; /E /Y /R" />
+ </Target>
+
+</Project>
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<ChannelRequest> ChannelValidator = ChannelRequest.GetValidator();
+ private static readonly IValidator<FeedMeta> 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<ChannelManager>();
+ }
+
+ protected override async ValueTask<VfReturnType> 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<VfReturnType> 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<ChannelRequest>();
+
+ 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<VfReturnType> 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<ChannelRequest>();
+
+ 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<VfReturnType> 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<ChannelRequest> GetValidator()
+ {
+ InlineValidator<ChannelRequest> 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<ContentMeta> 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<ContentManager>();
+ _blogContextManager = plugin.GetOrCreateSingleton<ChannelManager>();
+ }
+
+
+ protected override async ValueTask<VfReturnType> 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<VfReturnType> 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<ContentMeta>();
+
+ 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<VfReturnType> 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<VfReturnType> 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<BlogPost> 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<PostManager>();
+ ContentManager = plugin.GetOrCreateSingleton<ChannelManager>();
+ }
+
+ protected override async ValueTask<VfReturnType> 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<VfReturnType> 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<BlogPost>();
+
+ 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<VfReturnType> 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<BlogPost>();
+
+ 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<VfReturnType> 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<PostMeta> 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<string, string> 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<PostMeta?> GetPostAsync(IChannelContext context, string postId, CancellationToken cancellation);
+
+ Task<PostMeta[]> GetPostsAsync(IChannelContext context, CancellationToken cancellation);
+
+ Task<bool> 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
+ {
+ /// <summary>
+ /// Gets a channel context by id.
+ /// </summary>
+ /// <param name="id">The id of the context to get</param>
+ /// <returns></returns>
+ Task<IChannelContext?> GetChannelAsync(string id, CancellationToken cancellation);
+
+ /// <summary>
+ /// Gets the entire channel collection.
+ /// </summary>
+ /// <returns>Opaque objects that represent channel context objects</returns>
+ Task<object[]> GetAllContextsAsync(CancellationToken cancellation);
+
+ /// <summary>
+ /// Creates a new channel context.
+ /// </summary>
+ /// <param name="context">The new blog channel to create</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>The result of the operation</returns>
+ Task<bool> CreateChannelAsync(BlogChannel context, CancellationToken cancellation);
+
+ /// <summary>
+ /// Updates an existing channel context.
+ /// </summary>
+ /// <param name="context">The channel context to update</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>The result of the operation</returns>
+ Task<bool> UpdateChannelAsync(BlogChannel context, CancellationToken cancellation);
+
+ /// <summary>
+ /// Delets a channel context from the store.
+ /// </summary>
+ /// <param name="context">The context to delete</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that completes when the channel was deleted</returns>
+ 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
+{
+ /// <summary>
+ /// Represents a class that can generate an RSS feed from a collection of posts.
+ /// </summary>
+ internal interface IRssFeedGenerator
+ {
+ /// <summary>
+ /// Builds an XML RSS feed from the given posts and writes it to the given output stream.
+ /// </summary>
+ /// <param name="context">The channel context containing feed information</param>
+ /// <param name="posts">The collection of posts to publish to the feed</param>
+ /// <param name="output">The output stream</param>
+ void BuildFeed(IChannelContext context, IEnumerable<PostMeta> 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<BlogPost> GetValidator()
+ {
+ InlineValidator<BlogPost> validator = new();
+
+ validator.RuleFor(x => x.Title!)
+ .NotEmpty()
+ .IllegalCharacters()
+ .MaximumLength(200);
+
+ validator.RuleFor(x => x.Summary!)
+ .NotEmpty()
+ .IllegalCharacters()
+ .MaximumLength(200);
+
+ validator.RuleFor(x => x.Author!)
+ .NotEmpty()
+ .IllegalCharacters()
+ .MaximumLength(64);
+
+ return validator;
+ }
+ }
+}
diff --git a/back-end/src/Model/ChannelManager.cs b/back-end/src/Model/ChannelManager.cs
new file mode 100644
index 0000000..adcc3b2
--- /dev/null
+++ b/back-end/src/Model/ChannelManager.cs
@@ -0,0 +1,156 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: ChannelManager.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+using VNLib.Hashing;
+using VNLib.Plugins;
+using VNLib.Plugins.Extensions.Loading;
+
+using Content.Publishing.Blog.Admin.Storage;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+
+ [ConfigurationName("blog_channels")]
+ internal sealed class ChannelManager : IChannelContextManager
+ {
+ private readonly IStorageFacade Storage;
+ private readonly string _indexPath;
+
+
+ public ChannelManager(PluginBase plugin, IConfigScope config)
+ {
+ //Init minio client
+ Storage = plugin.GetOrCreateSingleton<ManagedStorage>();
+
+ _indexPath = config["index_file_name"].GetString() ?? "channels.json";
+ }
+
+ ///<inheritdoc/>
+ public async Task<bool> CreateChannelAsync(BlogChannel context, CancellationToken cancellation)
+ {
+ _ = context.Id ?? throw new ArgumentNullException(nameof(context.Id));
+
+ IRecordDb<BlogChannel> db = await LoadDb(cancellation);
+
+ BlogChannel? existing = db.GetRecord(context.Id);
+
+ //Make sure the id is unique
+ if (existing != null)
+ {
+ return false;
+ }
+
+ //Add to the index
+ db.SetRecord(context);
+
+ //Publish updated index to storage
+ await StoreDb(db, cancellation);
+
+ return true;
+ }
+
+ ///<inheritdoc/>
+ public async Task<bool> UpdateChannelAsync(BlogChannel channel, CancellationToken cancellation)
+ {
+ _ = channel.Id ?? throw new ArgumentNullException(nameof(channel.Id));
+
+ IRecordDb<BlogChannel> db = await LoadDb(cancellation);
+
+ //Get the original context for the channel
+ _ = db.GetRecord(channel.Id) ?? throw new KeyNotFoundException("The requested channel does not exist");
+
+ //Add the updated context to the index
+ db.SetRecord(channel);
+
+ //Publish updated index to storage
+ await StoreDb(db, cancellation);
+
+ return true;
+ }
+
+ ///<inheritdoc/>
+ public async Task DeleteChannelAsync(IChannelContext context, CancellationToken cancellation)
+ {
+ _ = context?.Id ?? throw new ArgumentNullException(nameof(context));
+
+ IRecordDb<BlogChannel> db = await LoadDb(cancellation);
+
+ //Remove from the index
+ db.RemoveRecord(context.Id);
+
+ //Delete the channel dir
+ await Storage.DeleteFileAsync(context.BaseDir, cancellation);
+
+ //Publish updated index to storage
+ await StoreDb(db, cancellation);
+ }
+
+ /// <summary>
+ /// Computes the unique id for a context
+ /// </summary>
+ /// <param name="context">The context to produce the context id for</param>
+ /// <returns>The unique context id</returns>
+ public static string ComputeContextId(IChannelContext context)
+ {
+ //Context-id is the hash of the base dir and index file path
+ return ManagedHash.ComputeHexHash($"{context.BaseDir}/{context.IndexPath}", HashAlg.SHA1).ToLowerInvariant();
+ }
+
+ ///<inheritdoc/>
+ public async Task<IChannelContext?> GetChannelAsync(string id, CancellationToken cancellation)
+ {
+ //Recover the db
+ IRecordDb<BlogChannel> db = await LoadDb(cancellation);
+
+ //Get the channel
+ return db.GetRecord(id);
+ }
+
+ ///<inheritdoc/>
+ public async Task<object[]> GetAllContextsAsync(CancellationToken cancellation)
+ {
+ //Recover the db
+ IRecordDb<BlogChannel> db = await LoadDb(cancellation);
+
+ //Get the channel
+ return db.GetRecords().ToArray();
+ }
+
+
+ private Task<IRecordDb<BlogChannel>> LoadDb(CancellationToken cancellation)
+ {
+ return Storage.LoadDbAsync<BlogChannel>(_indexPath, cancellation);
+ }
+
+ private Task StoreDb(IRecordDb<BlogChannel> db, CancellationToken cancellation)
+ {
+ return Storage.StoreAsync(_indexPath, db, cancellation);
+ }
+
+ }
+}
diff --git a/back-end/src/Model/ContentManager.cs b/back-end/src/Model/ContentManager.cs
new file mode 100644
index 0000000..a0ed94f
--- /dev/null
+++ b/back-end/src/Model/ContentManager.cs
@@ -0,0 +1,300 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: ContentManager.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+
+using Content.Publishing.Blog.Admin.Storage;
+
+using VNLib.Hashing;
+using VNLib.Net.Http;
+using VNLib.Plugins;
+using VNLib.Plugins.Extensions.Loading;
+
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal sealed class ContentManager
+ {
+ private const string ContentIndex = "content.json";
+
+ private readonly IStorageFacade Storage;
+
+ public ContentManager(PluginBase plugin)
+ {
+ //Load the minio client manager
+ Storage = plugin.GetOrCreateSingleton<ManagedStorage>();
+ }
+
+ /// <summary>
+ /// Gets the content meta object for the given content item by its id
+ /// </summary>
+ /// <param name="channel">The channel that contains the desired content</param>
+ /// <param name="metaId">The id of the object</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>The content meta item if found in the store</returns>
+ public async Task<ContentMeta?> GetMetaAsync(IChannelContext channel, string metaId, CancellationToken cancellation)
+ {
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await Storage.LoadDbAsync<ContentMeta>(channel, ContentIndex, cancellation);
+
+ //Get the content meta
+ return contentIndex.GetRecord(metaId);
+ }
+
+ /// <summary>
+ /// Overwrites the content index with the given content index
+ /// </summary>
+ /// <param name="channel">The channel to set the content for</param>
+ /// <param name="meta">The contne meta to update</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that completes when the operation has completed</returns>
+ /// <exception cref="ArgumentNullException"></exception>
+ public async Task SetMetaAsync(IChannelContext channel, ContentMeta meta, CancellationToken cancellation)
+ {
+ _ = meta.Id ?? throw new ArgumentNullException(nameof(meta));
+
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await Storage.LoadDbAsync<ContentMeta>(channel, ContentIndex, cancellation);
+
+ //Set the content meta
+ contentIndex.SetRecord(meta);
+
+ //Save the content index
+ await StoreContentIndex(channel, contentIndex, cancellation);
+ }
+
+ /// <summary>
+ /// Initializes a new content meta object for a new content item
+ /// </summary>
+ /// <param name="length">The length of the new content item</param>
+ /// <returns>An initializes <see cref="ContentMeta"/> ready for a new content item</returns>
+ public ContentMeta GetNewMetaObject(long length, string? fileName, ContentType ct)
+ {
+ string fileId = RandomHash.GetRandomBase32(16).ToLowerInvariant();
+
+ return new()
+ {
+ Id = fileId,
+ Length = length,
+ FileName = fileName,
+ //File path from ct
+ FilePath = GetFileNameFromType(fileId, ct)
+ };
+ }
+
+ /// <summary>
+ /// Gets all content items in the given channel
+ /// </summary>
+ /// <param name="context">The channel to get content items for</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>The collection of content items for the channel</returns>
+ public async Task<ContentMeta[]> GetAllContentItemsAsync(IChannelContext context, CancellationToken cancellation)
+ {
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await Storage.LoadDbAsync<ContentMeta>(context, ContentIndex, cancellation);
+
+ //Return all content items
+ return contentIndex.GetRecords().ToArray();
+ }
+
+ /// <summary>
+ /// Reads content from the store and writes it to the output stream
+ /// </summary>
+ /// <param name="channel">The channel that contains the content</param>
+ /// <param name="metaId">The id of the content item to read</param>
+ /// <param name="output">The stream to write the file data to</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>The meta object that contains the content metadata if found, null if the content was not found in the directory</returns>
+ public async Task<ContentMeta?> GetContentAsync(IChannelContext context, string metaId, Stream output, CancellationToken cancellation)
+ {
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await Storage.LoadDbAsync<ContentMeta>(context, ContentIndex, cancellation);
+
+ //Get the content meta
+ ContentMeta? meta = contentIndex.GetRecord(metaId);
+
+ //Read the content
+ if (meta?.Id != null)
+ {
+ await Storage.ReadFileAsync(context, GetFilePath(context, meta), output, cancellation);
+ }
+
+ return meta;
+ }
+
+ /// <summary>
+ /// Adds content to the store
+ /// </summary>
+ /// <param name="context">The blog channel to store the data in</param>
+ /// <param name="meta">The content meta of the data to store</param>
+ /// <param name="data">The data stream to store</param>
+ /// <param name="ct">The content type of the data to store</param>
+ /// <param name="cancellation"></param>
+ /// <returns>A task that complets when the content has been added to the store</returns>
+ public async Task SetContentAsync(IChannelContext context, ContentMeta meta, Stream data, ContentType ct, CancellationToken cancellation)
+ {
+ //Update content type
+ meta.ContentType = HttpHelpers.GetContentTypeString(ct);
+
+ //update time
+ meta.Date = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ //update length
+ meta.Length = data.Length;
+
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await GetContentIndex(context, cancellation);
+
+ //Add the content meta to the store
+ contentIndex.SetRecord(meta);
+
+ //try to update the index before writing content
+ await StoreContentIndex(context, contentIndex, cancellation);
+
+ //Write the content
+ await Storage.SetObjectDataAsync(context, data, GetFilePath(context, meta), ct, cancellation);
+ }
+
+ /// <summary>
+ /// Creates a new content item in the store with no content for a given post id
+ /// </summary>
+ /// <param name="context">The channel context to create the item for</param>
+ /// <param name="postId">The id of the post to create content for</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that represents the async create operation</returns>
+ public async Task CreateNewPostContent(IChannelContext context, string postId, CancellationToken cancellation)
+ {
+ //Create the content meta for the post as an empty html file
+ ContentMeta meta = new()
+ {
+ ContentType = HttpHelpers.GetContentTypeString(ContentType.Html),
+ Date = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
+ FileName = $"Content for post {postId}",
+ Id = postId,
+ Length = 0,
+ FilePath = GetFileNameFromType(postId, ContentType.Html),
+ };
+
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await GetContentIndex(context, cancellation);
+
+ //Add the content meta to the store
+ contentIndex.SetRecord(meta);
+
+ //try to update the index before writing content
+ await StoreContentIndex(context, contentIndex, cancellation);
+ }
+
+ /// <summary>
+ /// Deletes content from the store by its id
+ /// </summary>
+ /// <param name="context">The blog context to delete the item from</param>
+ /// <param name="id"></param>
+ /// <param name="cancellation"></param>
+ /// <returns></returns>
+ public async Task<bool> DeleteContentAsync(IChannelContext context, string id, CancellationToken cancellation)
+ {
+ //get the content index
+ IRecordDb<ContentMeta> contentIndex = await GetContentIndex(context, cancellation);
+
+ //Get the post meta
+ ContentMeta? meta = contentIndex.GetRecord(id);
+
+ //Delete content before deleting the meta
+ if (meta?.Id == null)
+ {
+ return false;
+ }
+
+ //Remove the content meta from the store
+ contentIndex.RemoveRecord(id);
+
+ //Remove the content from storage first
+ await Storage.RemoveObjectAsync(context, GetFilePath(context, meta), cancellation);
+
+ //Overwrite the content index
+ await StoreContentIndex(context, contentIndex, cancellation);
+
+ return true;
+ }
+
+ /// <summary>
+ /// Gets the external path for the given item id.
+ /// </summary>
+ /// <param name="context">The context the item resides in</param>
+ /// <param name="id">The id of the item to get the path for</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>The external path of the item, or null if the item does not exist</returns>
+ public async Task<string?> GetExternalPathForItemAsync(IChannelContext context, string metaId, CancellationToken cancellation)
+ {
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await Storage.LoadDbAsync<ContentMeta>(context, ContentIndex, cancellation);
+
+ //Get the content meta
+ ContentMeta? meta = contentIndex.GetRecord(metaId);
+
+ //Read the content
+ if (meta?.Id == null)
+ {
+ return null;
+ }
+
+ //Get the full item path
+ return Storage.GetExternalFilePath(context, GetFilePath(context, meta));
+ }
+
+
+ private async Task<IRecordDb<ContentMeta>> GetContentIndex(IChannelContext context, CancellationToken cancellation)
+ {
+ //Get the content index
+ IRecordDb<ContentMeta> contentIndex = await Storage.LoadDbAsync<ContentMeta>(context, ContentIndex, cancellation);
+
+ //Return the content index
+ return contentIndex;
+ }
+
+ private async Task StoreContentIndex(IChannelContext channel, IRecordDb<ContentMeta> contentIndex, CancellationToken cancellation)
+ {
+ //Store the content index
+ await Storage.StoreAsync(channel, ContentIndex, contentIndex, cancellation);
+ }
+
+
+ private static string GetFilePath(IChannelContext context, ContentMeta meta)
+ {
+ return $"{context.ContentDir}/{meta.FilePath}";
+ }
+
+ private static string GetFileNameFromType(string fileId, ContentType type)
+ {
+ //Create file path from its id and file extension
+ return type switch
+ {
+ ContentType.Javascript => $"{fileId}.js",
+ _ => $"{fileId}.{type.ToString().ToLowerInvariant()}",
+ };
+ }
+ }
+}
diff --git a/back-end/src/Model/ContentMeta.cs b/back-end/src/Model/ContentMeta.cs
new file mode 100644
index 0000000..ee35d3f
--- /dev/null
+++ b/back-end/src/Model/ContentMeta.cs
@@ -0,0 +1,68 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: ContentMeta.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Text.Json.Serialization;
+
+using FluentValidation;
+
+using VNLib.Plugins.Extensions.Validation;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal class ContentMeta : IRecord
+ {
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("date")]
+ public long Date { get; set; }
+
+ [JsonPropertyName("name")]
+ public string? FileName { get; set; }
+
+ [JsonPropertyName("content_type")]
+ public string? ContentType { get; set; }
+
+ [JsonPropertyName("length")]
+ public long Length { get; set; }
+
+ [JsonPropertyName("path")]
+ public string? FilePath { get; set; }
+
+ public static IValidator<ContentMeta> GetValidator()
+ {
+ InlineValidator<ContentMeta> validationRules = new ();
+
+ validationRules.RuleFor(meta => meta.Id)
+ .NotEmpty()
+ .MaximumLength(64);
+
+ //Check filename
+ validationRules.RuleFor(meta => meta.FileName)
+ .NotEmpty()
+ .MaximumLength(200)
+ .IllegalCharacters();
+
+ return validationRules;
+ }
+ }
+}
diff --git a/back-end/src/Model/ExtendedProperty.cs b/back-end/src/Model/ExtendedProperty.cs
new file mode 100644
index 0000000..83f245b
--- /dev/null
+++ b/back-end/src/Model/ExtendedProperty.cs
@@ -0,0 +1,44 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: ExtendedProperty.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal class ExtendedProperty
+ {
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ [JsonPropertyName("attributes")]
+ public Dictionary<string, string>? Attributes { get; set; }
+
+ [JsonPropertyName("value")]
+ public string? Value { get; set; }
+
+ [JsonPropertyName("namespace")]
+ public string? NameSpace { get; set; }
+
+ [JsonPropertyName("properties")]
+ public ExtendedProperty[]? Children { get; set; }
+ }
+}
diff --git a/back-end/src/Model/FeedMeta.cs b/back-end/src/Model/FeedMeta.cs
new file mode 100644
index 0000000..7cd788a
--- /dev/null
+++ b/back-end/src/Model/FeedMeta.cs
@@ -0,0 +1,109 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: FeedMeta.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+using System.Text.RegularExpressions;
+
+using FluentValidation;
+
+using VNLib.Plugins.Extensions.Validation;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal class FeedMeta : IBlogFeedContext
+ {
+ private static readonly Regex FileNameRegex = new(@"^[a-zA-Z0-9_.\-]+$", RegexOptions.Compiled);
+ private static readonly Regex HttpUrlRegex = new(@"^https?://", RegexOptions.Compiled | RegexOptions.IgnoreCase);
+
+ [JsonPropertyName("url")]
+ public string PublihUrl { get; set; } = string.Empty;
+
+ [JsonPropertyName("path")]
+ public string FeedPath { get; set; } = string.Empty;
+
+ [JsonPropertyName("image")]
+ public string ImageUrl { get; set; } = string.Empty;
+
+ [JsonPropertyName("description")]
+ public string? Description { get; set; }
+
+ [JsonPropertyName("maxItems")]
+ public int? MaxItems { get; set; }
+
+ [JsonPropertyName("author")]
+ public string? Author { get; set; }
+
+ [JsonPropertyName("contact")]
+ public string? WebMaster { get; set; }
+
+ [JsonPropertyName("properties")]
+ public ExtendedProperty[]? ExtendedProperties { get; set; }
+
+
+ public static IValidator<FeedMeta> GetValidator()
+ {
+ InlineValidator<FeedMeta> validator = new();
+
+ validator.RuleFor(x => x.PublihUrl)
+ .NotEmpty()
+ .MaximumLength(200)
+ .Matches(HttpUrlRegex)
+ .WithMessage("Your feed url is not a valid http url");
+
+ validator.RuleFor(x => x.FeedPath)
+ .NotEmpty()
+ .MaximumLength(200)
+ .Must(static p => !p.StartsWith('/') && !p.StartsWith('\\'))
+ .WithMessage("The feed file path must not contain a leading slash")
+ .Matches(FileNameRegex)
+ .WithMessage("The feed file name is not valid");
+
+ validator.RuleFor(x => x.ImageUrl)
+ .MaximumLength(200)
+ .Must(static x => string.IsNullOrWhiteSpace(x) || HttpUrlRegex.IsMatch(x))
+ .WithMessage("The image url is not a valid http url");
+
+ validator.RuleFor(x => x.Description!)
+ .NotEmpty()
+ .IllegalCharacters()
+ .MaximumLength(200);
+
+ //Cap max items at 100
+ validator.RuleFor(x => x.MaxItems)
+ .InclusiveBetween(1, 100);
+
+ validator.RuleFor(x => x.Author!)
+ .SpecialCharacters()
+ .MaximumLength(200);
+
+ validator.RuleFor(x => x.WebMaster!)
+ .IllegalCharacters()
+ .MaximumLength(200);
+
+ //Make sure the keys for each propery are valid
+ validator.RuleForEach(x => x.ExtendedProperties)
+ .ChildRules(r => r.RuleFor(k => k.Name!).IllegalCharacters())
+ .When(x => x.ExtendedProperties != null);
+
+ return validator;
+ }
+ }
+}
diff --git a/back-end/src/Model/IBlogFeedContext.cs b/back-end/src/Model/IBlogFeedContext.cs
new file mode 100644
index 0000000..9a51429
--- /dev/null
+++ b/back-end/src/Model/IBlogFeedContext.cs
@@ -0,0 +1,34 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: IBlogFeedContext.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal interface IBlogFeedContext
+ {
+ string PublihUrl { get; }
+ string FeedPath { get; }
+ string? ImageUrl { get; }
+ int? MaxItems { get; }
+ string? WebMaster { get; }
+ string? Author { get; }
+ string? Description { get; }
+ }
+}
diff --git a/back-end/src/Model/IChannelContext.cs b/back-end/src/Model/IChannelContext.cs
new file mode 100644
index 0000000..f96858d
--- /dev/null
+++ b/back-end/src/Model/IChannelContext.cs
@@ -0,0 +1,36 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: IChannelContext.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal interface IChannelContext : IRecord
+ {
+ string BlogName { get; }
+
+ string BaseDir { get; }
+
+ string IndexPath { get; }
+
+ string ContentDir { get; }
+
+ FeedMeta? Feed { get; }
+ }
+}
diff --git a/back-end/src/Model/IRecord.cs b/back-end/src/Model/IRecord.cs
new file mode 100644
index 0000000..1a6fd11
--- /dev/null
+++ b/back-end/src/Model/IRecord.cs
@@ -0,0 +1,40 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: IRecord.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ /// <summary>
+ /// A simple record to store in a <see cref="IRecordDb{T}"/>
+ /// </summary>
+ internal interface IRecord
+ {
+ /// <summary>
+ /// The record id
+ /// </summary>
+ string? Id { get; set; }
+
+ /// <summary>
+ /// The date the record was last modified
+ /// </summary>
+ long Date { get; set; }
+ }
+}
diff --git a/back-end/src/Model/IRecordDb.cs b/back-end/src/Model/IRecordDb.cs
new file mode 100644
index 0000000..ccab318
--- /dev/null
+++ b/back-end/src/Model/IRecordDb.cs
@@ -0,0 +1,71 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: IRecordDb.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+
+using System.IO;
+using System.Collections.Generic;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ /// <summary>
+ /// Represents a simple primary-key record based database
+ /// </summary>
+ /// <typeparam name="T">The record type</typeparam>
+ internal interface IRecordDb<T>
+ {
+ /// <summary>
+ /// Sets a record in the database. Adds or overwrites the entire record if it already exists.
+ /// </summary>
+ /// <param name="record">The record to set</param>
+ void SetRecord(T record);
+
+ /// <summary>
+ /// Removes a record from the database by its id
+ /// </summary>
+ /// <param name="id">The id of the record to delete</param>
+ void RemoveRecord(string id);
+
+ /// <summary>
+ /// Gets a record from the database by its id
+ /// </summary>
+ /// <param name="id">The id of the item to get</param>
+ /// <returns>The item if found, null otherwise</returns>
+ T? GetRecord(string id);
+
+ /// <summary>
+ /// Gets all records in the database
+ /// </summary>
+ /// <returns>A enumeration of the current collection of records</returns>
+ IEnumerable<T> GetRecords();
+
+ /// <summary>
+ /// Writes the entire state of the current store to the given stream
+ /// </summary>
+ /// <param name="stream">The stream to write the state data to</param>
+ void Store(Stream stream);
+
+ /// <summary>
+ /// Loads the entire state of the store from the given stream
+ /// </summary>
+ /// <param name="stream">The stream to read the state from</param>
+ void Load(Stream stream);
+ }
+}
diff --git a/back-end/src/Model/JsonRecordDb.cs b/back-end/src/Model/JsonRecordDb.cs
new file mode 100644
index 0000000..e7f2884
--- /dev/null
+++ b/back-end/src/Model/JsonRecordDb.cs
@@ -0,0 +1,167 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: JsonRecordDb.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Text.Json;
+using System.Collections.Generic;
+
+using VNLib.Utils.IO;
+using VNLib.Utils.Extensions;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ /// <summary>
+ /// A json backed record database
+ /// </summary>
+ /// <typeparam name="T">The record type</typeparam>
+ internal class JsonRecordDb<T> : IRecordDb<T> where T : IRecord
+ {
+ private static readonly Version CurrentVersion = new (0, 1, 0);
+
+
+ private DateTimeOffset _lastModified;
+ private Version? _version;
+
+ /*
+ * Records in the list are only ever read from, any changes are made by
+ * creating a new list and re-ordering it.
+ *
+ * We dont need any synchronization in this case
+ */
+ private IReadOnlyList<T> _records;
+
+ public JsonRecordDb()
+ {
+ _lastModified = DateTimeOffset.UnixEpoch;
+ _records = new List<T>();
+ }
+
+ ///<inheritdoc/>
+ public T? GetRecord(string id)
+ {
+ return _records.SingleOrDefault(r => r.Id!.Equals(id, StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///<inheritdoc/>
+ public IEnumerable<T> GetRecords()
+ {
+ return _records;
+ }
+
+ ///<inheritdoc/>
+ public void RemoveRecord(string id)
+ {
+ _ = id ?? throw new ArgumentNullException(nameof(id));
+
+ //Create new list without the record and re-order
+ _records = _records.Where(r => !id.Equals(r.Id, StringComparison.OrdinalIgnoreCase))
+ .OrderByDescending(static r => r.Date)
+ .ToList();
+ }
+
+ ///<inheritdoc/>
+ public void SetRecord(T record)
+ {
+ _ = record?.Id ?? throw new ArgumentNullException(nameof(record));
+
+ //Remove record if it already exists
+ RemoveRecord(record.Id);
+
+ //Add the record and re-order
+ _records = _records.Append(record)
+ .OrderByDescending(static r => r.Date)
+ .ToList();
+
+ //Update last modified time
+ _lastModified = DateTimeOffset.UtcNow;
+ }
+
+ ///<inheritdoc/>
+ public void Load(Stream stream)
+ {
+ if (stream.Length == 0)
+ {
+ //Set defaults
+ _lastModified = DateTimeOffset.UnixEpoch;
+ _records = new List<T>();
+ }
+ else
+ {
+ //Read stream into a doc
+ using JsonDocument doc = JsonDocument.Parse(stream);
+
+ //Read the last modified time
+ _lastModified = DateTimeOffset.FromUnixTimeSeconds(doc.RootElement.GetProperty("last_modified").GetInt64());
+
+ //Try to read the version
+ if (doc.RootElement.TryGetProperty("version", out JsonElement versionEl))
+ {
+ _ = Version.TryParse(versionEl.GetString(), out _version);
+ }
+
+ if (doc.RootElement.TryGetProperty("records", out JsonElement el))
+ {
+ //Read the records array
+ _records = el.Deserialize<List<T>>() ?? new List<T>();
+ }
+ else
+ {
+ //Set defaults
+ _records = new List<T>();
+ }
+ }
+ }
+
+ ///<inheritdoc/>
+ public void Store(Stream stream)
+ {
+ using Utf8JsonWriter writer = new(stream);
+ writer.WriteStartObject();
+
+ //Write last modified time
+ writer.WriteNumber("last_modified", _lastModified.ToUnixTimeSeconds());
+
+ //Set version if not already set
+ _version ??= CurrentVersion;
+
+ //Write version
+ writer.WriteString("version", _version.ToString());
+
+ //Write the records array
+ writer.WritePropertyName("records");
+
+ JsonSerializer.Serialize(writer, _records, VNLib.Plugins.Essentials.Statics.SR_OPTIONS);
+
+ writer.WriteEndObject();
+ }
+
+ /// <summary>
+ /// Create a new <see cref="JsonRecordDb{T}"/> of a given type
+ /// </summary>
+ /// <returns>The new record store</returns>
+ public static JsonRecordDb<T> Create()
+ {
+ return new JsonRecordDb<T>();
+ }
+ }
+}
diff --git a/back-end/src/Model/PostManager.cs b/back-end/src/Model/PostManager.cs
new file mode 100644
index 0000000..22e3682
--- /dev/null
+++ b/back-end/src/Model/PostManager.cs
@@ -0,0 +1,232 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: PostManager.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Collections.Generic;
+
+using Minio;
+using Minio.DataModel.Tracing;
+
+using VNLib.Hashing;
+using VNLib.Utils.IO;
+using VNLib.Utils.Logging;
+using VNLib.Net.Http;
+using VNLib.Plugins;
+using VNLib.Plugins.Extensions.Loading;
+
+using Content.Publishing.Blog.Admin.Storage;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+
+ internal sealed class PostManager : IBlogPostManager
+ {
+ private readonly IStorageFacade Storage;
+ private readonly IRssFeedGenerator FeedGenerator;
+ private readonly ContentManager ContentMan;
+
+ public PostManager(PluginBase plugin)
+ {
+ //Get minio client
+ Storage = plugin.GetOrCreateSingleton<ManagedStorage>();
+
+ //Get feed generator
+ FeedGenerator = plugin.GetOrCreateSingleton<FeedGenerator>();
+
+ //Get content manager
+ ContentMan = plugin.GetOrCreateSingleton<ContentManager>();
+ }
+
+ ///<inheritdoc/>
+ public async Task<PostMeta?> GetPostAsync(IChannelContext context, string postId, CancellationToken cancellation)
+ {
+ _ = context ?? throw new ArgumentNullException(nameof(context));
+ _ = postId ?? throw new ArgumentNullException(nameof(postId));
+
+ //Read the index into memory
+ IRecordDb<PostMeta> db = await GetPostIndexAsync(context, cancellation);
+
+ //Get the post meta
+ return db.GetRecord(postId);
+ }
+
+ ///<inheritdoc/>
+ public async Task<PostMeta[]> GetPostsAsync(IChannelContext context, CancellationToken cancellation)
+ {
+ _ = context ?? throw new ArgumentNullException(nameof(context));
+
+ //Read the index into memory
+ IRecordDb<PostMeta> db = await GetPostIndexAsync(context, cancellation);
+
+ //Return post metas
+ return db.GetRecords().ToArray();
+ }
+
+ ///<inheritdoc/>
+ public async Task PublishPostAsync(IChannelContext context, PostMeta post, CancellationToken cancellation)
+ {
+ _ = context ?? throw new ArgumentNullException(nameof(context));
+ _ = post ?? throw new ArgumentNullException(nameof(post));
+
+ //Read the index into memory
+ IRecordDb<PostMeta> db = await GetPostIndexAsync(context, cancellation);
+
+ //Update index modifed time and post date
+ post.Date = post.Created = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ //Compute post id before publishing to storage
+ ComputePostId(post);
+
+ //Add post to the index
+ db.SetRecord(post);
+
+ //Update the index
+ await SetPostIndexAsync(context, db, cancellation);
+
+ //Create empty post content
+ await ContentMan.CreateNewPostContent(context, post.Id!, cancellation);
+ }
+
+ ///<inheritdoc/>
+ public async Task DeletePostAsync(IChannelContext context, string postId, CancellationToken cancellation)
+ {
+ _ = context ?? throw new ArgumentNullException(nameof(context));
+ _ = postId ?? throw new ArgumentNullException(nameof(postId));
+
+ //Get the index
+ IRecordDb<PostMeta> db = await GetPostIndexAsync(context, cancellation);
+
+ //Remove the post from the index if it exists
+ PostMeta? post = db.GetRecord(postId);
+
+ if (post == null)
+ {
+ return;
+ }
+
+ db.RemoveRecord(postId);
+
+ //Remove post content before flushing db changes
+ await ContentMan.DeleteContentAsync(context, postId, cancellation);
+
+ //update feed after post deletion
+ await UpdateIndexAndFeed(context, db, cancellation);
+ }
+
+ ///<inheritdoc/>
+ public async Task<bool> UpdatePostAsync(IChannelContext context, PostMeta post, CancellationToken cancellation)
+ {
+ _ = context ?? throw new ArgumentNullException(nameof(context));
+ _ = post?.Id ?? throw new ArgumentNullException(nameof(post));
+
+ //Get the index
+ IRecordDb<PostMeta> db = await GetPostIndexAsync(context, cancellation);
+
+ //Try to get the post by its id
+ PostMeta? oldMeta = db.GetRecord(post.Id);
+
+ if (oldMeta == null)
+ {
+ return false;
+ }
+
+ //Update modified time
+ post.Date = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
+
+ //Save old time
+ post.Created = oldMeta.Created;
+
+ //Remove the old post meta
+ db.SetRecord(post);
+
+ //Update the index and feed after post update
+ await UpdateIndexAndFeed(context, db, cancellation);
+
+ return true;
+ }
+
+
+ private async Task UpdateIndexAndFeed(IChannelContext context, IRecordDb<PostMeta> index, CancellationToken cancellation)
+ {
+ //Write the index back to the bucket
+ await Storage.StoreAsync(context, context.IndexPath, index, cancellation);
+
+ //Update feed
+ if (context.Feed != null)
+ {
+ await UpdateRssFeed(context, index.GetRecords(), cancellation);
+ }
+ }
+
+ private async Task UpdateRssFeed(IChannelContext context, IEnumerable<PostMeta> meta, CancellationToken cancellation)
+ {
+ using VnMemoryStream feedData = new();
+
+ //Build the feed from posts
+ FeedGenerator.BuildFeed(context, meta, feedData);
+
+ //Rewind the feed stream
+ feedData.Seek(0, System.IO.SeekOrigin.Begin);
+
+ //Write the feed to the bucket
+ await Storage.SetObjectDataAsync(context, feedData, context.Feed!.FeedPath, ContentType.Rss, cancellation);
+ }
+
+ #region Load/Store Db
+
+ private Task<IRecordDb<PostMeta>> GetPostIndexAsync(IChannelContext channel, CancellationToken cancellation)
+ {
+ //Read the index into memory
+ return Storage.LoadDbAsync<PostMeta>(channel, channel.IndexPath, cancellation);
+ }
+
+ private Task SetPostIndexAsync(IChannelContext channel, IRecordDb<PostMeta> db, CancellationToken cancellation)
+ {
+ //Read the index into memory
+ return Storage.StoreAsync(channel, channel.IndexPath, db, cancellation);
+ }
+
+ #endregion
+
+ /*
+ * Computes a post id based on its meta information and produces a sha1 hash
+ * to use as a unique id for the post
+ */
+ static void ComputePostId(PostMeta post)
+ {
+ post.Id = ManagedHash.ComputeHexHash($"{post.Title}.{post.Author}.{post.Summary}.{post.Date}", HashAlg.SHA1).ToLowerInvariant();
+ }
+
+ internal record class ReqLogger(ILogProvider Log) : IRequestLogger
+ {
+ public void LogRequest(RequestToLog requestToLog, ResponseToLog responseToLog, double durationMs)
+ {
+ Log.Debug("S3 result\n{method} {uri} HTTP {ms}ms\nHTTP {status} {message}\n{content}",
+ requestToLog.Method, requestToLog.Resource, durationMs,
+ responseToLog.StatusCode, responseToLog.ErrorMessage, responseToLog.Content
+ );
+ }
+ }
+ }
+}
diff --git a/back-end/src/Model/PostMeta.cs b/back-end/src/Model/PostMeta.cs
new file mode 100644
index 0000000..2bb963e
--- /dev/null
+++ b/back-end/src/Model/PostMeta.cs
@@ -0,0 +1,55 @@
+/*
+* Copyright (c) 2023 Vaughn Nugent
+*
+* Library: CMNext
+* Package: Content.Publishing.Blog.Admin
+* File: PostMeta.cs
+*
+* CMNext is free software: you can redistribute it and/or modify
+* it under the terms of the GNU Affero General Public License as
+* published by the Free Software Foundation, either version 3 of the
+* License, or (at your option) any later version.
+*
+* CMNext is distributed in the hope that it will be useful,
+* but WITHOUT ANY WARRANTY; without even the implied warranty of
+* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+* GNU Affero General Public License for more details.
+*
+* You should have received a copy of the GNU Affero General Public License
+* along with this program. If not, see https://www.gnu.org/licenses/.
+*/
+
+using System.Text.Json.Serialization;
+
+namespace Content.Publishing.Blog.Admin.Model
+{
+ internal class PostMeta : IRecord
+ {
+ [JsonPropertyName("id")]
+ public string? Id { get; set; }
+
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ [JsonPropertyName("date")]
+ public long Date { get; set; }
+
+ [JsonPropertyName("created")]
+ public long Created { get; set; }
+
+ [JsonPropertyName("author")]
+ public string? Author { get; set; }
+
+ [JsonPropertyName("summary")]
+ public string? Summary { get; set; }
+
+ [JsonPropertyName("tags")]
+ public string[]? Tags { get; set; }
+
+ [JsonPropertyName("image")]
+ public string? Image { get; set; }
+
+ [JsonPropertyName("properties")]
+ public ExtendedProperty[]? ExtendedProperties { get; set; }
+ }
+}
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");
+ }
+
+
+ ///<inheritdoc/>
+ public override Task DeleteFileAsync(string filePath, CancellationToken cancellation)
+ {
+ return _client.DeleteFile(GetExternalFilePath(filePath), cancellation);
+ }
+
+ ///<inheritdoc/>
+ public override async Task<long> 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;
+ }
+ }
+
+ ///<inheritdoc/>
+ 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}");
+ }
+ }
+
+ ///<inheritdoc/>
+ 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
+{
+ /// <summary>
+ /// Represents an opaque storage interface that abstracts simple storage operations
+ /// ignorant of the underlying storage system.
+ /// </summary>
+ internal interface IStorageFacade
+ {
+ /// <summary>
+ /// Gets the full public file path for the given relative file path
+ /// </summary>
+ /// <param name="filePath">The relative file path of the item to get the full path for</param>
+ /// <returns>The full relative file path</returns>
+ string GetExternalFilePath(string filePath);
+
+ /// <summary>
+ /// Deletes a file from the storage system asynchronously
+ /// </summary>
+ /// <param name="filePath">The path to the file to delete</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that represents and asynchronous work</returns>
+ Task DeleteFileAsync(string filePath, CancellationToken cancellation);
+
+ /// <summary>
+ /// Writes a file from the stream to the given file location
+ /// </summary>
+ /// <param name="filePath">The path to the file to write to</param>
+ /// <param name="data">The file data to stream</param>
+ /// <param name="ct">The content type of the file to write</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that represents and asynchronous work</returns>
+ Task SetFileAsync(string filePath, Stream data, ContentType ct, CancellationToken cancellation);
+
+ /// <summary>
+ /// Reads a file from the storage system at the given path asynchronously
+ /// </summary>
+ /// <param name="filePath">The file to read</param>
+ /// <param name="output">The stream to write the file output to</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>The number of bytes read, -1 if the operation failed</returns>
+ Task<long> 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<MinioClientManager>())
+ {
+ //Use minio storage
+ _backingStorage = plugin.GetOrCreateSingleton<MinioClientManager>();
+ }
+ else if (plugin.HasConfigForType<FtpStorageManager>())
+ {
+ //Use ftp storage
+ _backingStorage = plugin.GetOrCreateSingleton<FtpStorageManager>();
+ }
+ else
+ {
+ throw new ArgumentException("No storage providers were found, cannot continue!");
+ }
+ }
+
+ ///<inheritdoc/>
+ public Task DeleteFileAsync(string filePath, CancellationToken cancellation)
+ {
+ return _backingStorage.DeleteFileAsync(filePath, cancellation);
+ }
+
+ ///<inheritdoc/>
+ public string GetExternalFilePath(string filePath)
+ {
+ return _backingStorage.GetExternalFilePath(filePath);
+ }
+
+ ///<inheritdoc/>
+ public async Task<long> 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;
+ }
+
+ ///<inheritdoc/>
+ 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<S3Config>();
+ Client = new();
+ }
+
+ ///<inheritdoc/>
+ protected override string? BasePath => Config.BaseBucket;
+
+ ///<inheritdoc/>
+ 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();
+ }
+
+ ///<inheritdoc/>
+ 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);
+ }
+
+ ///<inheritdoc/>
+ 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);
+ }
+
+ ///<inheritdoc/>
+ public override async Task<long> 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
+ {
+ /// <summary>
+ /// The base file path within the remote file system to use for external urls
+ /// </summary>
+ protected abstract string? BasePath { get; }
+
+ ///<inheritdoc/>
+ public abstract Task ConfigureServiceAsync(PluginBase plugin);
+
+ ///<inheritdoc/>
+ public abstract Task DeleteFileAsync(string filePath, CancellationToken cancellation);
+
+ ///<inheritdoc/>
+ public abstract Task<long> ReadFileAsync(string filePath, Stream output, CancellationToken cancellation);
+
+ ///<inheritdoc/>
+ public abstract Task SetFileAsync(string filePath, Stream data, ContentType ct, CancellationToken cancellation);
+
+ ///<inheritdoc/>
+ 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
+ {
+ /// <summary>
+ /// Writes a file of the given content type to the given path in the given channel context.
+ /// </summary>
+ /// <param name="storage"></param>
+ /// <param name="context">The channel context to write the file in</param>
+ /// <param name="data">The stream containing the file data to write to the storage layer</param>
+ /// <param name="path">The relative path to the file within the context to write</param>
+ /// <param name="ct">The file content type</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that completes when the file data has been written to the storage layer</returns>
+ 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);
+ }
+
+ /// <summary>
+ /// Removes an item from a pauth scoped within the given channel context
+ /// </summary>
+ /// <param name="storage"></param>
+ /// <param name="context">The channel to scope the item into</param>
+ /// <param name="path">The item path within the channel to delete</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that completes when the deletion operation has completed</returns>
+ public static Task RemoveObjectAsync(this IStorageFacade storage, IChannelContext context, string path, CancellationToken cancellation)
+ {
+ return storage.DeleteFileAsync($"{context.BaseDir}/{path}", cancellation);
+ }
+
+ /// <summary>
+ /// Creates and loads a new <see cref="IRecordDb{T}"/> with the file data from the given path
+ /// that resides in the given channel context
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="storage"></param>
+ /// <param name="context">The channel context to get the file data from</param>
+ /// <param name="fileName">The relative path inside the channel to load the database from</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that resolves the new <see cref="IRecordDb{T}"/> from file</returns>
+ public static Task<IRecordDb<T>> LoadDbAsync<T>(this IStorageFacade storage, IChannelContext context, string fileName, CancellationToken cancellation) where T : IRecord
+ {
+ return storage.LoadDbAsync<T>($"{context.BaseDir}/{fileName}", cancellation);
+ }
+
+ /// <summary>
+ /// Stores the given <see cref="IRecordDb{T}"/> in the given channel context at the given file path
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="storage"></param>
+ /// <param name="context">The channel context to write the database file inside</param>
+ /// <param name="fileName">The path to the database file to overwrite</param>
+ /// <param name="store">The database to capture the file data from</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that completes when the database has been stored</returns>
+ public static Task StoreAsync<T>(this IStorageFacade storage, IChannelContext context, string fileName, IRecordDb<T> store, CancellationToken cancellation)
+ {
+ return storage.StoreAsync($"{context.BaseDir}/{fileName}", store, cancellation);
+ }
+
+ /// <summary>
+ /// Reads the file data from the given path that resides in the given channel context
+ /// and writes it to the given stream
+ /// </summary>
+ /// <param name="storage"></param>
+ /// <param name="context">The channel context to read the file from</param>
+ /// <param name="fileName">The realtive path within the channel to the file</param>
+ /// <param name="stream">The stream to write the file data to from the storage layer</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that resolves the number of bytes read into the output stream</returns>
+ public static Task<long> ReadFileAsync(this IStorageFacade storage, IChannelContext context, string fileName, Stream stream, CancellationToken cancellation)
+ {
+ return storage.ReadFileAsync($"{context.BaseDir}/{fileName}", stream, cancellation);
+ }
+
+ /// <summary>
+ /// Gets the external file path for the given path that exists in the given channel context
+ /// </summary>
+ /// <param name="storage"></param>
+ /// <param name="context">The channel context that contains the item</param>
+ /// <param name="path">The realtive path inside the channel to the item to get the path for</param>
+ /// <returns>The full external path of the item</returns>
+ public static string GetExternalFilePath(this IStorageFacade storage, IChannelContext context, string path)
+ {
+ return storage.GetExternalFilePath($"{context.BaseDir}/{path}");
+ }
+
+ /// <summary>
+ /// Stores the <see cref="IRecordDb{T}"/> at the given file path async
+ /// </summary>
+ /// <typeparam name="T">The record type</typeparam>
+ /// <param name="storage"></param>
+ /// <param name="store">The database to store</param>
+ /// <param name="path">The file path to store the record at</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that completes when the operation has completed</returns>
+ public static async Task StoreAsync<T>(this IStorageFacade storage, string path, IRecordDb<T> 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);
+ }
+
+ /// <summary>
+ /// Creates a new <see cref="IRecordDb{T}"/> from the given object path and populates
+ /// it with records from the file
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="storage"></param>
+ /// <param name="objPath">The path to the stored database file</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>The populated <see cref="IRecordDb{T}"/>, if loading fails (file not found etc) the store will be returned empty</returns>
+ public static async Task<IRecordDb<T>> LoadDbAsync<T>(this IStorageFacade storage, string objPath, CancellationToken cancellation) where T : IRecord
+ {
+ //Create the db
+ IRecordDb<T> db = JsonRecordDb<T>.Create();
+
+ await storage.LoadDbAsync(objPath, db, cancellation);
+
+ return db;
+ }
+
+ /// <summary>
+ /// Populates the given <see cref="IRecordDb{T}"/> with records from the file at the given path
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="storage"></param>
+ /// <param name="objPath">The path to the database file</param>
+ /// <param name="db">The record database store ready to accept the database content</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that completes when the database has been populated</returns>
+ public static async Task LoadDbAsync<T>(this IStorageFacade storage, string objPath, IRecordDb<T> 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);
+ }
+
+ }
+}