From f64955c69d91e578e580b409ba31ac4b3477da96 Mon Sep 17 00:00:00 2001 From: vnugent Date: Wed, 12 Jul 2023 01:28:23 -0400 Subject: Initial commit --- back-end/src/Model/ContentManager.cs | 300 +++++++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 back-end/src/Model/ContentManager.cs (limited to 'back-end/src/Model/ContentManager.cs') diff --git a/back-end/src/Model/ContentManager.cs b/back-end/src/Model/ContentManager.cs new file mode 100644 index 0000000..a0ed94f --- /dev/null +++ b/back-end/src/Model/ContentManager.cs @@ -0,0 +1,300 @@ +/* +* Copyright (c) 2023 Vaughn Nugent +* +* Library: CMNext +* Package: Content.Publishing.Blog.Admin +* File: ContentManager.cs +* +* CMNext is free software: you can redistribute it and/or modify +* it under the terms of the GNU Affero General Public License as +* published by the Free Software Foundation, either version 3 of the +* License, or (at your option) any later version. +* +* CMNext is distributed in the hope that it will be useful, +* but WITHOUT ANY WARRANTY; without even the implied warranty of +* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +* GNU Affero General Public License for more details. +* +* You should have received a copy of the GNU Affero General Public License +* along with this program. If not, see https://www.gnu.org/licenses/. +*/ + +using System; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Content.Publishing.Blog.Admin.Storage; + +using VNLib.Hashing; +using VNLib.Net.Http; +using VNLib.Plugins; +using VNLib.Plugins.Extensions.Loading; + + +namespace Content.Publishing.Blog.Admin.Model +{ + internal sealed class ContentManager + { + private const string ContentIndex = "content.json"; + + private readonly IStorageFacade Storage; + + public ContentManager(PluginBase plugin) + { + //Load the minio client manager + Storage = plugin.GetOrCreateSingleton(); + } + + /// + /// Gets the content meta object for the given content item by its id + /// + /// The channel that contains the desired content + /// The id of the object + /// A token to cancel the operation + /// The content meta item if found in the store + public async Task GetMetaAsync(IChannelContext channel, string metaId, CancellationToken cancellation) + { + //Get the content index + IRecordDb contentIndex = await Storage.LoadDbAsync(channel, ContentIndex, cancellation); + + //Get the content meta + return contentIndex.GetRecord(metaId); + } + + /// + /// Overwrites the content index with the given content index + /// + /// The channel to set the content for + /// The contne meta to update + /// A token to cancel the operation + /// A task that completes when the operation has completed + /// + public async Task SetMetaAsync(IChannelContext channel, ContentMeta meta, CancellationToken cancellation) + { + _ = meta.Id ?? throw new ArgumentNullException(nameof(meta)); + + //Get the content index + IRecordDb contentIndex = await Storage.LoadDbAsync(channel, ContentIndex, cancellation); + + //Set the content meta + contentIndex.SetRecord(meta); + + //Save the content index + await StoreContentIndex(channel, contentIndex, cancellation); + } + + /// + /// Initializes a new content meta object for a new content item + /// + /// The length of the new content item + /// An initializes ready for a new content item + public ContentMeta GetNewMetaObject(long length, string? fileName, ContentType ct) + { + string fileId = RandomHash.GetRandomBase32(16).ToLowerInvariant(); + + return new() + { + Id = fileId, + Length = length, + FileName = fileName, + //File path from ct + FilePath = GetFileNameFromType(fileId, ct) + }; + } + + /// + /// Gets all content items in the given channel + /// + /// The channel to get content items for + /// A token to cancel the operation + /// The collection of content items for the channel + public async Task GetAllContentItemsAsync(IChannelContext context, CancellationToken cancellation) + { + //Get the content index + IRecordDb contentIndex = await Storage.LoadDbAsync(context, ContentIndex, cancellation); + + //Return all content items + return contentIndex.GetRecords().ToArray(); + } + + /// + /// Reads content from the store and writes it to the output stream + /// + /// The channel that contains the content + /// The id of the content item to read + /// The stream to write the file data to + /// A token to cancel the operation + /// The meta object that contains the content metadata if found, null if the content was not found in the directory + public async Task GetContentAsync(IChannelContext context, string metaId, Stream output, CancellationToken cancellation) + { + //Get the content index + IRecordDb contentIndex = await Storage.LoadDbAsync(context, ContentIndex, cancellation); + + //Get the content meta + ContentMeta? meta = contentIndex.GetRecord(metaId); + + //Read the content + if (meta?.Id != null) + { + await Storage.ReadFileAsync(context, GetFilePath(context, meta), output, cancellation); + } + + return meta; + } + + /// + /// Adds content to the store + /// + /// The blog channel to store the data in + /// The content meta of the data to store + /// The data stream to store + /// The content type of the data to store + /// + /// A task that complets when the content has been added to the store + public async Task SetContentAsync(IChannelContext context, ContentMeta meta, Stream data, ContentType ct, CancellationToken cancellation) + { + //Update content type + meta.ContentType = HttpHelpers.GetContentTypeString(ct); + + //update time + meta.Date = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); + + //update length + meta.Length = data.Length; + + //Get the content index + IRecordDb contentIndex = await GetContentIndex(context, cancellation); + + //Add the content meta to the store + contentIndex.SetRecord(meta); + + //try to update the index before writing content + await StoreContentIndex(context, contentIndex, cancellation); + + //Write the content + await Storage.SetObjectDataAsync(context, data, GetFilePath(context, meta), ct, cancellation); + } + + /// + /// Creates a new content item in the store with no content for a given post id + /// + /// The channel context to create the item for + /// The id of the post to create content for + /// A token to cancel the operation + /// A task that represents the async create operation + public async Task CreateNewPostContent(IChannelContext context, string postId, CancellationToken cancellation) + { + //Create the content meta for the post as an empty html file + ContentMeta meta = new() + { + ContentType = HttpHelpers.GetContentTypeString(ContentType.Html), + Date = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + FileName = $"Content for post {postId}", + Id = postId, + Length = 0, + FilePath = GetFileNameFromType(postId, ContentType.Html), + }; + + //Get the content index + IRecordDb contentIndex = await GetContentIndex(context, cancellation); + + //Add the content meta to the store + contentIndex.SetRecord(meta); + + //try to update the index before writing content + await StoreContentIndex(context, contentIndex, cancellation); + } + + /// + /// Deletes content from the store by its id + /// + /// The blog context to delete the item from + /// + /// + /// + public async Task DeleteContentAsync(IChannelContext context, string id, CancellationToken cancellation) + { + //get the content index + IRecordDb contentIndex = await GetContentIndex(context, cancellation); + + //Get the post meta + ContentMeta? meta = contentIndex.GetRecord(id); + + //Delete content before deleting the meta + if (meta?.Id == null) + { + return false; + } + + //Remove the content meta from the store + contentIndex.RemoveRecord(id); + + //Remove the content from storage first + await Storage.RemoveObjectAsync(context, GetFilePath(context, meta), cancellation); + + //Overwrite the content index + await StoreContentIndex(context, contentIndex, cancellation); + + return true; + } + + /// + /// Gets the external path for the given item id. + /// + /// The context the item resides in + /// The id of the item to get the path for + /// A token to cancel the operation + /// The external path of the item, or null if the item does not exist + public async Task GetExternalPathForItemAsync(IChannelContext context, string metaId, CancellationToken cancellation) + { + //Get the content index + IRecordDb contentIndex = await Storage.LoadDbAsync(context, ContentIndex, cancellation); + + //Get the content meta + ContentMeta? meta = contentIndex.GetRecord(metaId); + + //Read the content + if (meta?.Id == null) + { + return null; + } + + //Get the full item path + return Storage.GetExternalFilePath(context, GetFilePath(context, meta)); + } + + + private async Task> GetContentIndex(IChannelContext context, CancellationToken cancellation) + { + //Get the content index + IRecordDb contentIndex = await Storage.LoadDbAsync(context, ContentIndex, cancellation); + + //Return the content index + return contentIndex; + } + + private async Task StoreContentIndex(IChannelContext channel, IRecordDb contentIndex, CancellationToken cancellation) + { + //Store the content index + await Storage.StoreAsync(channel, ContentIndex, contentIndex, cancellation); + } + + + private static string GetFilePath(IChannelContext context, ContentMeta meta) + { + return $"{context.ContentDir}/{meta.FilePath}"; + } + + private static string GetFileNameFromType(string fileId, ContentType type) + { + //Create file path from its id and file extension + return type switch + { + ContentType.Javascript => $"{fileId}.js", + _ => $"{fileId}.{type.ToString().ToLowerInvariant()}", + }; + } + } +} -- cgit