aboutsummaryrefslogtreecommitdiff
path: root/cmnext-cli/src/Site
diff options
context:
space:
mode:
Diffstat (limited to 'cmnext-cli/src/Site')
-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
6 files changed, 746 insertions, 0 deletions
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