aboutsummaryrefslogtreecommitdiff
path: root/back-end/src/Endpoints
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/src/Endpoints
Initial commit
Diffstat (limited to 'back-end/src/Endpoints')
-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
3 files changed, 888 insertions, 0 deletions
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;
+ }
+
+ }
+}