diff --git a/.github/workflows/EssentialsPlugins-builds-4-series-caller.yml b/.github/workflows/EssentialsPlugins-builds-4-series-caller.yml index 291e9371..d5990e99 100644 --- a/.github/workflows/EssentialsPlugins-builds-4-series-caller.yml +++ b/.github/workflows/EssentialsPlugins-builds-4-series-caller.yml @@ -19,4 +19,5 @@ jobs: version: ${{ needs.getVersion.outputs.version }} tag: ${{ needs.getVersion.outputs.tag }} channel: ${{ needs.getVersion.outputs.channel }} - bypassPackageCheck: true \ No newline at end of file + bypassPackageCheck: true + devToolsVersion: ${{ vars.ESSENTIALSDEVTOOLSVERSION }} \ No newline at end of file diff --git a/src/PepperDash.Core/Web/WebApiServer.cs b/src/PepperDash.Core/Web/WebApiServer.cs index 11ec4e2f..cd113c15 100644 --- a/src/PepperDash.Core/Web/WebApiServer.cs +++ b/src/PepperDash.Core/Web/WebApiServer.cs @@ -99,6 +99,8 @@ namespace PepperDash.Core.Web if (_server == null) _server = new HttpCwsServer(BasePath); + _server.AuthenticateAllRoutes = false; + _server.setProcessName(Key); _server.HttpRequestHandler = new DefaultRequestHandler(); diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index c498fedf..dfed5fdd 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -77,6 +77,11 @@ namespace PepperDash.Essentials.Core.Web { var routes = new List { + new HttpCwsRoute("login") + { + Name = "Root", + RouteHandler = new LoginRequestHandler() + }, new HttpCwsRoute("versions") { Name = "ReportVersions", diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs index 75f90189..397278f5 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs @@ -36,17 +36,25 @@ namespace PepperDash.Essentials.Core.Web }; } + + /// /// MapToDeviceListObject method /// public static object MapToDeviceListObject(IKeyed device) { + var interfaces = device.GetType() + .GetInterfaces() + .Select(i => i.Name) + .ToList(); + return new { device.Key, Name = (device is IKeyName) ? (device as IKeyName).Name - : "---" + : "---", + Interfaces = interfaces }; } @@ -110,5 +118,10 @@ namespace PepperDash.Essentials.Core.Web CType = device.Value.Type == null ? "---": device.Value.Type.ToString() }; } - } + + internal static bool IsAuthenticated(HttpCwsContext context) + { + throw new NotImplementedException(); + } + } } \ No newline at end of file diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs new file mode 100644 index 00000000..67aded59 --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -0,0 +1,122 @@ + +using System; +using Crestron.SimplSharp.CrestronAuthentication; +using Crestron.SimplSharp.WebScripting; +using Newtonsoft.Json; +using PepperDash.Core.Web.RequestHandlers; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers +{ + /// + /// Represents a LoginRequestHandler + /// + public class LoginRequestHandler : WebApiBaseRequestHandler + { + /// + /// Constructor + /// + /// + /// base(true) enables CORS support by default + /// + public LoginRequestHandler() + : base(true) + { + } + + /// + /// Handles POST method requests for user login and token generation + /// + /// The HTTP context for the request. + protected override void HandlePost(HttpCwsContext context) + { + try + { + if (context.Request.ContentLength < 0) + { + context.Response.StatusCode = 400; + context.Response.StatusDescription = "Bad Request"; + context.Response.End(); + + return; + } + + var data = context.Request.GetRequestBody(); + if (string.IsNullOrEmpty(data)) + { + context.Response.StatusCode = 400; + context.Response.StatusDescription = "Bad Request"; + context.Response.End(); + + return; + } + + var loginRequest = JsonConvert.DeserializeObject(data); + + if (loginRequest == null || string.IsNullOrEmpty(loginRequest.Username) || string.IsNullOrEmpty(loginRequest.Password)) + { + context.Response.StatusCode = 400; + context.Response.StatusDescription = "Bad Request"; + context.Response.End(); + + return; + } + + Authentication.UserToken token; + + try + { + token = Authentication.GetAuthenticationToken(loginRequest.Username, loginRequest.Password); + } + catch (ArgumentException) + { + context.Response.StatusCode = 401; + context.Response.StatusDescription = "Bad Request"; + context.Response.End(); + + return; + } + + if (!token.Valid) + { + context.Response.StatusCode = 401; + context.Response.StatusDescription = "Unauthorized"; + context.Response.End(); + + return; + } + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(JsonConvert.SerializeObject(new { Token = token }, Formatting.Indented), false); + context.Response.End(); + } + catch (System.Exception ex) + { + context.Response.StatusCode = 500; + context.Response.StatusDescription = "Internal Server Error"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(JsonConvert.SerializeObject(new { Error = ex.Message }, Formatting.Indented), false); + context.Response.End(); + } + } + } + + /// + /// Represents a LoginRequest + /// + public class LoginRequest + { + /// + /// Gets or sets the username. + /// + public string Username { get; set; } + + /// + /// Gets or sets the password. + /// + public string Password { get; set; } + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials/AssetLoader.cs b/src/PepperDash.Essentials/AssetLoader.cs new file mode 100644 index 00000000..d8b9f5c1 --- /dev/null +++ b/src/PepperDash.Essentials/AssetLoader.cs @@ -0,0 +1,257 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Linq; +using PepperDash.Core; +using Serilog.Events; + +namespace PepperDash.Essentials +{ + /// + /// Handles extracting embedded asset bundles and moving configuration files from the + /// application directory to the program file-path prefix at startup. + /// Implemented using System.IO types so it can run (and be tested) outside + /// of a Crestron runtime environment. + /// + internal static class AssetLoader + { + /// + /// Scans for well-known zip bundles and + /// JSON configuration files and deploys them to . + /// + /// + /// The directory to scan (typically the Crestron application root). + /// + /// + /// The program's runtime working directory (e.g. /nvram/program1/). + /// + internal static void Load(string applicationDirectoryPath, string filePathPrefix) + { + var applicationDirectory = new DirectoryInfo(applicationDirectoryPath); + Debug.LogMessage(LogEventLevel.Information, + "Searching: {applicationDirectory:l} for embedded assets - {Destination}", + applicationDirectory.FullName, filePathPrefix); + + ExtractAssetsZip(applicationDirectory, filePathPrefix); + ExtractHtmlAssetsZip(applicationDirectory, filePathPrefix); + ExtractDevToolsZip(applicationDirectory, filePathPrefix); + MoveConfigurationFile(applicationDirectory, filePathPrefix); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private static void ExtractAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var zipFiles = applicationDirectory.GetFiles("assets*.zip"); + + if (zipFiles.Length > 1) + throw new Exception("Multiple assets zip files found. Cannot continue."); + + if (zipFiles.Length == 1) + { + var zipFile = zipFiles[0]; + var assetsRoot = Path.GetFullPath(filePathPrefix); + if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + assetsRoot += Path.DirectorySeparatorChar; + } + + Debug.LogMessage(LogEventLevel.Information, + "Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName); + + using (var archive = ZipFile.OpenRead(zipFile.FullName)) + { + foreach (var entry in archive.Entries) + { + var destinationPath = Path.Combine(filePathPrefix, entry.FullName); + var fullDest = Path.GetFullPath(destinationPath); + if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Entry '{entry.FullName}' is trying to extract outside of the target directory."); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + if (Directory.Exists(destinationPath)) + Directory.Delete(destinationPath, recursive: true); + + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)); + entry.ExtractToFile(destinationPath, overwrite: true); + Debug.LogMessage(LogEventLevel.Information, + "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); + } + } + } + + foreach (var file in zipFiles) + File.Delete(file.FullName); + } + + private static void ExtractHtmlAssetsZip(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip"); + + if (htmlZipFiles.Length > 1) + throw new Exception( + "Multiple htmlassets zip files found in application directory. " + + "Please ensure only one htmlassets*.zip file is present and retry."); + + if (htmlZipFiles.Length == 1) + { + var htmlZipFile = htmlZipFiles[0]; + var programDir = new DirectoryInfo( + filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var userOrNvramDir = programDir.Parent; + var rootDir = userOrNvramDir?.Parent; + if (rootDir == null) + throw new Exception( + $"Unable to determine root directory for html extraction. Current path: {filePathPrefix}"); + + var htmlDir = Path.Combine(rootDir.FullName, "html"); + var htmlRoot = Path.GetFullPath(htmlDir); + if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + htmlRoot += Path.DirectorySeparatorChar; + } + + Debug.LogMessage(LogEventLevel.Information, + "Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName); + + using (var archive = ZipFile.OpenRead(htmlZipFile.FullName)) + { + foreach (var entry in archive.Entries) + { + var destinationPath = Path.Combine(htmlDir, entry.FullName); + var fullDest = Path.GetFullPath(destinationPath); + if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Entry '{entry.FullName}' is trying to extract outside of the target directory."); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + if (File.Exists(destinationPath)) + File.Delete(destinationPath); + + var parentDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(parentDir)) + Directory.CreateDirectory(parentDir); + + entry.ExtractToFile(destinationPath, overwrite: true); + Debug.LogMessage(LogEventLevel.Information, + "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); + } + } + } + + foreach (var file in htmlZipFiles) + File.Delete(file.FullName); + } + + private static void ExtractDevToolsZip(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var devToolsZipFiles = applicationDirectory.GetFiles("essentials-devtools*.zip"); + + if (devToolsZipFiles.Length > 1) + throw new Exception( + "Multiple essentials-devtools zip files found in application directory. " + + "Please ensure only one essentials-devtools*.zip file is present and retry."); + + if (devToolsZipFiles.Length == 1) + { + var devToolsZipFile = devToolsZipFiles[0]; + var programDir = new DirectoryInfo( + filePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); + var userOrNvramDir = programDir.Parent; + var rootDir = userOrNvramDir?.Parent; + if (rootDir == null) + throw new Exception( + $"Unable to determine root directory for debug html extraction. Current path: {filePathPrefix}"); + + var debugDir = Path.Combine(rootDir.FullName, "html", "debug"); + var debugRoot = Path.GetFullPath(debugDir); + if (!debugRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && + !debugRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) + { + debugRoot += Path.DirectorySeparatorChar; + } + + Debug.LogMessage(LogEventLevel.Information, + "Found essentials-devtools zip file: {zipFile:l}... Unzipping to {Destination}...", + devToolsZipFile.FullName, debugDir); + + using (var archive = ZipFile.OpenRead(devToolsZipFile.FullName)) + { + foreach (var entry in archive.Entries) + { + var destinationPath = Path.Combine(debugDir, entry.FullName); + var fullDest = Path.GetFullPath(destinationPath); + if (!fullDest.StartsWith(debugRoot, StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException( + $"Entry '{entry.FullName}' is trying to extract outside of the target directory."); + + if (string.IsNullOrEmpty(entry.Name)) + { + Directory.CreateDirectory(destinationPath); + continue; + } + + if (File.Exists(destinationPath)) + File.Delete(destinationPath); + + var parentDir = Path.GetDirectoryName(destinationPath); + if (!string.IsNullOrEmpty(parentDir)) + Directory.CreateDirectory(parentDir); + + entry.ExtractToFile(destinationPath, overwrite: true); + Debug.LogMessage(LogEventLevel.Information, + "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); + } + } + } + + foreach (var file in devToolsZipFiles) + File.Delete(file.FullName); + } + + private static void MoveConfigurationFile(DirectoryInfo applicationDirectory, string filePathPrefix) + { + var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json"); + + if (jsonFiles.Length > 1) + { + Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}", + jsonFiles.Select(f => f.FullName).ToArray()); + throw new Exception("Multiple configuration files found. Cannot continue."); + } + + if (jsonFiles.Length == 1) + { + var jsonFile = jsonFiles[0]; + var finalPath = Path.Combine(filePathPrefix, jsonFile.Name); + Debug.LogMessage(LogEventLevel.Information, + "Found configuration file: {jsonFile:l}... Moving to: {Destination}", + jsonFile.FullName, finalPath); + + if (File.Exists(finalPath)) + { + Debug.LogMessage(LogEventLevel.Information, + "Removing existing configuration file: {Destination}", finalPath); + File.Delete(finalPath); + } + + jsonFile.MoveTo(finalPath); + } + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index 5fb7771e..815aaa41 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -245,7 +245,7 @@ namespace PepperDash.Essentials // _ = new ProcessorExtensionDeviceFactory(); // _ = new MobileControlFactory(); - LoadAssets(); + LoadAssets(Global.ApplicationDirectoryPathPrefix, Global.FilePathPrefix); Debug.LogMessage(LogEventLevel.Information, "Starting Essentials load from configuration"); @@ -825,142 +825,8 @@ namespace PepperDash.Essentials } } - private static void LoadAssets() - { - var applicationDirectory = new DirectoryInfo(Global.ApplicationDirectoryPathPrefix); - Debug.LogMessage(LogEventLevel.Information, "Searching: {applicationDirectory:l} for embedded assets - {Destination}", applicationDirectory.FullName, Global.FilePathPrefix); + internal static void LoadAssets(string applicationDirectoryPath, string filePathPrefix) => + AssetLoader.Load(applicationDirectoryPath, filePathPrefix); - var zipFiles = applicationDirectory.GetFiles("assets*.zip"); - - if (zipFiles.Length > 1) - { - throw new Exception("Multiple assets zip files found. Cannot continue."); - } - - if (zipFiles.Length == 1) - { - var zipFile = zipFiles[0]; - var assetsRoot = System.IO.Path.GetFullPath(Global.FilePathPrefix); - if (!assetsRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && !assetsRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) - { - assetsRoot += Path.DirectorySeparatorChar; - } - Debug.LogMessage(LogEventLevel.Information, "Found assets zip file: {zipFile:l}... Unzipping...", zipFile.FullName); - using (var archive = ZipFile.OpenRead(zipFile.FullName)) - { - foreach (var entry in archive.Entries) - { - var destinationPath = Path.Combine(Global.FilePathPrefix, entry.FullName); - var fullDest = System.IO.Path.GetFullPath(destinationPath); - if (!fullDest.StartsWith(assetsRoot, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory."); - - if (string.IsNullOrEmpty(entry.Name)) - { - Directory.CreateDirectory(destinationPath); - continue; - } - - // If a directory exists where a file should go, delete it - if (Directory.Exists(destinationPath)) - Directory.Delete(destinationPath, true); - - Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)); - entry.ExtractToFile(destinationPath, true); - Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); - } - } - } - - // cleaning up zip files - foreach (var file in zipFiles) - { - File.Delete(file.FullName); - } - - var htmlZipFiles = applicationDirectory.GetFiles("htmlassets*.zip"); - - if (htmlZipFiles.Length > 1) - { - throw new Exception("Multiple htmlassets zip files found in application directory. Please ensure only one htmlassets*.zip file is present and retry."); - } - - - if (htmlZipFiles.Length == 1) - { - var htmlZipFile = htmlZipFiles[0]; - var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)); - var userOrNvramDir = programDir.Parent; - var rootDir = userOrNvramDir?.Parent; - if (rootDir == null) - { - throw new Exception($"Unable to determine root directory for html extraction. Current path: {Global.FilePathPrefix}"); - } - var htmlDir = Path.Combine(rootDir.FullName, "html"); - var htmlRoot = System.IO.Path.GetFullPath(htmlDir); - if (!htmlRoot.EndsWith(Path.DirectorySeparatorChar.ToString()) && - !htmlRoot.EndsWith(Path.AltDirectorySeparatorChar.ToString())) - { - htmlRoot += Path.DirectorySeparatorChar; - } - Debug.LogMessage(LogEventLevel.Information, "Found htmlassets zip file: {zipFile:l}... Unzipping...", htmlZipFile.FullName); - using (var archive = ZipFile.OpenRead(htmlZipFile.FullName)) - { - foreach (var entry in archive.Entries) - { - var destinationPath = Path.Combine(htmlDir, entry.FullName); - var fullDest = System.IO.Path.GetFullPath(destinationPath); - if (!fullDest.StartsWith(htmlRoot, StringComparison.OrdinalIgnoreCase)) - throw new InvalidOperationException($"Entry '{entry.FullName}' is trying to extract outside of the target directory."); - - if (string.IsNullOrEmpty(entry.Name)) - { - Directory.CreateDirectory(destinationPath); - continue; - } - - // Only delete the file if it exists and is a file, not a directory - if (File.Exists(destinationPath)) - File.Delete(destinationPath); - - var parentDir = Path.GetDirectoryName(destinationPath); - if (!string.IsNullOrEmpty(parentDir)) - Directory.CreateDirectory(parentDir); - - entry.ExtractToFile(destinationPath, true); - Debug.LogMessage(LogEventLevel.Information, "Extracted: {entry:l} to {Destination}", entry.FullName, destinationPath); - } - } - } - - // cleaning up html zip files - foreach (var file in htmlZipFiles) - { - File.Delete(file.FullName); - } - - var jsonFiles = applicationDirectory.GetFiles("*configurationFile*.json"); - - if (jsonFiles.Length > 1) - { - Debug.LogError("Multiple configuration files found in application directory: {@jsonFiles}", jsonFiles.Select(f => f.FullName).ToArray()); - throw new Exception("Multiple configuration files found. Cannot continue."); - } - - if (jsonFiles.Length == 1) - { - var jsonFile = jsonFiles[0]; - var finalPath = Path.Combine(Global.FilePathPrefix, jsonFile.Name); - Debug.LogMessage(LogEventLevel.Information, "Found configuration file: {jsonFile:l}... Moving to: {Destination}", jsonFile.FullName, finalPath); - - if (File.Exists(finalPath)) - { - Debug.LogMessage(LogEventLevel.Information, "Removing existing configuration file: {Destination}", finalPath); - File.Delete(finalPath); - } - - jsonFile.MoveTo(finalPath); - } - } } }