aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLibravatar vnugent <public@vaughnnugent.com>2024-01-14 18:38:58 -0500
committerLibravatar vnugent <public@vaughnnugent.com>2024-01-14 18:38:58 -0500
commit2889b3dd10de9a5df42f3f8ab2171b52893ac3f4 (patch)
treede8c02ae251e2b5cc4b1db6a0ce10ecb7fb89119
parentbfeadbf69f60effe405609ba15bec57eca989ade (diff)
cli-dev branch
-rw-r--r--cmnext-cli/README.md18
-rw-r--r--cmnext-cli/Taskfile.yaml103
-rw-r--r--cmnext-cli/build.readme.txt37
-rw-r--r--cmnext-cli/src/CMNext.Cli.csproj45
-rw-r--r--cmnext-cli/src/Commands/AuthnticationCommands.cs166
-rw-r--r--cmnext-cli/src/Commands/ChannelCommands.cs371
-rw-r--r--cmnext-cli/src/Commands/ConfigCommands.cs176
-rw-r--r--cmnext-cli/src/ConsoleLogProvider.cs78
-rw-r--r--cmnext-cli/src/Exceptions/Exceptions.cs60
-rw-r--r--cmnext-cli/src/INamedEntity.cs29
-rw-r--r--cmnext-cli/src/Model/CMNextStoreExtensions.cs31
-rw-r--r--cmnext-cli/src/Model/Channels/ChannelFeed.cs46
-rw-r--r--cmnext-cli/src/Model/Channels/ChannelMeta.cs52
-rw-r--r--cmnext-cli/src/Model/Channels/ChannelStore.cs95
-rw-r--r--cmnext-cli/src/Model/Content/ContentMeta.cs46
-rw-r--r--cmnext-cli/src/Model/ICMNextEntity.cs29
-rw-r--r--cmnext-cli/src/Model/ICMNextIndex.cs31
-rw-r--r--cmnext-cli/src/Model/ICMNextStore.cs39
-rw-r--r--cmnext-cli/src/Model/Posts/PostMeta.cs106
-rw-r--r--cmnext-cli/src/Program.cs164
-rw-r--r--cmnext-cli/src/Security/IAuthAdapter.cs30
-rw-r--r--cmnext-cli/src/Security/IPkiCredential.cs27
-rw-r--r--cmnext-cli/src/Security/ISecurityCredential.cs29
-rw-r--r--cmnext-cli/src/Security/IWebAuthenticator.cs32
-rw-r--r--cmnext-cli/src/Security/LoginRequest.cs37
-rw-r--r--cmnext-cli/src/Security/VauthRunner.cs84
-rw-r--r--cmnext-cli/src/Security/WebAuthenticator.cs395
-rw-r--r--cmnext-cli/src/Settings/AppConfig.cs80
-rw-r--r--cmnext-cli/src/Settings/AppSettings.cs52
-rw-r--r--cmnext-cli/src/Site/CMNextEndpointDefintion.cs330
-rw-r--r--cmnext-cli/src/Site/CMNextSiteAdapter.cs137
-rw-r--r--cmnext-cli/src/Site/ChannelRequests.cs34
-rw-r--r--cmnext-cli/src/Site/ContentRequests.cs38
-rw-r--r--cmnext-cli/src/Site/PostRequests.cs34
-rw-r--r--cmnext-cli/src/Site/SiteManager.cs173
-rw-r--r--cmnext-cli/src/Storage/IStorable.cs32
-rw-r--r--cmnext-cli/src/Storage/PersistentDataStore.cs154
37 files changed, 3420 insertions, 0 deletions
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 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+ <PropertyGroup>
+ <OutputType>Exe</OutputType>
+ <TargetFramework>net8.0</TargetFramework>
+ <AssemblyName>cmnext</AssemblyName>
+ <Nullable>enable</Nullable>
+ <RootNamespace>CMNext.Cli</RootNamespace>
+ </PropertyGroup>
+
+ <PropertyGroup>
+ <PackageId>cmnext-cli</PackageId>
+ <Authors>Vaughn Nugent</Authors>
+ <Company>Vaughn Nugent</Company>
+ <Product>PkiAuthenticator aka vauth</Product>
+ <Description></Description>
+ <Copyright>Copyright © 2024 Vaughn Nugent</Copyright>
+ <PackageProjectUrl>https://www.vaughnnugent.com/resources/software/modules/cmnext</PackageProjectUrl>
+ <RepositoryUrl>https://github.com/VnUgE/cmnext/cmnext-cli</RepositoryUrl>
+ <PackageReadmeFile>README.md</PackageReadmeFile>
+ <PackageLicenseFile>LICENSE.txt</PackageLicenseFile>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <None Include="..\..\LICENSE.txt">
+ <Pack>True</Pack>
+ <PackagePath>\</PackagePath>
+ <CopyToOutputDirectory>Always</CopyToOutputDirectory>
+ </None>
+ <None Include="..\README.md">
+ <Pack>True</Pack>
+ <PackagePath>\</PackagePath>
+ </None>
+ </ItemGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Typin" Version="3.1.0" />
+ <PackageReference Include="VNLib.Hashing.Portable" Version="0.1.0-ci0109" />
+ <PackageReference Include="VNLib.Net.Rest.Client" Version="0.1.0-ci0109" />
+ <PackageReference Include="VNLib.Utils" Version="0.1.0-ci0109" />
+ <PackageReference Include="VNLib.Plugins" Version="0.1.0-ci0109" />
+ <PackageReference Include="VNLib.Plugins.Extensions.Validation" Version="0.1.0-ci0047" />
+ </ItemGroup>
+
+</Project>
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; }
+
+ ///<inheritdoc/>
+ 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
+ {
+ ///<inheritdoc/>
+ 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; }
+
+ ///<inheritdoc/>
+ 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
+ {
+
+ ///<inheritdoc/>
+ 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
+ {
+ ///<inheritdoc/>
+ 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;
+
+ ///<inheritdoc/>
+ public void Flush() => console.Output.Flush();
+
+ ///<inheritdoc/>
+ public object GetLogProvider() => console.Output;
+
+ ///<inheritdoc/>
+ 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);
+
+ ///<inheritdoc/>
+ public void Write(LogLevel level, string value)
+ {
+ if (!IsEnabled(level)) return;
+
+ console.Output.WriteLine("[{0}]: {1}", level, value);
+ }
+
+ ///<inheritdoc/>
+ public void Write(LogLevel level, Exception exception, string value = "")
+ {
+ if (!IsEnabled(level)) return;
+
+ console.Output.WriteLine("[{0}]: {1}", level, $"{value}\n{exception}");
+ }
+
+ ///<inheritdoc/>
+ public void Write(LogLevel level, string value, params object?[] args)
+ {
+ if (!IsEnabled(level)) return;
+
+ console.Output.WriteLine("[{0}]: {1}", level, string.Format(value, args));
+ }
+
+ ///<inheritdoc/>
+ 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<ChannelMeta>
+ {
+
+ ///<inheritdoc/>
+ public async Task CreateAsync(ChannelMeta entity, CancellationToken cancellation)
+ {
+ CMNextSiteAdapter cmSite = await site.GetAdapterAsync();
+
+ await cmSite.BeginRequest(new CreateChannelRequest(entity))
+ .ExecAsync(cancellation);
+ }
+
+ ///<inheritdoc/>
+ public async Task<ChannelMeta?> GetAsync(string id, CancellationToken cancellation)
+ {
+ ChannelMeta[] all = await ListAsync(cancellation);
+ return all.FirstOrDefault(c => c.Id == id);
+ }
+
+ ///<inheritdoc/>
+ public async Task<ChannelMeta[]> ListAsync(CancellationToken cancellation)
+ {
+ CMNextSiteAdapter cmSite = await site.GetAdapterAsync();
+
+ ChannelMeta[]? index = await cmSite
+ .BeginRequest(new ListChannelRequest())
+ .ExecAsync(cancellation)
+ .AsJson<ChannelMeta[]>();
+
+ return index ?? [];
+ }
+
+ ///<inheritdoc/>
+ public async Task DeleteAsync(string id, CancellationToken cancellation)
+ {
+ CMNextSiteAdapter cmSite = await site.GetAdapterAsync();
+
+ await cmSite.BeginRequest(new DeleteChannelRequest(id))
+ .ExecAsync(cancellation);
+ }
+
+ ///<inheritdoc/>
+ 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<ChannelMeta>
+ {
+ [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<T> 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<T> where T : ICMNextEntity
+ {
+ Task<T[]> ListAsync(CancellationToken cancellation);
+
+ Task<T?> 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<PostMeta>
+ {
+ 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<PostMeta?> GetAsync(string id, CancellationToken cancellation)
+ {
+ return Site.BeginRequest(new GetPostMetaRequest(ChannelId, id))
+ .ExecAsync(cancellation)
+ .AsJson<PostMeta>()!;
+ }
+
+ public async Task<PostMeta[]> ListAsync(CancellationToken cancellation)
+ {
+ PostMeta[]? index = await Site
+ .BeginRequest(new ListPostMetaRequest(ChannelId))
+ .ExecAsync(cancellation)
+ .AsJson<PostMeta[]>();
+
+ //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; }
+
+ /// <summary>
+ /// 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.
+ /// </summary>
+ 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<SystemConsole>()
+ .UseTitle("CMNext Copyright (c) Vaughn Nugent")
+ .UseStartupMessage("CMNext Copyright (c) Vaughn Nugent")
+ .UseVersionText("0.1.4")
+ .ConfigureServices(services =>
+ {
+ services.AddSingleton<PersistentDataStore>()
+ .AddSingleton<AppSettings>()
+ .AddSingleton<SiteManager>()
+ .AddSingleton<VauthRunner>()
+ .AddSingleton<ChannelStore>()
+ .AddSingleton<ConsoleLogProvider>();
+ })
+ .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<T>(IRestRequestBuilder<T> 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<IPkiCredential> 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;
+
+
+ /// <summary>
+ /// Determines whether the current session has a valid login
+ /// or needs to re-authenticate
+ /// </summary>
+ /// <returns></returns>
+ 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;
+ }
+
+ ///<inheritdoc/>
+ protected override void Free()
+ {
+ _session.Destroy();
+ }
+
+ ///<inheritdoc/>
+ public void SetModifiersForEndpoint<T>(IRestRequestBuilder<T> 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();
+ }
+
+ ///<inheritdoc/>
+ public ISecurityCredential PrepareLogin() => Credential.Create();
+
+ ///<inheritdoc/>
+ 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);
+ }
+
+ ///<inheritdoc/>
+ 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();
+ }
+
+ ///<inheritdoc/>
+ 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;
+
+ ///<inheritdoc/>
+ public string PublicKey { get; set; } = string.Empty;
+
+ ///<inheritdoc/>
+ 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<byte> buffer = MemoryUtil.UnsafeAllocNearestPage(4000, true);
+ using UnsafeMemoryHandle<byte> 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";
+
+ /// <summary>
+ /// Gets whether the login session has been initialized
+ /// </summary>
+ public readonly bool Initialized => _privateLKey != null;
+
+ private readonly byte[] _privateLKey;
+ private readonly byte[] _sharedKey;
+
+ private LoginSession(byte[] privateLKey, byte[] sharedKey)
+ {
+ _privateLKey = privateLKey;
+ _sharedKey = sharedKey;
+ }
+
+ /// <summary>
+ /// Writes the login session to the provided json writer
+ /// </summary>
+ /// <param name="writer"></param>
+ /// <exception cref="InvalidOperationException"></exception>
+ 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();
+ }
+
+ /// <summary>
+ /// Destroys any secret data
+ /// </summary>
+ 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.
+ */
+
+ ///<inheritdoc/>
+ 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);
+ }
+
+ /// <summary>
+ /// Gets a SPKI encoded public key from the stored private key which matches the
+ /// server format
+ /// </summary>
+ /// <returns>The base64 encoded public key string</returns>
+ /// <exception cref="InvalidOperationException"></exception>
+ 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;
+ }
+
+ /// <summary>
+ /// Loads the secret session data from the provided json element
+ /// </summary>
+ /// <param name="data">The previously stored json data object that contains the secrets element</param>
+ /// <returns>The recovered <see cref="LoginSession"/></returns>
+ 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);
+ }
+
+ /// <summary>
+ /// Creates a new login session from the provided credential server
+ /// response web message
+ /// </summary>
+ /// <param name="credential">The existing credential that initiated the login</param>
+ /// <param name="webm">The webmessage server response</param>
+ /// <returns>The new <see cref="LoginSession"/> structure containing secret data</returns>
+ 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<AppConfig>(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
+{
+ /// <summary>
+ /// A store for persistent application settings
+ /// </summary>
+ /// <param name="store"></param>
+ public sealed class AppSettings(PersistentDataStore store)
+ {
+ //Load the config from the disk
+ private readonly Task<AppConfig> _loadtask = store.ReadJsonOrDefaultAsync<AppConfig>(Program.ConfigFileName);
+
+ /// <summary>
+ /// Gets the apps global configuration data from the disk
+ /// </summary>
+ /// <returns>The site configuration</returns>
+ public Task<AppConfig> GetConfigAsync() => _loadtask;
+
+ /// <summary>
+ /// Saves the provided configuration data to the disk
+ /// </summary>
+ /// <param name="config">The configuration instannce to store</param>
+ /// <returns>A task that completes when stored</returns>
+ 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<ListChannelRequest>()
+ .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<GetChannelRequest>()
+ .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<SetChannelRequest>()
+ .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<CreateChannelRequest>()
+ .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<DeleteChannelRequest>()
+ .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<ListPostMetaRequest>()
+ .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<GetPostMetaRequest>()
+ .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<SetPostMetaRequest>()
+ .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<CreatePostMetaRequest>()
+ .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<DeletePostMetaRequest>()
+ .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<ListContentRequest>()
+ .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<SetContentMetaRequest>()
+ .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<GetContentLinkRequest>()
+ .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<DeleteContentRequest>()
+ .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<DeleteBulkContentRequest>()
+ .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<UploadFileRequest>()
+ .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
+ {
+ /// <summary>
+ /// Specifes that the desired response Content-Type is of application/json
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="builder"></param>
+ /// <returns></returns>
+ public static IRestRequestBuilder<T> AcceptJson<T>(this IRestRequestBuilder<T> builder)
+ {
+ return builder.WithHeader("Accept", "application/json");
+ }
+
+ public static IRestRequestBuilder<T> WithAccessDeniedHandler<T>(this IRestRequestBuilder<T> builder, string message)
+ {
+ return builder.OnResponse((_, res) =>
+ {
+ if (res.StatusCode == HttpStatusCode.Forbidden)
+ {
+ throw new CMNextPermissionException(message);
+ }
+ });
+ }
+
+ public static IRestRequestBuilder<T> WithBody<T, TBody>(this IRestRequestBuilder<T> builder, Func<T, TBody> body) where TBody : class
+ {
+ return builder.WithModifier((t, req) => req.AddJsonBody(body(t)));
+ }
+
+ public static IRestRequestBuilder<T> WithLogger<T>(this IRestRequestBuilder<T> 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;
+ }
+
+ /// <summary>
+ /// Specifies the authentication adapter for the endpoint
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="buider"></param>
+ /// <param name="adapter">The auth adapter to set for the endpoint</param>
+ /// <returns></returns>
+ public static IRestRequestBuilder<T> WithAuth<T>(this IRestRequestBuilder<T> buider, IAuthAdapter adapter)
+ {
+ //Specify adapter for desired endpoint
+ adapter.SetModifiersForEndpoint(buider);
+ return buider;
+ }
+
+ public static PendingRequest<T> BeginRequest<T>(this IRestSiteAdapter site, T request)
+ => new (site, request);
+
+ public sealed class PendingRequest<T>(IRestSiteAdapter Adapter, T request)
+ {
+
+ private readonly LinkedList<Action<T>> _beforeExecChain = new();
+
+ public PendingRequest<T> BeforeRequest(Action<T> beforeRequest)
+ {
+ _beforeExecChain.AddLast(beforeRequest);
+ return this;
+ }
+
+ public Task<RestResponse> ExecAsync(CancellationToken cancellation)
+ {
+ _beforeExecChain.TryForeach(p => p.Invoke(request));
+ return Adapter.ExecuteAsync(request, cancellation);
+ }
+
+ public Task<RestResponse<TJson>> ExecAsync<TJson>(CancellationToken cancellation)
+ {
+ return Adapter.ExecuteAsync<T, TJson>(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);
+ }
+
+ ///<inheritdoc/>
+ public override Task WaitAsync(CancellationToken cancellation = default) => Task.CompletedTask;
+
+ ///<inheritdoc/>
+ 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<ServerValidationJson>()!)
+ .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<WebAuthenticator> _lazyAuth = GetAuthenticatorAsync(state, data);
+ readonly CancellationToken cancellation = console.GetCancellationToken();
+
+ static async Task<WebAuthenticator> 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<bool> HasValidAuth() => (await _lazyAuth).HasValidLogin();
+
+ /// <summary>
+ /// Gets the site adapter for the current site and configures
+ /// it
+ /// </summary>
+ /// <returns></returns>
+ public async Task<CMNextSiteAdapter> 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<ServerPokeRequest>()
+ .WithUrl("/")
+ .WithMethod(Method.Get)
+ .WithModifier((_, r) => r.CookieContainer = man.Cookies)
+ .WithLogger(logger);
+
+ //Set login endpoint
+ adapter.DefineSingleEndpoint()
+ .WithEndpoint<LoginRequest>()
+ .WithUrl(config.Endpoints.LoginPath)
+ .WithMethod(Method.Post)
+ .WithBody(r => r)
+ .WithModifier((_, r) => r.CookieContainer = man.Cookies)
+ .WithLogger(logger);
+
+ return adapter;
+ }
+
+ /// <summary>
+ /// Attempts to authenticate the provided site with the provided credentials
+ /// </summary>
+ /// <param name="site">The cmnext site adapter to connect to</param>
+ /// <param name="auth">The authenticator to use for the connection</param>
+ /// <param name="token"></param>
+ /// <param name="cancellation"></param>
+ /// <returns></returns>
+ /// <exception cref="AuthenticationException"></exception>
+ 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<WebMessage>())!;
+
+ //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);
+ }
+
+ /// <summary>
+ /// Destroys the current authentication session
+ /// </summary>
+ /// <returns></returns>
+ 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;
+
+ /// <summary>
+ /// Creates a new isolated storage store with the provided name
+ /// that can be used to store and retreive <see cref="IStorable"/>
+ /// data.
+ /// </summary>
+ /// <param name="storeName">The directory name to store data in</param>
+ /// <returns>The new <see cref="PersistentDataStore"/></returns>
+ 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
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="fileName"></param>
+ /// <param name="entity"></param>
+ /// <param name="cancellation"></param>
+ /// <returns></returns>
+ public async Task<bool> RestoreAsync<T>(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);
+ }
+
+ /// <summary>
+ /// Stores the contents of the provided entity to the specified file
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="fileName">The name of the file to write</param>
+ /// <param name="entity">The storable entity to store</param>
+ /// <returns>A task that resolves when the save has been completed</returns>
+ public async Task SaveAsync<T>(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);
+ }
+
+ /// <summary>
+ /// Reads the contents of the specified file as a json object directly
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="fileName">The name of the file to read</param>
+ /// <param name="cancellation">A token to cancel the operation</param>
+ /// <returns>A task that resolves the object if found, null otherwise</returns>
+ public async Task<T?> ReadJsonAsync<T>(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<T>(stream, cancellationToken: _cancellation);
+ }
+
+ /// <summary>
+ /// 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
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="fileName">The name of the file to read data from</param>
+ /// <returns>A task that resolves the read object</returns>
+ public async Task<T> ReadJsonOrDefaultAsync<T>(string fileName) where T : class, new()
+ {
+ T? result = await ReadJsonAsync<T>(fileName);
+ return result ?? new();
+ }
+
+ /// <summary>
+ /// Saves the provided entity as a json object to the specified file
+ /// </summary>
+ /// <typeparam name="T"></typeparam>
+ /// <param name="fileName">The name of the file to write data to</param>
+ /// <param name="entity">The object to store</param>
+ /// <returns>A task that resolves when the data has been stored</returns>
+ public async Task SaveJsonAsync<T>(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