/* * 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()}", }; } } }