From 2889b3dd10de9a5df42f3f8ab2171b52893ac3f4 Mon Sep 17 00:00:00 2001 From: vnugent Date: Sun, 14 Jan 2024 18:38:58 -0500 Subject: cli-dev branch --- cmnext-cli/README.md | 18 ++ cmnext-cli/Taskfile.yaml | 103 ++++++ cmnext-cli/build.readme.txt | 37 +++ cmnext-cli/src/CMNext.Cli.csproj | 45 +++ cmnext-cli/src/Commands/AuthnticationCommands.cs | 166 ++++++++++ cmnext-cli/src/Commands/ChannelCommands.cs | 371 +++++++++++++++++++++ cmnext-cli/src/Commands/ConfigCommands.cs | 176 ++++++++++ cmnext-cli/src/ConsoleLogProvider.cs | 78 +++++ cmnext-cli/src/Exceptions/Exceptions.cs | 60 ++++ cmnext-cli/src/INamedEntity.cs | 29 ++ cmnext-cli/src/Model/CMNextStoreExtensions.cs | 31 ++ cmnext-cli/src/Model/Channels/ChannelFeed.cs | 46 +++ cmnext-cli/src/Model/Channels/ChannelMeta.cs | 52 +++ cmnext-cli/src/Model/Channels/ChannelStore.cs | 95 ++++++ cmnext-cli/src/Model/Content/ContentMeta.cs | 46 +++ cmnext-cli/src/Model/ICMNextEntity.cs | 29 ++ cmnext-cli/src/Model/ICMNextIndex.cs | 31 ++ cmnext-cli/src/Model/ICMNextStore.cs | 39 +++ cmnext-cli/src/Model/Posts/PostMeta.cs | 106 ++++++ cmnext-cli/src/Program.cs | 164 ++++++++++ cmnext-cli/src/Security/IAuthAdapter.cs | 30 ++ cmnext-cli/src/Security/IPkiCredential.cs | 27 ++ cmnext-cli/src/Security/ISecurityCredential.cs | 29 ++ cmnext-cli/src/Security/IWebAuthenticator.cs | 32 ++ cmnext-cli/src/Security/LoginRequest.cs | 37 +++ cmnext-cli/src/Security/VauthRunner.cs | 84 +++++ cmnext-cli/src/Security/WebAuthenticator.cs | 395 +++++++++++++++++++++++ cmnext-cli/src/Settings/AppConfig.cs | 80 +++++ cmnext-cli/src/Settings/AppSettings.cs | 52 +++ cmnext-cli/src/Site/CMNextEndpointDefintion.cs | 330 +++++++++++++++++++ cmnext-cli/src/Site/CMNextSiteAdapter.cs | 137 ++++++++ cmnext-cli/src/Site/ChannelRequests.cs | 34 ++ cmnext-cli/src/Site/ContentRequests.cs | 38 +++ cmnext-cli/src/Site/PostRequests.cs | 34 ++ cmnext-cli/src/Site/SiteManager.cs | 173 ++++++++++ cmnext-cli/src/Storage/IStorable.cs | 32 ++ cmnext-cli/src/Storage/PersistentDataStore.cs | 154 +++++++++ 37 files changed, 3420 insertions(+) create mode 100644 cmnext-cli/README.md create mode 100644 cmnext-cli/Taskfile.yaml create mode 100644 cmnext-cli/build.readme.txt create mode 100644 cmnext-cli/src/CMNext.Cli.csproj create mode 100644 cmnext-cli/src/Commands/AuthnticationCommands.cs create mode 100644 cmnext-cli/src/Commands/ChannelCommands.cs create mode 100644 cmnext-cli/src/Commands/ConfigCommands.cs create mode 100644 cmnext-cli/src/ConsoleLogProvider.cs create mode 100644 cmnext-cli/src/Exceptions/Exceptions.cs create mode 100644 cmnext-cli/src/INamedEntity.cs create mode 100644 cmnext-cli/src/Model/CMNextStoreExtensions.cs create mode 100644 cmnext-cli/src/Model/Channels/ChannelFeed.cs create mode 100644 cmnext-cli/src/Model/Channels/ChannelMeta.cs create mode 100644 cmnext-cli/src/Model/Channels/ChannelStore.cs create mode 100644 cmnext-cli/src/Model/Content/ContentMeta.cs create mode 100644 cmnext-cli/src/Model/ICMNextEntity.cs create mode 100644 cmnext-cli/src/Model/ICMNextIndex.cs create mode 100644 cmnext-cli/src/Model/ICMNextStore.cs create mode 100644 cmnext-cli/src/Model/Posts/PostMeta.cs create mode 100644 cmnext-cli/src/Program.cs create mode 100644 cmnext-cli/src/Security/IAuthAdapter.cs create mode 100644 cmnext-cli/src/Security/IPkiCredential.cs create mode 100644 cmnext-cli/src/Security/ISecurityCredential.cs create mode 100644 cmnext-cli/src/Security/IWebAuthenticator.cs create mode 100644 cmnext-cli/src/Security/LoginRequest.cs create mode 100644 cmnext-cli/src/Security/VauthRunner.cs create mode 100644 cmnext-cli/src/Security/WebAuthenticator.cs create mode 100644 cmnext-cli/src/Settings/AppConfig.cs create mode 100644 cmnext-cli/src/Settings/AppSettings.cs create mode 100644 cmnext-cli/src/Site/CMNextEndpointDefintion.cs create mode 100644 cmnext-cli/src/Site/CMNextSiteAdapter.cs create mode 100644 cmnext-cli/src/Site/ChannelRequests.cs create mode 100644 cmnext-cli/src/Site/ContentRequests.cs create mode 100644 cmnext-cli/src/Site/PostRequests.cs create mode 100644 cmnext-cli/src/Site/SiteManager.cs create mode 100644 cmnext-cli/src/Storage/IStorable.cs create mode 100644 cmnext-cli/src/Storage/PersistentDataStore.cs diff --git a/cmnext-cli/README.md b/cmnext-cli/README.md new file mode 100644 index 0000000..bf1547e --- /dev/null +++ b/cmnext-cli/README.md @@ -0,0 +1,18 @@ +# cmnext-cli +*A .NET cross-platform command-line tool that helps you manage your cms.* + +## Motivation +I want to easily manage documentation from git repositories to compile markdown files into html, then from the command line, I can easily publish to a CMNext server. It would also generally be useful to have a command line tool to manage content anyway. + +## Builds +Debug build w/ symbols & xml docs, release builds, NuGet packages, and individually packaged source code are available on my website (link below). + +## Docs and Guides +Documentation, specifications, and setup guides are available on my website. + +[Docs and Articles](https://www.vaughnnugent.com/resources/software/articles?tags=docs,_cmnext.cli) +[Builds and Source](https://www.vaughnnugent.com/resources/software/modules/CMNext) +[Nuget Feeds](https://www.vaughnnugent.com/resources/software/modules) + +## License +See [LICENSE](../LICENSE.txt) for license information. \ No newline at end of file diff --git a/cmnext-cli/Taskfile.yaml b/cmnext-cli/Taskfile.yaml new file mode 100644 index 0000000..c184bf8 --- /dev/null +++ b/cmnext-cli/Taskfile.yaml @@ -0,0 +1,103 @@ +# https://taskfile.dev + +version: '3' + +vars: + INT_DIR: '{{.SCRATCH_DIR}}/obj/{{.MODULE_NAME}}/' + TARGET: '{{.USER_WORKING_DIR}}/bin' + RELEASE_DIR: "./bin/release/{{.TARGET_FRAMEWORK}}" + +tasks: + + #when build succeeds, archive the output into a tgz + postbuild_success: + dir: '{{.USER_WORKING_DIR}}' + + cmds: + + #run post in debug mode + - task: postbuild + vars: + BUILD_MODE: debug + TARGET_OS: linux-x64 + + - task: postbuild + vars: + BUILD_MODE: debug + TARGET_OS: win-x64 + + - task: postbuild + vars: + BUILD_MODE: debug + TARGET_OS: osx-x64 + + - task: postbuild + vars: + BUILD_MODE: debug + TARGET_OS: linux-arm + + - task: postbuild + vars: + BUILD_MODE: debug + TARGET_OS: linux-arm64 + + + #remove uncessary files from the release dir + - powershell -Command "Get-ChildItem -Recurse '{{.RELEASE_DIR}}/' -Include *.pdb,*.xml | Remove-Item" + + #run post in release mode + - task: postbuild + vars: + BUILD_MODE: release + TARGET_OS: linux-x64 + + - task: postbuild + vars: + BUILD_MODE: release + TARGET_OS: win-x64 + + - task: postbuild + vars: + BUILD_MODE: release + TARGET_OS: osx-x64 + + - task: postbuild + vars: + BUILD_MODE: release + TARGET_OS: linux-arm + + - task: postbuild + vars: + BUILD_MODE: release + TARGET_OS: linux-arm64 + + #pack up source code and put in output + - powershell -Command "Get-ChildItem -Include *.cs,*.csproj -Recurse | Where { \$_.FullName -notlike '*\obj\*' } | Resolve-Path -Relative | tar --files-from - -czf '{{.TARGET}}/src.tgz'" + + + postbuild_failed: + dir: '{{.USER_WORKING_DIR}}' + cmds: + - echo "postbuild failed {{.PROJECT_NAME}}" + + + postbuild: + dir: '{{.USER_WORKING_DIR}}' + vars: + BUILD_DIR: "{{.USER_WORKING_DIR}}/bin/{{.BUILD_MODE}}/{{.TARGET_FRAMEWORK}}/{{.TARGET_OS}}/publish" + internal: true + cmds: + #copy license and readme to target + - cd .. && powershell -Command "Copy-Item -Path ./build.readme.txt -Destination '{{.BUILD_DIR}}/readme.txt'" + + #tar outputs + - cd "{{.BUILD_DIR}}" && tar -czf "{{.TARGET}}/{{.TARGET_OS}}-{{.BUILD_MODE}}.tgz" . + + +#Remove the output dirs on clean + clean: + dir: '{{.USER_WORKING_DIR}}' + ignore_error: true + cmds: + - cmd: powershell Remove-Item -Recurse './bin' + - cmd: powershell Remove-Item -Recurse './obj' \ No newline at end of file diff --git a/cmnext-cli/build.readme.txt b/cmnext-cli/build.readme.txt new file mode 100644 index 0000000..1ddde93 --- /dev/null +++ b/cmnext-cli/build.readme.txt @@ -0,0 +1,37 @@ +PKiAuthenticator aka vauth, Copyright (c) 2023 Vaughn Nugent + +PkiAuthenticator is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published +by the Free Software Foundation, either version 2 of the License, +or (at your option) any later version. + +PkiAuthenticator 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 +General Public License for more details. + +You should have received a copy of the GNU General Public License +along with PkiAuthenticator. If not, see http://www.gnu.org/licenses/. + + +Contact Information: + +Email: vnpublic@proton.me +Website: http://www.vaughnnugent.com +Documentation: https://www.vaughnnugent.com/resources/software/articles?tags=docs,_PkiAuthenticator +Github Repository: https://github.com/VnUgE/pkiauthenticator + + +Setup: + +To use this application, you must have .NET/6.0 runtime installed on your system. +It can be found here https://dotnet.microsoft.com/en-us/download. It must be installed globally +for the application to work. + +Run the following command to read the man page +vauth -h + + +All set to get started! For more information, please visit the documentation link above, or +the github repository. If you would like to report a bug or a vulnerability, please send +me an email to the adress above. \ No newline at end of file diff --git a/cmnext-cli/src/CMNext.Cli.csproj b/cmnext-cli/src/CMNext.Cli.csproj new file mode 100644 index 0000000..d358be2 --- /dev/null +++ b/cmnext-cli/src/CMNext.Cli.csproj @@ -0,0 +1,45 @@ + + + + Exe + net8.0 + cmnext + enable + CMNext.Cli + + + + cmnext-cli + Vaughn Nugent + Vaughn Nugent + PkiAuthenticator aka vauth + + Copyright © 2024 Vaughn Nugent + https://www.vaughnnugent.com/resources/software/modules/cmnext + https://github.com/VnUgE/cmnext/cmnext-cli + README.md + LICENSE.txt + + + + + True + \ + Always + + + True + \ + + + + + + + + + + + + + diff --git a/cmnext-cli/src/Commands/AuthnticationCommands.cs b/cmnext-cli/src/Commands/AuthnticationCommands.cs new file mode 100644 index 0000000..5e67594 --- /dev/null +++ b/cmnext-cli/src/Commands/AuthnticationCommands.cs @@ -0,0 +1,166 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using CMNext.Cli.Security; +using CMNext.Cli.Site; + +using System; +using System.Security.Authentication; +using System.Threading; +using System.Threading.Tasks; + +using Typin.Attributes; +using Typin.Console; +using Typin.Exceptions; + + +namespace CMNext.Cli.Commands +{ + public sealed class AuthnticationCommands + { + + [Command("auth", Description = "Manages your local authentication data")] + public sealed class AuthCommand : BaseCommand + { } + + [Command("auth login", Description = "Authenticates this client against your CMNext server")] + public sealed class AuthLoginCommand(SiteManager siteManager, VauthRunner vauth, ConsoleLogProvider logger) : BaseCommand + { + [CommandOption("stdin", 's', Description = "Reads the token from stdin instead of using vauth")] + public bool FromStdin { get; set; } + + [CommandOption("force", 'f', Description = "Forces a login even if you already have a valid authorization")] + public bool Force { get; set; } + + /// + public override async ValueTask ExecuteAsync(IConsole console) + { + logger.SetVerbose(Verbose); + + //global cancel + CancellationToken cancellation = console.GetCancellationToken(); + + IPkiCredential token; + + //See if current auth is valid + if(await siteManager.HasValidAuth()) + { + if (!Force) + { + console.WithForegroundColor(ConsoleColor.Green, c => c.Output.WriteLine("You already have a valid authorization!")); + return; + } + } + + if (FromStdin) + { + console.Output.WriteLine("Please enter your PKI token:"); + + //Read the token in from stdin + string? otp = await console.Input.ReadLineAsync(cancellation); + + if (string.IsNullOrWhiteSpace(otp)) + { + throw new CommandException("You must enter a one time login token to continue"); + } + + token = new PkiToken(otp); + } + else + { + console.Output.WriteLine("Getting token from vauth"); + //Get the token from vauth + token = await vauth.GetOptTokenAsync(); + } + + + console.Output.WriteLine("Logging in..."); + + try + { + await siteManager.AuthenticateAsync(token); + console.WithForegroundColor(ConsoleColor.Green, c => c.Output.WriteLine("Login successful!")); + } + catch (AuthenticationException ae) + { + console.WithForegroundColor(ConsoleColor.Red, c => c.Output.WriteLine($"Authentication failed: {ae.Message}")); + } + finally + { + await siteManager.SaveStateAsync(); + } + } + + sealed record class PkiToken(string Token) : IPkiCredential + { + public string GetToken() => Token; + } + } + + [Command("auth logout", Description = "Destroys any local previous login state")] + public sealed class AuthLogoutCommand(SiteManager siteManager, ConsoleLogProvider logger) : BaseCommand + { + /// + public override async ValueTask ExecuteAsync(IConsole console) + { + logger.SetVerbose(Verbose); + + console.Output.WriteLine("Logging out..."); + + try + { + //See if current auth is valid + await siteManager.LogoutAsync(); + console.WithForegroundColor(ConsoleColor.Green, c => c.Output.WriteLine("Logout successful!")); + } + finally + { + await siteManager.SaveStateAsync(); + } + } + + sealed record class PkiToken(string Token) : IPkiCredential + { + public string GetToken() => Token; + } + } + + [Command("auth status", Description = "Gets you client's current authorization status")] + public sealed class AuthStatusCommand(SiteManager siteManager) : BaseCommand + { + public override async ValueTask ExecuteAsync(IConsole console) + { + //global cancel + CancellationToken cancellation = console.GetCancellationToken(); + + console.Output.WriteLine("Checking login status..."); + + if (await siteManager.HasValidAuth()) + { + console.WithForegroundColor(ConsoleColor.Green, c => c.Output.WriteLine("You have a valid authorization")); + } + else + { + console.WithForegroundColor(ConsoleColor.Red, c => c.Output.WriteLine("You do not have a valid authorization")); + } + } + } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Commands/ChannelCommands.cs b/cmnext-cli/src/Commands/ChannelCommands.cs new file mode 100644 index 0000000..f184b0d --- /dev/null +++ b/cmnext-cli/src/Commands/ChannelCommands.cs @@ -0,0 +1,371 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using CMNext.Cli.Exceptions; +using CMNext.Cli.Model.Channels; + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Typin.Attributes; +using Typin.Console; +using Typin.Utilities; + + +namespace CMNext.Cli.Commands +{ + public sealed class ChannelCommands + { + [Command("channels", Description = "Performs operations against blog channels within your cms")] + public sealed class ChannelCommand : BaseCommand + { } + + [Command("channels list", Description = "Lists all blog channels within your cms")] + public sealed class ChannelList(ChannelStore store, ConsoleLogProvider logger) : ListCommand + { + public override string Id { get; set; } = string.Empty; + + [CommandOption("search", 's', Description = "Show only results for a channel by it's name")] + public string Search { get; set; } = string.Empty; + + public override async ValueTask ExecuteAsync(IConsole console) + { + CancellationToken cancellation = console.GetCancellationToken(); + logger.SetVerbose(Verbose); + + ChannelMeta[]? channels = await store.ListAsync(cancellation); + + if (channels is null) + { + console.Error.WriteLine("No channels found"); + return; + } + + if(!string.IsNullOrWhiteSpace(Search)) + { + channels = channels.Where(c => c.Name.Contains(Search, StringComparison.OrdinalIgnoreCase)).ToArray(); + } + + console.WithForegroundColor(ConsoleColor.Green, c => c.Output.WriteLine($"Found {channels.Length} channels")); + + TableUtils.Write( + console.Output, + channels, + ["Id", "Name", "Path", "Index", "Content", "Feed"], + null, + c => c.Id, + c => c.Name, + c => c.Path, + c => $"/{c.IndexFileName}", + c => $"/{c.ContentDir}", + c => (c.Feed != null) ? "*" : "" + ); + } + } + + [Command("channels delete", Description = "Deletes a channel by it's id")] + public sealed class ChannelDelete(ChannelStore store, ConsoleLogProvider logger) : DeleteCommand + { + [CommandOption("channel", 'c', Description = "Specifies the channel to delete", IsRequired = true)] + public override string Id { get; set; } = string.Empty; + + public override async ValueTask ExecuteAsync(IConsole console) + { + CancellationToken cancellation = console.GetCancellationToken(); + logger.SetVerbose(Verbose); + try + { + await store.DeleteAsync(Id, cancellation); + console.WithForegroundColor(ConsoleColor.Green, c => c.Output.WriteLine($"Deleted channel {Id}")); + } + catch (EntityNotFoundException) + { + console.WithForegroundColor(ConsoleColor.Red, c => c.Error.WriteLine($"Channel {Id} does not exist")); + return; + } + } + } + + [Command("channels create", Description = "Creates a new channel")] + public sealed class ChannelCreateCommand(ChannelStore store, ConsoleLogProvider logger) : BaseCommand + { + [CommandOption("name", 'n', Description = "Specifies the name of the channel", IsRequired = true)] + public string Name { get; set; } = string.Empty; + + [CommandOption("path", 'p', Description = "Specifies the path of the channel", IsRequired = true)] + public string Path { get; set; } = string.Empty; + + [CommandOption("index", 'i', Description = "Specifies the index file name of the channel")] + public string Index { get; set; } = "index.json"; + + [CommandOption("content", 'c', Description = "Specifies the content directory of the channel", IsRequired = true)] + public string Content { get; set; } = string.Empty; + + public async override ValueTask ExecuteAsync(IConsole console) + { + CancellationToken cancellation = console.GetCancellationToken(); + logger.SetVerbose(Verbose); + + ChannelMeta channel = new () + { + Name = Name, + Path = Path, + IndexFileName = Index, + ContentDir = Content, + }; + + await store.CreateAsync(channel, cancellation); + console.WithForegroundColor(ConsoleColor.Green, c => c.Output.WriteLine("New channel created successfully")); + } + } + + [Command("channels set", Description = "Updates a channel")] + public sealed class ChannelUpdateCommand(ChannelStore store, ConsoleLogProvider logger) : BaseCommand + { + [CommandOption("channel", 'c', Description = "Specifies the channel to update", IsRequired = true)] + public string Channel { get; set; } = string.Empty; + + [CommandOption("name", 'n', Description = "Specifies the name of the channel")] + public string Name { get; set; } = string.Empty; + + [CommandOption("index", 'i', Description = "Specifies the index file name of the channel")] + public string Index { get; set; } = string.Empty; + + [CommandOption("content", Description = "Specifies the content directory of the channel")] + public string Content { get; set; } = string.Empty; + + public async override ValueTask ExecuteAsync(IConsole console) + { + CancellationToken cancellation = console.GetCancellationToken(); + logger.SetVerbose(Verbose); + + ChannelMeta? channel = await store.GetAsync(Channel, cancellation); + + if (channel is null) + { + console.WithForegroundColor(ConsoleColor.Red, c => c.Error.WriteLine($"Channel {Channel} does not exist")); + return; + } + + console.Output.WriteLine($"Found channel {Channel}"); + + if (!string.IsNullOrWhiteSpace(Name)) + { + channel.Name = Name; + console.Output.WriteLine($"Setting channel name to {Name}"); + } + + if (!string.IsNullOrWhiteSpace(Index)) + { + channel.IndexFileName = Index; + console.Output.WriteLine($"Setting channel index file name to {Index}"); + } + + if (!string.IsNullOrWhiteSpace(Content)) + { + channel.ContentDir = Content; + console.Output.WriteLine($"Setting channel content directory to {Content}"); + } + + await store.UpdateAsync(channel, cancellation); + console.WithForegroundColor(ConsoleColor.Green, c => c.Output.WriteLine("Channel updated successfully")); + } + } + + [Command("channels feed get", Description = "Gets the RSS feed data for a channel if its set")] + public sealed class ChannelFeedGetCommand(ChannelStore store, ConsoleLogProvider logger) : BaseCommand + { + [CommandOption("channel", 'c', Description = "Specifies the channel to get the feed for", IsRequired = true)] + public string Channel { get; set; } = string.Empty; + + public async override ValueTask ExecuteAsync(IConsole console) + { + CancellationToken cancellation = console.GetCancellationToken(); + logger.SetVerbose(Verbose); + + ChannelMeta? channel = await store.GetAsync(Channel, cancellation); + + if (channel is null) + { + console.WithForegroundColor(ConsoleColor.Red, c => c.Error.WriteLine($"Channel {Channel} does not exist")); + return; + } + + console.Output.WriteLine($"Found channel {Channel}"); + + if (channel.Feed is null) + { + console.WithForegroundColor(ConsoleColor.Red, c => c.Error.WriteLine($"Channel {Channel} does not have a feed")); + return; + } + + console.Output.WriteLine($"Found feed for channel {Channel}"); + + console.ForegroundColor = ConsoleColor.Gray; + console.Output.WriteLine($" Path: {channel.Feed.Path}"); + console.Output.WriteLine($" Description: {channel.Feed.Description}"); + console.Output.WriteLine($" Author: {channel.Feed.Author}"); + console.Output.WriteLine($" Contact: {channel.Feed.Contact}"); + console.Output.WriteLine($" Image: {channel.Feed.ImagePath}"); + console.Output.WriteLine($" Link: {channel.Feed.Url}"); + + console.ResetColor(); + } + } + + [Command("channels feed set", Description = "Sets the RSS feed data for a channel")] + public sealed class ChannelFeedSetCommand(ChannelStore store, ConsoleLogProvider logger) : BaseCommand + { + [CommandOption("channel", 'c', Description = "Specifies the channel to set the feed for", IsRequired = true)] + public string Channel { get; set; } = string.Empty; + + [CommandOption("path", 'p', Description = "Specifies the path of the feed")] + public string Path { get; set; } = string.Empty; + + [CommandOption("description", 'd', Description = "Specifies the description of the feed")] + public string Description { get; set; } = string.Empty; + + [CommandOption("author", 'a', Description = "Specifies the author of the feed")] + public string Author { get; set; } = string.Empty; + + [CommandOption("contact", Description = "Specifies the contact email address of the feed")] + public string Contact { get; set; } = string.Empty; + + [CommandOption("image", 'i', Description = "Specifies the image of the feed")] + public string Image { get; set; } = string.Empty; + + [CommandOption("link", 'l', Description = "Specifies the link of the feed")] + public string Link { get; set; } = string.Empty; + + public async override ValueTask ExecuteAsync(IConsole console) + { + CancellationToken cancellation = console.GetCancellationToken(); + logger.SetVerbose(Verbose); + + ChannelMeta? channel = await store.GetAsync(Channel, cancellation); + + if (channel is null) + { + console.WithForegroundColor(ConsoleColor.Red, c => c.Error.WriteLine($"Channel {Channel} does not exist")); + return; + } + + console.Output.WriteLine($"Found channel {Channel}"); + + bool modified = false; + + //Allow creating a new feed if the channel doesn't have one + channel.Feed ??= new ChannelFeed(); + + if (!string.IsNullOrWhiteSpace(Path)) + { + channel.Feed.Path = Path; + console.Output.WriteLine($"Setting feed path to {Path}"); + modified = true; + } + if (!string.IsNullOrWhiteSpace(Contact)) + { + channel.Feed.Contact = Contact; + console.Output.WriteLine($"Setting feed contact to {Contact}"); + modified = true; + } + + if (!string.IsNullOrWhiteSpace(Description)) + { + channel.Feed.Description = Description; + console.Output.WriteLine($"Setting feed description to {Description}"); + modified = true; + } + + if (!string.IsNullOrWhiteSpace(Author)) + { + channel.Feed.Author = Author; + console.Output.WriteLine($"Setting feed author to {Author}"); + modified = true; + } + + if (!string.IsNullOrWhiteSpace(Image)) + { + channel.Feed.ImagePath = Image; + console.Output.WriteLine($"Setting feed image to {Image}"); + modified = true; + } + + if (!string.IsNullOrWhiteSpace(Link)) + { + channel.Feed.Url = Link; + console.Output.WriteLine($"Setting feed link to {Link}"); + modified = true; + } + + if (!modified) + { + console.WithForegroundColor(ConsoleColor.Yellow, c => c.Error.WriteLine($"No changes made to channel {Channel}")); + return; + } + + await store.UpdateAsync(channel, cancellation); + console.WithForegroundColor(ConsoleColor.Green, c => c.Output.WriteLine("Channel feed updated successfully")); + } + + [Command("channels feed delete", Description = "Deletes the RSS feed data for a channel")] + public sealed class ChannelFeedDeleteCommand(ChannelStore store, ConsoleLogProvider logger) : BaseCommand + { + [CommandOption("channel", 'c', Description = "Specifies the channel to delete the feed for", IsRequired = true)] + public string Channel { get; set; } = string.Empty; + + public async override ValueTask ExecuteAsync(IConsole console) + { + CancellationToken cancellation = console.GetCancellationToken(); + logger.SetVerbose(Verbose); + + ChannelMeta? channel = await store.GetAsync(Channel, cancellation); + + if (channel is null) + { + console.WithForegroundColor(ConsoleColor.Red, c => c.Error.WriteLine($"Channel {Channel} does not exist")); + return; + } + + console.Output.WriteLine($"Found channel {Channel}"); + + if (channel.Feed is null) + { + console.WithForegroundColor(ConsoleColor.Red, c => c.Error.WriteLine($"Channel {Channel} does not have a feed")); + return; + } + + console.Output.WriteLine($"Found feed for channel {Channel}"); + + channel.Feed = null; + + await store.UpdateAsync(channel, cancellation); + console.WithForegroundColor(ConsoleColor.Green, c => c.Output.WriteLine("Channel feed deleted successfully")); + } + } + + } + } + + + +} \ No newline at end of file diff --git a/cmnext-cli/src/Commands/ConfigCommands.cs b/cmnext-cli/src/Commands/ConfigCommands.cs new file mode 100644 index 0000000..ff5c439 --- /dev/null +++ b/cmnext-cli/src/Commands/ConfigCommands.cs @@ -0,0 +1,176 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using CMNext.Cli.Settings; + +using System; +using System.Globalization; +using System.Threading.Tasks; + +using Typin.Attributes; +using Typin.Console; + + +namespace CMNext.Cli.Commands +{ + public sealed class ConfigCommands + { + + [Command("config", Description = "Manages this client's configuration data")] + public class ConfigBase : BaseCommand + { } + + [Command("config set", Description = "Sets the configuration of this client")] + public sealed class SetConfigCommand(AppSettings state) : BaseCommand + { + [CommandOption("url", 'u', Description = "The url of your cmnext server")] + public string? Url { get; set; } + + + [CommandOption("channels", 'c', Description = "The path from the base-url to the channels endpoint")] + public string? Channels { get; set; } + + [CommandOption("posts", Description = "The path from the base-url to the posts endpoint")] + public string? Posts { get; set; } + + [CommandOption("content", Description = "The path from the base-url to the auth endpoint")] + public string? Content { get; set; } + + [CommandOption("login", Description = "The path from the base-url to the pki login endpoint")] + public string? Login { get; set; } + + [CommandOption("vauth", Description = "The command to use to execute vauth to generate login passcode")] + public string? Vauth { get; set; } + + /// + public override async ValueTask ExecuteAsync(IConsole console) + { + bool modified = false; + + //get site config + AppConfig siteConfig = await state.GetConfigAsync(); + + if (!string.IsNullOrWhiteSpace(Url)) + { + console.Output.WriteLine($"Setting url to {Url}..."); + siteConfig.BaseAddress = Url; + modified = true; + } + + if (!string.IsNullOrWhiteSpace(Channels)) + { + console.Output.WriteLine($"Setting channel path to {Channels}..."); + siteConfig.Endpoints.ChannelPath = Channels; + modified = true; + } + + if (!string.IsNullOrWhiteSpace(Posts)) + { + console.Output.WriteLine($"Setting post path to {Posts}..."); + siteConfig.Endpoints.PostPath = Posts; + modified = true; + } + + if (!string.IsNullOrWhiteSpace(Content)) + { + console.Output.WriteLine($"Setting content path to {Content}..."); + siteConfig.Endpoints.ContentPath = Content; + modified = true; + } + + if (!string.IsNullOrWhiteSpace(Login)) + { + console.Output.WriteLine($"Setting pki endpoint path to {Login}..."); + siteConfig.Endpoints.LoginPath = Login; + modified = true; + } + + if(!string.IsNullOrWhiteSpace(Vauth)) + { + console.Output.WriteLine($"Setting vauth command to {Vauth}..."); + siteConfig.VauthCommands = Vauth; + modified = true; + } + + //Save config if modified + if (modified) + { + console.WithForegroundColor(ConsoleColor.Gray, io => io.Output.WriteLine("Writing configuration...")); + await state.SaveConfigAsync(siteConfig); + console.WithForegroundColor(ConsoleColor.Green, io => io.Output.WriteLine("Configuration saved.")); + } + else + { + console.Output.WriteLine("No changes to save."); + } + } + } + + [Command("config set endpoints", Description = "Sets the configuration of this client")] + public sealed class SetConfigEndpointsCommand(AppSettings state) : BaseCommand + { + + /// + public override async ValueTask ExecuteAsync(IConsole console) + { + bool modified = false; + + //get site config + AppConfig siteConfig = await state.GetConfigAsync(); + + + + //Save config if modified + if (modified) + { + console.Output.WriteLine("Saving configuration..."); + await state.SaveConfigAsync(siteConfig); + console.Output.WriteLine("Configuration saved."); + } + else + { + console.Output.WriteLine("No changes to save."); + } + } + } + + [Command("config get", Description = "Gets the configuration of this client")] + public sealed class GetConfigCommand(AppSettings state) : BaseCommand + { + /// + public override async ValueTask ExecuteAsync(IConsole console) + { + //get site config + AppConfig siteConfig = await state.GetConfigAsync(); + + console.WithForegroundColor(ConsoleColor.Gray, c => c.Output.WriteLine("Current configuration:")); + + console.Output.WriteLine("vauth: {0}", siteConfig.VauthCommands); + console.Output.WriteLine($"Url: {siteConfig.BaseAddress}"); + + console.Output.WriteLine($"Channels: {siteConfig.Endpoints.ChannelPath}"); + console.Output.WriteLine($"Posts: {siteConfig.Endpoints.PostPath}"); + console.Output.WriteLine($"Content: {siteConfig.Endpoints.ContentPath}"); + console.Output.WriteLine($"Login: {siteConfig.Endpoints.LoginPath}"); + + } + } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/ConsoleLogProvider.cs b/cmnext-cli/src/ConsoleLogProvider.cs new file mode 100644 index 0000000..b38491c --- /dev/null +++ b/cmnext-cli/src/ConsoleLogProvider.cs @@ -0,0 +1,78 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + + +using System; +using VNLib.Utils.Logging; +using Typin.Console; + +namespace CMNext.Cli +{ + public sealed class ConsoleLogProvider(IConsole console) : ILogProvider + { + private LogLevel _level = LogLevel.Information; + + /// + public void Flush() => console.Output.Flush(); + + /// + public object GetLogProvider() => console.Output; + + /// + public bool IsEnabled(LogLevel level) => level >= _level; + + + public void SetLogLevel(LogLevel level) => _level = level; + public void SetVerbose(bool verbose) => SetLogLevel(verbose ? LogLevel.Verbose : LogLevel.Information); + public void SetDebug(bool debug) => SetLogLevel(debug ? LogLevel.Debug : LogLevel.Information); + + /// + public void Write(LogLevel level, string value) + { + if (!IsEnabled(level)) return; + + console.Output.WriteLine("[{0}]: {1}", level, value); + } + + /// + public void Write(LogLevel level, Exception exception, string value = "") + { + if (!IsEnabled(level)) return; + + console.Output.WriteLine("[{0}]: {1}", level, $"{value}\n{exception}"); + } + + /// + public void Write(LogLevel level, string value, params object?[] args) + { + if (!IsEnabled(level)) return; + + console.Output.WriteLine("[{0}]: {1}", level, string.Format(value, args)); + } + + /// + public void Write(LogLevel level, string value, params ValueType[] args) + { + if (!IsEnabled(level)) return; + + console.Output.WriteLine("[{0}]: {1}", level, string.Format(value, args)); + } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Exceptions/Exceptions.cs b/cmnext-cli/src/Exceptions/Exceptions.cs new file mode 100644 index 0000000..162d4b9 --- /dev/null +++ b/cmnext-cli/src/Exceptions/Exceptions.cs @@ -0,0 +1,60 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using System; + + +namespace CMNext.Cli.Exceptions +{ + internal class CMNextException : Exception + { + public CMNextException(string message) : base(message) { } + + public CMNextException(string? message, Exception? innerException) : base(message, innerException) + { } + } + + internal class CMNextApiException : CMNextException + { + public CMNextApiException(string message) : base(message) + { } + + public CMNextApiException(string? message, Exception? innerException) : base(message, innerException) + { } + } + + internal class CMNextPermissionException : CMNextApiException + { + public CMNextPermissionException(string message) : base(message) + { } + + public CMNextPermissionException(string? message, Exception? innerException) : base(message, innerException) + { } + } + + internal sealed class EntityNotFoundException : CMNextApiException + { + public EntityNotFoundException(string message) : base(message) + { } + + public EntityNotFoundException(string? message, Exception? innerException) : base(message, innerException) + { } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/INamedEntity.cs b/cmnext-cli/src/INamedEntity.cs new file mode 100644 index 0000000..dd8b638 --- /dev/null +++ b/cmnext-cli/src/INamedEntity.cs @@ -0,0 +1,29 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using CMNext.Cli.Model; + +namespace CMNext.Cli +{ + internal interface INamedEntity : ICMNextEntity + { + string Name { get; } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Model/CMNextStoreExtensions.cs b/cmnext-cli/src/Model/CMNextStoreExtensions.cs new file mode 100644 index 0000000..482afc4 --- /dev/null +++ b/cmnext-cli/src/Model/CMNextStoreExtensions.cs @@ -0,0 +1,31 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Threading; +using System.Threading.Tasks; + + +namespace CMNext.Cli.Model +{ + internal static class CMNextStoreExtensions + { + + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Model/Channels/ChannelFeed.cs b/cmnext-cli/src/Model/Channels/ChannelFeed.cs new file mode 100644 index 0000000..da1f2f4 --- /dev/null +++ b/cmnext-cli/src/Model/Channels/ChannelFeed.cs @@ -0,0 +1,46 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Text.Json.Serialization; + + +namespace CMNext.Cli.Model.Channels +{ + public sealed class ChannelFeed + { + [JsonPropertyName("url")] + public string Url { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + [JsonPropertyName("image")] + public string? ImagePath { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("author")] + public string? Author { get; set; } + + [JsonPropertyName("contact")] + public string? Contact { get; set; } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Model/Channels/ChannelMeta.cs b/cmnext-cli/src/Model/Channels/ChannelMeta.cs new file mode 100644 index 0000000..d0e626d --- /dev/null +++ b/cmnext-cli/src/Model/Channels/ChannelMeta.cs @@ -0,0 +1,52 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using System; + +using System.Text.Json.Serialization; + + +namespace CMNext.Cli.Model.Channels +{ + + public sealed class ChannelMeta : INamedEntity + { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("date")] + public long Date { get; set; } + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + [JsonPropertyName("index")] + public string IndexFileName { get; set; } = string.Empty; + + [JsonPropertyName("content")] + public string ContentDir { get; set; } = string.Empty; + + [JsonPropertyName("feed")] + public ChannelFeed? Feed { get; set; } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Model/Channels/ChannelStore.cs b/cmnext-cli/src/Model/Channels/ChannelStore.cs new file mode 100644 index 0000000..5d930fa --- /dev/null +++ b/cmnext-cli/src/Model/Channels/ChannelStore.cs @@ -0,0 +1,95 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json.Serialization; + +using CMNext.Cli.Site; + +using VNLib.Net.Rest.Client.Construction; +using System.Linq; + + +namespace CMNext.Cli.Model.Channels +{ + public sealed class ChannelStore(SiteManager site) : ICMNextStore + { + + /// + public async Task CreateAsync(ChannelMeta entity, CancellationToken cancellation) + { + CMNextSiteAdapter cmSite = await site.GetAdapterAsync(); + + await cmSite.BeginRequest(new CreateChannelRequest(entity)) + .ExecAsync(cancellation); + } + + /// + public async Task GetAsync(string id, CancellationToken cancellation) + { + ChannelMeta[] all = await ListAsync(cancellation); + return all.FirstOrDefault(c => c.Id == id); + } + + /// + public async Task ListAsync(CancellationToken cancellation) + { + CMNextSiteAdapter cmSite = await site.GetAdapterAsync(); + + ChannelMeta[]? index = await cmSite + .BeginRequest(new ListChannelRequest()) + .ExecAsync(cancellation) + .AsJson(); + + return index ?? []; + } + + /// + public async Task DeleteAsync(string id, CancellationToken cancellation) + { + CMNextSiteAdapter cmSite = await site.GetAdapterAsync(); + + await cmSite.BeginRequest(new DeleteChannelRequest(id)) + .ExecAsync(cancellation); + } + + /// + public async Task UpdateAsync(ChannelMeta entity, CancellationToken cancellation) + { + CMNextSiteAdapter cmSite = await site.GetAdapterAsync(); + + await cmSite.BeginRequest(new SetChannelRequest(entity)) + .ExecAsync(cancellation); + } + + private sealed class ChannelIndex : ICMNextIndex + { + [JsonPropertyName("date")] + public long Date { get; set; } + + [JsonPropertyName("records")] + public ChannelMeta[] Records { get; set; } = []; + + [JsonPropertyName("version")] + public string Version { get; set; } = "0.0.0"; + } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Model/Content/ContentMeta.cs b/cmnext-cli/src/Model/Content/ContentMeta.cs new file mode 100644 index 0000000..ff25da3 --- /dev/null +++ b/cmnext-cli/src/Model/Content/ContentMeta.cs @@ -0,0 +1,46 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Text.Json.Serialization; + + +namespace CMNext.Cli.Model.Content +{ + internal class ContentMeta : ICMNextEntity + { + [JsonPropertyName("id")] + public string Id { get; set; } + + [JsonPropertyName("date")] + public long Date { get; set; } + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("content_type")] + public string ContentType { get; set; } = string.Empty; + + [JsonPropertyName("length")] + public long Length { get; set; } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Model/ICMNextEntity.cs b/cmnext-cli/src/Model/ICMNextEntity.cs new file mode 100644 index 0000000..1611451 --- /dev/null +++ b/cmnext-cli/src/Model/ICMNextEntity.cs @@ -0,0 +1,29 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +namespace CMNext.Cli.Model +{ + public interface ICMNextEntity + { + string Id { get; } + + long Date { get; } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Model/ICMNextIndex.cs b/cmnext-cli/src/Model/ICMNextIndex.cs new file mode 100644 index 0000000..c25fc7c --- /dev/null +++ b/cmnext-cli/src/Model/ICMNextIndex.cs @@ -0,0 +1,31 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +namespace CMNext.Cli.Model +{ + public interface ICMNextIndex where T : ICMNextEntity + { + long Date { get; } + + T[] Records { get; } + + string Version { get; } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Model/ICMNextStore.cs b/cmnext-cli/src/Model/ICMNextStore.cs new file mode 100644 index 0000000..9cf3163 --- /dev/null +++ b/cmnext-cli/src/Model/ICMNextStore.cs @@ -0,0 +1,39 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using System.Threading; +using System.Threading.Tasks; + + +namespace CMNext.Cli.Model +{ + public interface ICMNextStore where T : ICMNextEntity + { + Task ListAsync(CancellationToken cancellation); + + Task GetAsync(string id, CancellationToken cancellation); + + Task DeleteAsync(string id, CancellationToken cancellation); + + Task CreateAsync(T entity, CancellationToken cancellation); + + Task UpdateAsync(T entity, CancellationToken cancellation); + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Model/Posts/PostMeta.cs b/cmnext-cli/src/Model/Posts/PostMeta.cs new file mode 100644 index 0000000..ce8c0a2 --- /dev/null +++ b/cmnext-cli/src/Model/Posts/PostMeta.cs @@ -0,0 +1,106 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using CMNext.Cli.Site; + +using System; + +using System.Text.Json.Serialization; +using System.Threading; +using System.Threading.Tasks; + +using VNLib.Net.Rest.Client.Construction; + + +namespace CMNext.Cli.Model.Posts +{ + public sealed class PostStore(CMNextSiteAdapter Site, string ChannelId) : ICMNextStore + { + public async Task DeleteAsync(string id, CancellationToken cancellation) + { + await Site.BeginRequest(new DeletePostMetaRequest(ChannelId, id)) + .ExecAsync(cancellation); + } + + public Task CreateAsync(PostMeta entity, CancellationToken cancellation) + { + return Site.BeginRequest(new CreatePostMetaRequest(ChannelId, entity)) + .ExecAsync(cancellation); + } + + public Task GetAsync(string id, CancellationToken cancellation) + { + return Site.BeginRequest(new GetPostMetaRequest(ChannelId, id)) + .ExecAsync(cancellation) + .AsJson()!; + } + + public async Task ListAsync(CancellationToken cancellation) + { + PostMeta[]? index = await Site + .BeginRequest(new ListPostMetaRequest(ChannelId)) + .ExecAsync(cancellation) + .AsJson(); + + //As long as the server is real, this should error or return a valid index. + return index ?? []; + } + + public Task UpdateAsync(PostMeta entity, CancellationToken cancellation) + { + return Site.BeginRequest(new SetPostMetaRequest(ChannelId, entity)) + .ExecAsync(cancellation); + } + } + + public class PostMeta : ICMNextEntity + { + [JsonPropertyName("id")] + public string Id { get; } + + [JsonPropertyName("date")] + public long Date { get; } + + [JsonPropertyName("created")] + public long Created { get; } + + [JsonPropertyName("title")] + public string? Title { get; set; } + + [JsonPropertyName("summary")] + public string? Description { get; set; } + + [JsonPropertyName("tags")] + public string[]? Tags { get; set; } + + [JsonPropertyName("image")] + public string? Image { get; set; } + + [JsonPropertyName("html_description")] + public string? HtmlDescription { get; set; } + + /// + /// Gets a value that an existing post is in podcast mode, and + /// stores a copy of the post's HTML description on the entity + /// record itself. + /// + public bool PodcastMode => Id != null && !string.IsNullOrWhiteSpace(HtmlDescription); + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Program.cs b/cmnext-cli/src/Program.cs new file mode 100644 index 0000000..8e42272 --- /dev/null +++ b/cmnext-cli/src/Program.cs @@ -0,0 +1,164 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using CMNext.Cli.Model.Channels; +using CMNext.Cli.Security; +using CMNext.Cli.Settings; +using CMNext.Cli.Site; +using CMNext.Cli.Storage; + +using Microsoft.Extensions.DependencyInjection; + +using System.IO; +using System.Security.Principal; +using System.Threading.Tasks; + +using Typin; +using Typin.Attributes; +using Typin.Console; +using Typin.Exceptions; + +using VNLib.Plugins; +using VNLib.Utils; +using VNLib.Utils.Logging; +using VNLib.Utils.Memory; + + +namespace CMNext.Cli +{ + + internal sealed class Program + { + internal const string StorePath = "cmnext"; + internal const string AuthFileName = "auth.json"; + internal const string ConfigFileName = "config.json"; + + static int Main(string[] argsv) + { + return new CliApplicationBuilder() + .AddCommandsFromThisAssembly() + .UseConsole() + .UseTitle("CMNext Copyright (c) Vaughn Nugent") + .UseStartupMessage("CMNext Copyright (c) Vaughn Nugent") + .UseVersionText("0.1.4") + .ConfigureServices(services => + { + services.AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton() + .AddSingleton(); + }) + .Build() + .RunAsync() + .AsTask() + .GetAwaiter() + .GetResult(); + } + } + + public abstract class BaseCommand : ICommand + { + [CommandOption("verbose", 'v', Description = "Prints verbose output")] + public bool Verbose { get; set; } + + public virtual ValueTask ExecuteAsync(IConsole console) + { + throw new CommandException("You must specify a sub command listed below", showHelp: true); + } + } + public abstract class ListCommand : BaseCommand + { + public abstract string Id { get; set; } + } + + public abstract class DeleteCommand : BaseCommand + { + public abstract string Id { get; set; } + } + + [Command("post", Description = "Performs operations against posts within your cms")] + public sealed class PostCommand : BaseCommand + { } + + + + //Sub command for posts + [Command("post list", Description = "Lists all posts within your cms")] + public sealed class PostListCommand : ListCommand + { + [CommandOption("channel", 'c', Description = "Specifies the channel to list posts from", IsRequired = true)] + public override string Id { get; set; } = string.Empty; + } + + + public abstract class PostMetaBase : BaseCommand + { + [CommandOption("channel", 'c', Description = "Specifies the channel to list posts from", IsRequired = true)] + public string Channel { get; set; } = string.Empty; + + [CommandOption("post", 'p', Description = "Specifies the id of the post to retrieve", IsRequired = true)] + public string Id { get; set; } = string.Empty; + } + + [Command("post meta", Description = "Interacts with post metadata")] + public sealed class PostMetaCommand : PostMetaBase + { } + + public enum FileFormat + { + Text, + Json, + Xml, + } + + [Command("post meta get", Description = "Retrieves prints the metadata of a desired post to the console")] + public sealed class PostMetaGet: PostMetaBase + { + [CommandOption("file", 'o', Description = "Specifies the json file to write the metadata to")] + public FileInfo? ToFile { get; set; } = null; + + [CommandOption("format", 't', Description = "Specifies the format of the metadata to write to the console", FallbackVariableName = "json")] + public FileFormat Format { get; set; } = FileFormat.Json; + } + + [Command("post meta set", Description = "Sets the metadata of a desired post")] + public sealed class PostMetaSet: PostMetaBase + { + + [CommandOption("file", 'i', Description = "The file to read medatadata from")] + public FileInfo? FromFile { get; set; } = null; + + [CommandOption("tags", Description = "Optional tags to set on the post meta")] + public string[]? Tags { get; set; } = null; + + [CommandOption("description", 'd', Description = "Optional description to set on the post meta")] + public string? Description { get; set; } + + [CommandOption("title", 't', Description = "Optional title to set on the post meta")] + public string? Title { get; set; } + + [CommandOption("author", 'a', Description = "Optional author to set on the post meta")] + public string? Author { get; set; } + } + + +} \ No newline at end of file diff --git a/cmnext-cli/src/Security/IAuthAdapter.cs b/cmnext-cli/src/Security/IAuthAdapter.cs new file mode 100644 index 0000000..e0283d9 --- /dev/null +++ b/cmnext-cli/src/Security/IAuthAdapter.cs @@ -0,0 +1,30 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + + +using VNLib.Net.Rest.Client.Construction; + +namespace CMNext.Cli.Security +{ + public interface IAuthAdapter + { + void SetModifiersForEndpoint(IRestRequestBuilder builder); + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Security/IPkiCredential.cs b/cmnext-cli/src/Security/IPkiCredential.cs new file mode 100644 index 0000000..81d6401 --- /dev/null +++ b/cmnext-cli/src/Security/IPkiCredential.cs @@ -0,0 +1,27 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +namespace CMNext.Cli.Security +{ + public interface IPkiCredential + { + string GetToken(); + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Security/ISecurityCredential.cs b/cmnext-cli/src/Security/ISecurityCredential.cs new file mode 100644 index 0000000..a5db523 --- /dev/null +++ b/cmnext-cli/src/Security/ISecurityCredential.cs @@ -0,0 +1,29 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +namespace CMNext.Cli.Security +{ + public interface ISecurityCredential + { + string PublicKey { get; } + + string ClientId { get; } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Security/IWebAuthenticator.cs b/cmnext-cli/src/Security/IWebAuthenticator.cs new file mode 100644 index 0000000..f3a28e6 --- /dev/null +++ b/cmnext-cli/src/Security/IWebAuthenticator.cs @@ -0,0 +1,32 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + + +using VNLib.Plugins; + +namespace CMNext.Cli.Security +{ + public interface IWebAuthenticator + { + ISecurityCredential PrepareLogin(); + + void FinalizeLogin(ISecurityCredential credential, WebMessage message); + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Security/LoginRequest.cs b/cmnext-cli/src/Security/LoginRequest.cs new file mode 100644 index 0000000..0a74151 --- /dev/null +++ b/cmnext-cli/src/Security/LoginRequest.cs @@ -0,0 +1,37 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + + +using System.Text.Json.Serialization; + +namespace CMNext.Cli.Security +{ + sealed record class LoginRequest(string Login, ISecurityCredential Cred) + { + [JsonPropertyName("login")] + public string Login { get; set; } = Login; + + [JsonPropertyName("clientid")] + public string CliendId { get; set; } = Cred.ClientId; + + [JsonPropertyName("pubkey")] + public string PublicKey { get; set; } = Cred.PublicKey; + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Security/VauthRunner.cs b/cmnext-cli/src/Security/VauthRunner.cs new file mode 100644 index 0000000..71ff8ea --- /dev/null +++ b/cmnext-cli/src/Security/VauthRunner.cs @@ -0,0 +1,84 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using CMNext.Cli.Settings; + +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Typin.Console; +using Typin.Exceptions; + +using VNLib.Utils.Logging; + + +namespace CMNext.Cli.Security +{ + public sealed class VauthRunner(AppSettings settings, IConsole console, ConsoleLogProvider logger) + { + private readonly CancellationToken cancellationToken = console.GetCancellationToken(); + + public async Task GetOptTokenAsync() + { + //Load app config + AppConfig config = await settings.GetConfigAsync(); + + //Get vauth command + string[] vauth = config.VauthCommands.Split(" "); + + logger.Verbose("Executing vauth {0} with arguments", vauth[0], vauth.Skip(1)); + + //Run the executable + ProcessStartInfo psi = new(vauth[0], vauth.Skip(1)) + { + RedirectStandardOutput = true, + CreateNoWindow = true, + ErrorDialog = true + }; + + Process p = Process.Start(psi)!; + + string? result = await p.StandardOutput.ReadLineAsync(cancellationToken); + logger.Verbose("vauth returned {0}", result); + + //Wait for the process to exit + await p.WaitForExitAsync(cancellationToken); + + if (p.ExitCode != 0) + { + throw new CommandException("vauth exited with a non-zero exit code"); + } + + if (string.IsNullOrWhiteSpace(result)) + { + throw new CommandException("vauth did not return a token"); + } + + return new PkiToken(result); + } + + sealed record class PkiToken(string Token) : IPkiCredential + { + public string GetToken() => Token; + } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Security/WebAuthenticator.cs b/cmnext-cli/src/Security/WebAuthenticator.cs new file mode 100644 index 0000000..4a571ca --- /dev/null +++ b/cmnext-cli/src/Security/WebAuthenticator.cs @@ -0,0 +1,395 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + + +using System; +using System.IO; +using System.Net; +using System.Linq; +using System.Text.Json; +using System.Security.Cryptography; +using System.Security.Authentication; + +using RestSharp; + +using FluentValidation; +using VNLib.Utils; +using VNLib.Utils.Memory; +using VNLib.Utils.Extensions; +using VNLib.Plugins; +using VNLib.Hashing; +using VNLib.Hashing.IdentityUtility; +using VNLib.Net.Rest.Client.Construction; + +using CMNext.Cli.Storage; + +namespace CMNext.Cli.Security +{ + internal sealed class WebAuthenticator(Uri SiteBaseAddress) : + VnDisposeable, + IAuthAdapter, + IWebAuthenticator, + IStorable + { + const string OtpHeaderName = "X-Web-Token"; + const string StatusCookieName = "li"; + + public CookieContainer Cookies { get; } = new(); + + private LoginSession _session; + + + /// + /// Determines whether the current session has a valid login + /// or needs to re-authenticate + /// + /// + public bool HasValidLogin() + { + //Find the status cookie and see if its still valid + Cookie? statusCookie = Cookies.GetAllCookies() + .Where(c => !c.Expired) + .Where(c => c.Name == StatusCookieName) + .FirstOrDefault(); + + //Only if we have session data an a valid status cookie + return _session.Initialized && statusCookie != null; + } + + /// + protected override void Free() + { + _session.Destroy(); + } + + /// + public void SetModifiersForEndpoint(IRestRequestBuilder builder) + { + //Set cookies for the request + builder.WithModifier((_, req) => req.CookieContainer = Cookies); + + //Also add the auth token + builder.WithModifier((_, req) => req.AddHeader(OtpHeaderName, ComputeOtp(req))); + + //Set origin header to be safe + builder.WithHeader("Origin", SiteBaseAddress.GetLeftPart(UriPartial.Authority)); + } + + public void Destroy() + { + //Expire all cookies + Cookies.GetAllCookies().TryForeach(static c => c.Expired = true); + _session.Destroy(); + } + + private string ComputeOtp(RestRequest request) + { + if (!_session.Initialized) + { + return string.Empty; + } + + //Get the origin and path from the server uri + string nonce = RandomHash.GetRandomBase32(16); + + using JsonWebToken jwt = new(); + + jwt.InitPayloadClaim(4) + .AddClaim("nonce", nonce) + .AddClaim("iat", DateTimeOffset.UtcNow.ToUnixTimeSeconds()) + .AddClaim("aud", SiteBaseAddress.GetLeftPart(UriPartial.Authority)) + .AddClaim("path", request.Resource) + .CommitClaims(); + + //Sign the jwt with the session data + _session.ComputeSignature(jwt); + return jwt.Compile(); + } + + /// + public ISecurityCredential PrepareLogin() => Credential.Create(); + + /// + public void FinalizeLogin(ISecurityCredential credential, WebMessage message) + { + if (credential is not Credential cred) + { + throw new ArgumentException("The provided credential is not a valid credential", nameof(credential)); + } + + //Create a new login session from the credential and store it + _session = LoginSession.FromCredential(cred, message); + } + + /// + public void Save(Stream stream) + { + using Utf8JsonWriter writer = new(stream); + + writer.WriteStartObject(); + + //Write cookies to stream + { + writer.WriteStartArray("cookies"); + + foreach (Cookie c in Cookies.GetAllCookies().Where(c => !c.Expired)) + { + writer.WriteStartObject(); + + writer.WriteString("name", c.Name); + writer.WriteString("value", c.Value); + writer.WriteString("domain", c.Domain); + writer.WriteString("path", c.Path); + writer.WriteBoolean("http_only", c.HttpOnly); + writer.WriteBoolean("secure", c.Secure); + writer.WriteNumber("expires", new DateTimeOffset(c.Expires).ToUnixTimeMilliseconds()); + + writer.WriteEndObject(); + } + + writer.WriteEndArray(); + } + + //Write secret data + _session.Save(writer); + + writer.WriteEndObject(); + writer.Flush(); + } + + /// + public bool Load(Stream stream) + { + if (stream.Length == 0) + { + return false; + } + + using JsonDocument doc = JsonDocument.Parse(stream); + + //Get cookies element + Cookie[] cookies = doc.RootElement.GetProperty("cookies") + .EnumerateArray() + .Select(c => new Cookie + { + Name = c.GetPropString("name")!, + Value = c.GetPropString("value"), + Domain = c.GetPropString("domain"), + Path = c.GetPropString("path"), + HttpOnly = c.GetProperty("http_only").GetBoolean(), + Secure = c.GetProperty("secure").GetBoolean(), + }) + .ToArray(); + + //Add cookies back to the collection + Array.ForEach(cookies, Cookies.Add); + + //recover session data + _session = LoginSession.Load(doc.RootElement); + return _session.Initialized; + } + + private sealed class Credential : ISecurityCredential + { + private readonly RSA _alg; + + private Credential(RSA alg) => _alg = alg; + + /// + public string PublicKey { get; set; } = string.Empty; + + /// + public string ClientId { get; set; } = string.Empty; + + public static Credential Create() + { + //Init a fresh credential + RSA rsa = RSA.Create(); + byte[] publicKey = rsa.ExportSubjectPublicKeyInfo(); + + return new(rsa) + { + ClientId = RandomHash.GetRandomHex(16), + PublicKey = Convert.ToBase64String(publicKey) + }; + } + + public byte[] ExporPrivateKeyAndErase() + { + byte[] key = _alg.ExportRSAPrivateKey(); + _alg.Dispose(); + return key; + } + + public byte[] DecryptSharedKey(WebMessage response) + { + //Alloc temp buffer for decoding + using UnsafeMemoryHandle buffer = MemoryUtil.UnsafeAllocNearestPage(4000, true); + using UnsafeMemoryHandle decryptBuffer = MemoryUtil.UnsafeAllocNearestPage(4000, true); + + //recover base64 encoded shared key + ERRNO read = VnEncoding.TryFromBase64Chars(response.Token, buffer.Span); + + if (read < 1) + { + throw new AuthenticationException("Failed to decode server's shared data"); + } + + if (!_alg.TryDecrypt(buffer.AsSpan(0, read), decryptBuffer.Span, RSAEncryptionPadding.OaepSHA256, out int written)) + { + throw new AuthenticationException("Failed to decrypt the server's shared data"); + } + + //Return the decrypted data + return decryptBuffer.AsSpan(0, written).ToArray(); + } + } + + + private readonly struct LoginSession + { + const string SecretsKey = "secrets"; + + /// + /// Gets whether the login session has been initialized + /// + public readonly bool Initialized => _privateLKey != null; + + private readonly byte[] _privateLKey; + private readonly byte[] _sharedKey; + + private LoginSession(byte[] privateLKey, byte[] sharedKey) + { + _privateLKey = privateLKey; + _sharedKey = sharedKey; + } + + /// + /// Writes the login session to the provided json writer + /// + /// + /// + public readonly void Save(Utf8JsonWriter writer) + { + if (!Initialized) + { + return; + } + + //Create a secrets element and write the keys to it + writer.WriteStartObject(SecretsKey); + writer.WriteBase64String("private_key", _privateLKey); + writer.WriteBase64String("shared_key", _sharedKey); + writer.WriteEndObject(); + } + + /// + /// Destroys any secret data + /// + public readonly void Destroy() + { + //Clean up keys + if (Initialized) + { + MemoryUtil.InitializeBlock(_privateLKey); + MemoryUtil.InitializeBlock(_sharedKey); + } + } + + + /* + * This function will be used to sign on-time passwords + * that are sent in the header fields for authentication + * by the Essentials.Accounts plugin. + */ + + /// + public readonly void ComputeSignature(JsonWebToken jwt) + { + if (!Initialized) + { + throw new InvalidOperationException("Cannot compute signature from an uninitialized login session"); + } + + + /* + * This much match the server's implementation. Currently configured + * for HMAC-SHA256. + */ + jwt.Sign(_sharedKey, HashAlg.SHA256); + } + + /// + /// Gets a SPKI encoded public key from the stored private key which matches the + /// server format + /// + /// The base64 encoded public key string + /// + public readonly AsymmetricAlgorithm GetAlgorithm() + { + if (!Initialized) + { + throw new InvalidOperationException("Cannot get public key from an uninitialized login session"); + } + + //Create RSA and import the stored private key + RSA rsa = RSA.Create(); + rsa.ImportRSAPrivateKey(_privateLKey, out _); + return rsa; + } + + /// + /// Loads the secret session data from the provided json element + /// + /// The previously stored json data object that contains the secrets element + /// The recovered + public static LoginSession Load(JsonElement data) + { + //Get secrets element + if(!data.TryGetProperty(SecretsKey, out JsonElement secretsEl)) + { + return default; + } + + //Recover keys from element + byte[] privateKey = secretsEl.GetProperty("private_key").GetBytesFromBase64(); + byte[] sharedKey = secretsEl.GetProperty("shared_key").GetBytesFromBase64(); + return new LoginSession(privateKey, sharedKey); + } + + /// + /// Creates a new login session from the provided credential server + /// response web message + /// + /// The existing credential that initiated the login + /// The webmessage server response + /// The new structure containing secret data + public static LoginSession FromCredential(Credential credential, WebMessage webm) + { + //Recover the shared key and private key from the credential + byte[] sharedKey = credential.DecryptSharedKey(webm); + byte[] privKey = credential.ExporPrivateKeyAndErase(); + + //Init new session from keys + return new LoginSession(privKey, sharedKey); + } + } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Settings/AppConfig.cs b/cmnext-cli/src/Settings/AppConfig.cs new file mode 100644 index 0000000..8ec1cbb --- /dev/null +++ b/cmnext-cli/src/Settings/AppConfig.cs @@ -0,0 +1,80 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using System.IO; +using System.Text.Json; +using System.Text.Json.Serialization; + +using CMNext.Cli.Site; +using CMNext.Cli.Storage; + +namespace CMNext.Cli.Settings +{ + public sealed class AppConfig : IStorable + { + [JsonPropertyName("url")] + public string BaseAddress { get; set; } = string.Empty; + + [JsonPropertyName("endpoints")] + public CMNextEndpointMap Endpoints { get; set; } = new(); + + [JsonPropertyName("auth")] + public string VauthCommands { get; set; } = "vauth --silent"; + + bool IStorable.Load(Stream data) + { + if (data.Length == 0) + { + return false; + } + + AppConfig config = JsonSerializer.Deserialize(data) ?? new(); + + //Set values + Endpoints = config.Endpoints; + BaseAddress = config.BaseAddress; + return true; + } + + void IStorable.Save(Stream data) + { + //Write the instance to the stream + JsonSerializer.Serialize(data, this); + } + } + + public sealed class CMNextEndpointMap : ICMNextEndpointMap + { + [JsonPropertyName("channels")] + public string ChannelPath { get; set; } = "/blog/channels"; + + [JsonPropertyName("posts")] + public string PostPath { get; set; } = "/blog/posts"; + + [JsonPropertyName("content")] + public string ContentPath { get; set; } = "/blog/content"; + + [JsonPropertyName("login")] + public string LoginPath { get; set; } = "/account/pki"; + + [JsonPropertyName("logout")] + public string LogoutPath { get; set; } = "/account/logout"; + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Settings/AppSettings.cs b/cmnext-cli/src/Settings/AppSettings.cs new file mode 100644 index 0000000..2dd5117 --- /dev/null +++ b/cmnext-cli/src/Settings/AppSettings.cs @@ -0,0 +1,52 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + + +using System.Threading; +using System.Threading.Tasks; + +using CMNext.Cli.Storage; + +namespace CMNext.Cli.Settings +{ + /// + /// A store for persistent application settings + /// + /// + public sealed class AppSettings(PersistentDataStore store) + { + //Load the config from the disk + private readonly Task _loadtask = store.ReadJsonOrDefaultAsync(Program.ConfigFileName); + + /// + /// Gets the apps global configuration data from the disk + /// + /// The site configuration + public Task GetConfigAsync() => _loadtask; + + /// + /// Saves the provided configuration data to the disk + /// + /// The configuration instannce to store + /// A task that completes when stored + public Task SaveConfigAsync(AppConfig config) + => store.SaveJsonAsync(Program.ConfigFileName, config); + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Site/CMNextEndpointDefintion.cs b/cmnext-cli/src/Site/CMNextEndpointDefintion.cs new file mode 100644 index 0000000..6066779 --- /dev/null +++ b/cmnext-cli/src/Site/CMNextEndpointDefintion.cs @@ -0,0 +1,330 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + + +using RestSharp; + +using System; +using System.Net; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Collections.Generic; +using VNLib.Utils.Extensions; +using VNLib.Utils.Logging; +using VNLib.Net.Rest.Client.Construction; + +using CMNext.Cli.Exceptions; +using CMNext.Cli.Security; +using System.Diagnostics; + +namespace CMNext.Cli.Site +{ + public interface ICMNextEndpointMap + { + string ChannelPath { get; } + + string PostPath { get; } + + string ContentPath { get; } + + string LoginPath { get; } + } + + + public sealed class CMNextEndpointDefintion(ICMNextEndpointMap Endpoints, IAuthAdapter Auth, ILogProvider Logger) : IRestEndpointDefinition + { + public void BuildRequest(IRestSiteAdapter site, IRestEndpointBuilder builder) + { + builder.WithEndpoint() + .WithUrl(Endpoints.ChannelPath) + .AcceptJson() + .WithMethod(Method.Get) + .WithAccessDeniedHandler("You do not have the required permissions to list channels. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + builder.WithEndpoint() + .WithUrl(Endpoints.ChannelPath) + .AcceptJson() + .WithMethod(Method.Get) + .WithQuery("id", p => p.ChannelId) + .WithAccessDeniedHandler("You do not have the required permissions to get a channel. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + builder.WithEndpoint() + .WithUrl(Endpoints.ChannelPath) + .AcceptJson() + .WithMethod(Method.Patch) + .WithBody(r => r.Channel) + .WithAccessDeniedHandler("You do not have the required permissions to update a channel. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + builder.WithEndpoint() + .WithUrl(Endpoints.ChannelPath) + .AcceptJson() + .WithMethod(Method.Post) + .WithBody(r => r.Channel) + .WithAccessDeniedHandler("You do not have the required permissions to create a channel. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + builder.WithEndpoint() + .WithUrl(Endpoints.ChannelPath) + .AcceptJson() + .WithMethod(Method.Delete) + .WithQuery("channel", p => p.ChannelId) + .WithAccessDeniedHandler("You do not have the required permissions to delete a channel. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + //Setup post endpoints + builder.WithEndpoint() + .WithUrl(Endpoints.PostPath) + .AcceptJson() + .WithMethod(Method.Get) + .WithQuery("channel", p => p.ChannelId) + .WithAccessDeniedHandler("You do not have the required permissions to list all posts. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + builder.WithEndpoint() + .WithUrl(Endpoints.PostPath) + .AcceptJson() + .WithMethod(Method.Get) + .WithQuery("channel", p => p.ChannelId) + .WithQuery("post", p => p.PostId) + .WithAccessDeniedHandler("You do not have the required permissions to get a post. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + builder.WithEndpoint() + .WithUrl(Endpoints.PostPath) + .AcceptJson() + .WithMethod(Method.Patch) + .WithBody(r => r.Post) + .WithQuery("channel", p => p.ChannelId) + .WithAccessDeniedHandler("You do not have the required permissions to modify a post. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + builder.WithEndpoint() + .WithUrl(Endpoints.PostPath) + .AcceptJson() + .WithMethod(Method.Post) + .WithBody(r => r.Post) + .WithQuery("channel", p => p.ChannelId) + .WithAccessDeniedHandler("You do not have the required permissions to create a post. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + builder.WithEndpoint() + .WithUrl(Endpoints.PostPath) + .AcceptJson() + .WithMethod(Method.Delete) + .WithQuery("channel", p => p.ChannelId) + .WithQuery("post", p => p.PostId) + .WithAccessDeniedHandler("You do not have the required permissions to delete a post. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + //Setup content endpoints + builder.WithEndpoint() + .WithUrl(Endpoints.ContentPath) + .AcceptJson() + .WithMethod(Method.Get) + .WithQuery("channel", p => p.ChannelId) + .WithAccessDeniedHandler("You do not have the required permissions to list all content. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + builder.WithEndpoint() + .WithUrl(Endpoints.ContentPath) + .AcceptJson() + .WithMethod(Method.Patch) + .WithQuery("channel", p => p.ChannelId) + .WithBody(r => r.Content) + .WithAccessDeniedHandler("You do not have the required permissions to modify content metadata. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + builder.WithEndpoint() + .WithUrl(Endpoints.ContentPath) + .AcceptJson() + .WithMethod(Method.Get) + .WithQuery("channel", p => p.ChannelId) + .WithQuery("id", p => p.ContentId) + .WithQuery("getlink", "true") + .WithAccessDeniedHandler("You do not have the required permissions to get content. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + builder.WithEndpoint() + .WithUrl(Endpoints.ContentPath) + .AcceptJson() + .WithMethod(Method.Delete) + .WithQuery("channel", p => p.ChannelId) + .WithQuery("id", p => p.ContentId) + .WithAccessDeniedHandler("You do not have the required permissions to delete content. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + builder.WithEndpoint() + .WithUrl(Endpoints.ContentPath) + .AcceptJson() + .WithMethod(Method.Delete) + .WithQuery("channel", p => p.ChannelId) + .WithQuery("ids", p => string.Join(',', p.ContentIds)) + .WithAccessDeniedHandler("You do not have the required permissions to delete content. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + builder.WithEndpoint() + .WithUrl(Endpoints.ContentPath) + .AcceptJson() + .WithMethod(Method.Put) + .WithQuery("channel", p => p.ChannelId) + .WithQuery("id", p => p.ContentId!) //Allowed to be null, it will be ignored + .WithHeader("X-Content-Name", p => p.Name) + .WithModifier((r, req) => req.AddFile("file", r.LocalFile.FullName)) //Add the file from its fileinfo + .WithAccessDeniedHandler("You do not have the required permissions to upload content. Access denied") + .WithAuth(Auth) + .WithLogger(Logger); + + //Setup server poke endpoint + } + } + + + + internal static class EndpointExtensions + { + /// + /// Specifes that the desired response Content-Type is of application/json + /// + /// + /// + /// + public static IRestRequestBuilder AcceptJson(this IRestRequestBuilder builder) + { + return builder.WithHeader("Accept", "application/json"); + } + + public static IRestRequestBuilder WithAccessDeniedHandler(this IRestRequestBuilder builder, string message) + { + return builder.OnResponse((_, res) => + { + if (res.StatusCode == HttpStatusCode.Forbidden) + { + throw new CMNextPermissionException(message); + } + }); + } + + public static IRestRequestBuilder WithBody(this IRestRequestBuilder builder, Func body) where TBody : class + { + return builder.WithModifier((t, req) => req.AddJsonBody(body(t))); + } + + public static IRestRequestBuilder WithLogger(this IRestRequestBuilder builder, ILogProvider logger) + { + builder.WithModifier((t, req) => + { + Debug.Assert(req.CookieContainer != null); + string[] cookies = req.CookieContainer!.GetAllCookies().Select(c => $"{c.Name}={c.Value}").ToArray(); + string cookie = string.Join("\n", cookies); + + //List all headers + string[] headers = req.Parameters.Where(p => p.Type == ParameterType.HttpHeader) + .Select(p => $"{p.Name}: {p.Value}") + .ToArray(); + + string h = string.Join("\n", headers); + + logger.Verbose("Sending: {0} {1} HTTP/1.1\n{2}\n{3}\n{4}", req.Method, req.Resource, h, cookie, t); + }); + + builder.OnResponse((_, res) => + { + string[] cookies = res.Cookies!.Select(c => $"{c.Name}={c.Value}").ToArray(); + string cookie = string.Join("\n", cookies); + + //list response headers + string[]? headers = res.Headers?.Select(h => $"{h.Name}: {h.Value}").ToArray(); + string h = string.Join("\n", headers ?? []); + + + logger.Verbose("Received: {0} {1} {2} -> {3} bytes \n{4}\n{5}\n{6}", + res.Request.Resource, + (int)res.StatusCode, + res.StatusCode.ToString(), + res.RawBytes?.Length, + h, + cookie, + res.Content + ); + }); + + return builder; + } + + /// + /// Specifies the authentication adapter for the endpoint + /// + /// + /// + /// The auth adapter to set for the endpoint + /// + public static IRestRequestBuilder WithAuth(this IRestRequestBuilder buider, IAuthAdapter adapter) + { + //Specify adapter for desired endpoint + adapter.SetModifiersForEndpoint(buider); + return buider; + } + + public static PendingRequest BeginRequest(this IRestSiteAdapter site, T request) + => new (site, request); + + public sealed class PendingRequest(IRestSiteAdapter Adapter, T request) + { + + private readonly LinkedList> _beforeExecChain = new(); + + public PendingRequest BeforeRequest(Action beforeRequest) + { + _beforeExecChain.AddLast(beforeRequest); + return this; + } + + public Task ExecAsync(CancellationToken cancellation) + { + _beforeExecChain.TryForeach(p => p.Invoke(request)); + return Adapter.ExecuteAsync(request, cancellation); + } + + public Task> ExecAsync(CancellationToken cancellation) + { + return Adapter.ExecuteAsync(request, cancellation); + } + } + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Site/CMNextSiteAdapter.cs b/cmnext-cli/src/Site/CMNextSiteAdapter.cs new file mode 100644 index 0000000..8cb6a85 --- /dev/null +++ b/cmnext-cli/src/Site/CMNextSiteAdapter.cs @@ -0,0 +1,137 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + + +using RestSharp; + +using System; +using System.Net; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Text.Json; +using System.Text.Json.Serialization; +using VNLib.Net.Rest.Client; +using VNLib.Net.Rest.Client.Construction; + +using FluentValidation; +using FluentValidation.Results; + +using CMNext.Cli.Exceptions; +using CMNext.Cli.Settings; + +namespace CMNext.Cli.Site +{ + public sealed class CMNextSiteAdapter : RestSiteAdapterBase + { + protected override RestClientPool Pool { get; } + + public CMNextSiteAdapter(AppConfig config) + { + Uri baseUri = new(config.BaseAddress); + + RestClientOptions options = new(baseUri) + { + RemoteCertificateValidationCallback = (_, _, _, err) => true, + AutomaticDecompression = DecompressionMethods.All, + Encoding = System.Text.Encoding.UTF8, + ThrowOnAnyError = false, + ThrowOnDeserializationError = true, + FollowRedirects = false, + UserAgent = "vnuge/cmnext-cli", + }; + + Pool = new RestClientPool(2, options); + } + + /// + public override Task WaitAsync(CancellationToken cancellation = default) => Task.CompletedTask; + + /// + public override void OnResponse(RestResponse response) + { + //always see if a json web-message error was returned + ParseErrorAndThrow(response); + + switch (response.StatusCode) + { + case HttpStatusCode.InternalServerError: + throw new CMNextApiException("The server encountered an internal error"); + case HttpStatusCode.NotFound: + throw new EntityNotFoundException("The requested entity was not found"); + case HttpStatusCode.Forbidden: + throw new CMNextPermissionException("You do not have the required permissions to perform this action. Access Denied"); + case HttpStatusCode.Unauthorized: + throw new CMNextPermissionException("Your credentials are invalid or expired. Access Denied"); + case HttpStatusCode.Conflict: + throw new CMNextApiException("The requested action could not be completed due to a conflict"); + default: + response.ThrowIfError(); + break; + } + } + + private static void ParseErrorAndThrow(RestResponse response) + { + if (response.RawBytes == null || response.ContentType != "application/json") + { + return; + } + + using JsonDocument doc = JsonDocument.Parse(response.RawBytes); + + //Webmessage must be an object + if(doc.RootElement.ValueKind != JsonValueKind.Object) + { + return; + } + + //Check for validation errors and raise them + if(doc.RootElement.TryGetProperty("errors", out JsonElement errors)) + { + //Desserilize the errors into a validation failure + ValidationFailure[] err = errors.EnumerateArray() + .Select(e => e.Deserialize()!) + .Select(e => new ValidationFailure(e.PropertyName, e.ErrorMessage)) + .ToArray(); + + //Raise a fluent validation exception from the server results + throw new ValidationException(err); + } + + //Get result now, we don't know it's type yet + _ = doc.RootElement.TryGetProperty("result", out JsonElement result); + + if (doc.RootElement.TryGetProperty("success", out JsonElement success)) + { + //If the request was not successful, throw an exception, a result will be a string + if (!success.GetBoolean()) + { + throw new CMNextException(result.GetString()!); + } + } + } + + internal record ServerValidationJson( + [property: JsonPropertyName("property")] string? PropertyName, + [property: JsonPropertyName("message")] string? ErrorMessage + ); + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Site/ChannelRequests.cs b/cmnext-cli/src/Site/ChannelRequests.cs new file mode 100644 index 0000000..c004636 --- /dev/null +++ b/cmnext-cli/src/Site/ChannelRequests.cs @@ -0,0 +1,34 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using CMNext.Cli.Model.Channels; + +namespace CMNext.Cli.Site +{ + internal sealed class ListChannelRequest() { } + + internal sealed record class GetChannelRequest(string ChannelId); + + internal sealed record class SetChannelRequest(ChannelMeta Channel); + + internal sealed record class CreateChannelRequest(ChannelMeta Channel); + + internal sealed record class DeleteChannelRequest(string ChannelId); +} \ No newline at end of file diff --git a/cmnext-cli/src/Site/ContentRequests.cs b/cmnext-cli/src/Site/ContentRequests.cs new file mode 100644 index 0000000..dffeda9 --- /dev/null +++ b/cmnext-cli/src/Site/ContentRequests.cs @@ -0,0 +1,38 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using System.IO; + +using CMNext.Cli.Model.Content; + +namespace CMNext.Cli.Site +{ + sealed record class ListContentRequest(string ChannelId); + + sealed record class GetContentLinkRequest(string ChannelId, string ContentId); + + sealed record class DeleteContentRequest(string ChannelId, string ContentId); + + sealed record class DeleteBulkContentRequest(string ChannelId, string[] ContentIds); + + internal sealed record class UploadFileRequest(string ChannelId, string? ContentId, string Name, FileInfo LocalFile); + + internal sealed record class SetContentMetaRequest(string ChannelId, ContentMeta Content); +} \ No newline at end of file diff --git a/cmnext-cli/src/Site/PostRequests.cs b/cmnext-cli/src/Site/PostRequests.cs new file mode 100644 index 0000000..0f935fa --- /dev/null +++ b/cmnext-cli/src/Site/PostRequests.cs @@ -0,0 +1,34 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + +using CMNext.Cli.Model.Posts; + +namespace CMNext.Cli.Site +{ + internal sealed record class GetPostMetaRequest(string ChannelId, string PostId); + + internal sealed record class ListPostMetaRequest(string ChannelId); + + internal sealed record class SetPostMetaRequest(string ChannelId, PostMeta Post); + + internal sealed record class CreatePostMetaRequest(string ChannelId, PostMeta Post); + + internal sealed record class DeletePostMetaRequest(string ChannelId, string PostId); +} \ No newline at end of file diff --git a/cmnext-cli/src/Site/SiteManager.cs b/cmnext-cli/src/Site/SiteManager.cs new file mode 100644 index 0000000..18a71a6 --- /dev/null +++ b/cmnext-cli/src/Site/SiteManager.cs @@ -0,0 +1,173 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + + +using RestSharp; +using System.Threading; +using System.Threading.Tasks; +using System.Security.Authentication; + +using VNLib.Plugins; +using VNLib.Net.Rest.Client.Construction; + +using Typin.Console; + +using CMNext.Cli.Exceptions; +using CMNext.Cli.Settings; +using CMNext.Cli.Security; +using CMNext.Cli.Storage; + +namespace CMNext.Cli.Site +{ + public sealed class SiteManager(AppSettings state, PersistentDataStore data, ConsoleLogProvider logger, IConsole console) + { + readonly Task _lazyAuth = GetAuthenticatorAsync(state, data); + readonly CancellationToken cancellation = console.GetCancellationToken(); + + static async Task GetAuthenticatorAsync(AppSettings Config, PersistentDataStore Data) + { + //Load site configuration + AppConfig site = await Config.GetConfigAsync(); + + WebAuthenticator wa = new(new(site.BaseAddress)); + + //try to load the auth data from the store + _ = await Data.RestoreAsync(Program.AuthFileName, wa); + + //Return the authenticator regardless of success + return wa; + } + + public async Task HasValidAuth() => (await _lazyAuth).HasValidLogin(); + + /// + /// Gets the site adapter for the current site and configures + /// it + /// + /// + public async Task GetAdapterAsync() + { + //Get the site configuration + AppConfig config = await state.GetConfigAsync(); + WebAuthenticator man = await _lazyAuth; + + //Init endpoint routes + CMNextEndpointDefintion endpoints = new(config.Endpoints, man, logger); + + //Create a new site adapter and build the endpoints + CMNextSiteAdapter adapter = new(config); + adapter.BuildEndpoints(endpoints); + + //Set internal poke endpoint + adapter.DefineSingleEndpoint() + .WithEndpoint() + .WithUrl("/") + .WithMethod(Method.Get) + .WithModifier((_, r) => r.CookieContainer = man.Cookies) + .WithLogger(logger); + + //Set login endpoint + adapter.DefineSingleEndpoint() + .WithEndpoint() + .WithUrl(config.Endpoints.LoginPath) + .WithMethod(Method.Post) + .WithBody(r => r) + .WithModifier((_, r) => r.CookieContainer = man.Cookies) + .WithLogger(logger); + + return adapter; + } + + /// + /// Attempts to authenticate the provided site with the provided credentials + /// + /// The cmnext site adapter to connect to + /// The authenticator to use for the connection + /// + /// + /// + /// + public async Task AuthenticateAsync(IPkiCredential token) + { + //Wait for web auth + WebAuthenticator auth = await _lazyAuth; + CMNextSiteAdapter adapter = await GetAdapterAsync(); + + //Prepare new login credentials + ISecurityCredential cred = auth.PrepareLogin(); + + /* + * We must poke the server before we can send the login + * request to make sure we have a valid session cookie + * ready for an upgrade + */ + await PokeServerAsync(adapter); + + //Create a new login request + LoginRequest request = new(token.GetToken(), cred); + + //Send the login request + WebMessage response = (await adapter.ExecuteAsync(request, cancellation).AsJson())!; + + //Check for success and throw result string if not + if (!response.Success) + { + throw new AuthenticationException(response.Result!.ToString()); + } + + //Finalize the login + auth.FinalizeLogin(cred, response); + } + + /// + /// Destroys the current authentication session + /// + /// + public async Task LogoutAsync() + { + //Wait for web auth + WebAuthenticator auth = await _lazyAuth; + auth.Destroy(); + } + + private async Task PokeServerAsync(CMNextSiteAdapter site) + { + try + { + await site.BeginRequest(new ServerPokeRequest()) + .ExecAsync(cancellation); + } + catch (CMNextPermissionException) + { + //its okay if there was a permission exception during poke + } + } + + public async Task SaveStateAsync() + { + //Save the authenticator state + WebAuthenticator auth = await _lazyAuth; + await data.SaveAsync(Program.AuthFileName, auth); + } + + sealed record class ServerPokeRequest(); + + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Storage/IStorable.cs b/cmnext-cli/src/Storage/IStorable.cs new file mode 100644 index 0000000..4055bc2 --- /dev/null +++ b/cmnext-cli/src/Storage/IStorable.cs @@ -0,0 +1,32 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + + +using System.IO; + +namespace CMNext.Cli.Storage +{ + public interface IStorable + { + bool Load(Stream data); + + void Save(Stream data); + } +} \ No newline at end of file diff --git a/cmnext-cli/src/Storage/PersistentDataStore.cs b/cmnext-cli/src/Storage/PersistentDataStore.cs new file mode 100644 index 0000000..93f7284 --- /dev/null +++ b/cmnext-cli/src/Storage/PersistentDataStore.cs @@ -0,0 +1,154 @@ +/* +* Copyright (c) 2024 Vaughn Nugent +* +* Package: CMNext.Cli +* File: Program.cs +* +* CMNext.Cli is free software: you can redistribute it and/or modify +* it under the terms of the GNU General Public License as published +* by the Free Software Foundation, either version 2 of the License, +* or (at your option) any later version. +* +* CMNext.Cli 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 +* General Public License for more details. +* +* You should have received a copy of the GNU General Public License +* along with CMNext.Cli. If not, see http://www.gnu.org/licenses/. +*/ + + +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.IO.IsolatedStorage; + +using VNLib.Utils.Extensions; + +using VNLib.Utils.IO; +using VNLib.Utils.Memory; +using Typin.Console; + +namespace CMNext.Cli.Storage +{ + public sealed class PersistentDataStore + { + private readonly IsolatedStorageDirectory _dir; + private readonly CancellationToken _cancellation; + + /// + /// Creates a new isolated storage store with the provided name + /// that can be used to store and retreive + /// data. + /// + /// The directory name to store data in + /// The new + public PersistentDataStore(IConsole console) + { + //Isolated storage for the current user scoped to the application + IsolatedStorageFile isf = IsolatedStorageFile.GetUserStoreForApplication(); + _dir = new(isf, Program.StorePath); + _cancellation = console.GetCancellationToken(); + } + + /// Restores the contents of the specified file to the provided entity + /// + /// + /// + /// + /// + /// + public async Task RestoreAsync(string fileName, T entity) where T : IStorable + { + if (!_dir.FileExists(fileName)) + { + return false; + } + + using VnMemoryStream _memStream = new(); + + //read the file into memory + await using (IsolatedStorageFileStream stream = _dir.OpenFile(fileName, FileMode.Open, FileAccess.Read)) + { + await stream.CopyToAsync(_memStream, 4096, MemoryUtil.Shared, _cancellation); + } + + //reset the stream + _memStream.Seek(0, SeekOrigin.Begin); + + //load the entity from the stream + return entity.Load(_memStream); + } + + /// + /// Stores the contents of the provided entity to the specified file + /// + /// + /// The name of the file to write + /// The storable entity to store + /// A task that resolves when the save has been completed + public async Task SaveAsync(string fileName, T entity) where T : IStorable + { + using VnMemoryStream _memStream = new(); + + //save the entity to the memory stream + entity.Save(_memStream); + + //reset the stream + _memStream.Seek(0, SeekOrigin.Begin); + + //write the stream to the file + await using IsolatedStorageFileStream stream = _dir.OpenFile(fileName, FileMode.Create, FileAccess.Write); + await _memStream.CopyToAsync(stream, 4096, _cancellation); + } + + /// + /// Reads the contents of the specified file as a json object directly + /// + /// + /// The name of the file to read + /// A token to cancel the operation + /// A task that resolves the object if found, null otherwise + public async Task ReadJsonAsync(string fileName) where T : class + { + //Make sure file exists + if (!_dir.FileExists(fileName)) + { + return null; + } + + //Read the file directly into the desserializer + await using IsolatedStorageFileStream stream = _dir.OpenFile(fileName, FileMode.Open, FileAccess.Read); + + return await JsonSerializer.DeserializeAsync(stream, cancellationToken: _cancellation); + } + + /// + /// Reads the contents of the specified file as a json object directly + /// or returns a new instance of the object if the file does not exist + /// + /// + /// The name of the file to read data from + /// A task that resolves the read object + public async Task ReadJsonOrDefaultAsync(string fileName) where T : class, new() + { + T? result = await ReadJsonAsync(fileName); + return result ?? new(); + } + + /// + /// Saves the provided entity as a json object to the specified file + /// + /// + /// The name of the file to write data to + /// The object to store + /// A task that resolves when the data has been stored + public async Task SaveJsonAsync(string fileName, T entity) where T : class + { + await using IsolatedStorageFileStream stream = _dir.OpenFile(fileName, FileMode.Create, FileAccess.Write); + await JsonSerializer.SerializeAsync(stream, entity, cancellationToken: _cancellation); + } + } +} \ No newline at end of file -- cgit