diff --git a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs index 12b3d90a..b94d9b12 100644 --- a/src/PepperDash.Core/Logging/DebugWebsocketSink.cs +++ b/src/PepperDash.Core/Logging/DebugWebsocketSink.cs @@ -46,8 +46,15 @@ namespace PepperDash.Core { get { - if (_httpsServer == null) return ""; - return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{_httpsServer.WebSocketServices[_path].Path}"; + if (_httpsServer == null || !_httpsServer.IsListening) return ""; + var service = _httpsServer.WebSocketServices[_path]; + if (service == null) return ""; + + // Use CSLAN IP if available, otherwise fallback to primary IP. This ensures we provide a reachable URL in dual-stack environments. + if (!string.IsNullOrEmpty(CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1))) + return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 1)}:{_httpsServer.Port}{service.Path}"; + else + return $"wss://{CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0)}:{_httpsServer.Port}{service.Path}"; } } diff --git a/src/PepperDash.Core/Web/WebApiServer.cs b/src/PepperDash.Core/Web/WebApiServer.cs index 62e577ca..f07aab42 100644 --- a/src/PepperDash.Core/Web/WebApiServer.cs +++ b/src/PepperDash.Core/Web/WebApiServer.cs @@ -173,6 +173,22 @@ namespace PepperDash.Core.Web _server.Routes.Remove(route); } + /// + /// Sets the fallback request handler that is invoked when no registered route + /// matches an incoming request. Must be called before . + /// + /// The handler to use as the server-level fallback. + public void SetFallbackHandler(IHttpCwsHandler handler) + { + if (handler == null) + { + this.LogWarning("SetFallbackHandler: handler parameter is null, ignoring"); + return; + } + + _server.HttpRequestHandler = handler; + } + /// /// GetRouteCollection method /// diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs index 2a7a48c5..9e781883 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -17,6 +17,9 @@ namespace PepperDash.Essentials.Core.Web { private readonly WebApiServer _server; + private readonly WebApiServer _debugServer; + + /// /// http(s)://{ipaddress}/cws/{basePath} /// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/cws/{basePath} @@ -70,6 +73,9 @@ namespace PepperDash.Essentials.Core.Web _server = new WebApiServer(Key, Name, BasePath); + _debugServer = new WebApiServer($"{key}-debug-app", $"{name} Debug App", "/debug"); + _debugServer.SetFallbackHandler(new ServeDebugAppRequestHandler()); + SetupRoutes(); } @@ -242,6 +248,7 @@ namespace PepperDash.Essentials.Core.Web Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series"); _server.Start(); + _debugServer.Start(); GetPaths(); @@ -252,7 +259,8 @@ namespace PepperDash.Essentials.Core.Web Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server"); _server.Start(); - + _debugServer.Start(); + GetPaths(); } diff --git a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs index 397278f5..c966ea59 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApiHelpers.cs @@ -36,7 +36,7 @@ namespace PepperDash.Essentials.Core.Web }; } - + /// /// MapToDeviceListObject method @@ -119,9 +119,5 @@ namespace PepperDash.Essentials.Core.Web }; } - 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 index 67aded59..5a4b7df7 100644 --- a/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/LoginRequestHandler.cs @@ -71,8 +71,10 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers { context.Response.StatusCode = 401; context.Response.StatusDescription = "Bad Request"; + context.Response.ContentType = "application/json"; + context.Response.ContentEncoding = System.Text.Encoding.UTF8; + context.Response.Write(JsonConvert.SerializeObject(new { Error = "Unauthorized" }, Formatting.Indented), false); context.Response.End(); - return; } diff --git a/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs new file mode 100644 index 00000000..d8a296e5 --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs @@ -0,0 +1,224 @@ +using System; +using System.Collections.Generic; +using System.Text; +using Crestron.SimplSharp; +using Crestron.SimplSharp.CrestronIO; +using Crestron.SimplSharp.WebScripting; +using PepperDash.Core; +using PepperDash.Core.Web.RequestHandlers; +using Serilog.Events; + +namespace PepperDash.Essentials.Core.Web.RequestHandlers +{ + /// + /// Serves the React debug app from the processor's /HTML/debug folder. + /// The root route (debug) and all sub-paths (debug/{*filePath}) are handled here. + /// Text assets are sent as UTF-8 strings; binary assets are written to the response + /// OutputStream. Any sub-path that does not match a real file falls back to + /// index.html so that client-side (React Router) routing continues to work. + /// + public class ServeDebugAppRequestHandler : WebApiBaseRequestHandler + { + private static readonly Dictionary MimeTypes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + { ".html", "text/html; charset=utf-8" }, + { ".htm", "text/html; charset=utf-8" }, + { ".js", "application/javascript" }, + { ".mjs", "application/javascript" }, + { ".jsx", "application/javascript" }, + { ".css", "text/css" }, + { ".json", "application/json" }, + { ".map", "application/json" }, + { ".svg", "image/svg+xml" }, + { ".ico", "image/x-icon" }, + { ".png", "image/png" }, + { ".jpg", "image/jpeg" }, + { ".jpeg", "image/jpeg" }, + { ".gif", "image/gif" }, + { ".woff", "font/woff" }, + { ".woff2","font/woff2" }, + { ".ttf", "font/ttf" }, + { ".eot", "application/vnd.ms-fontobject" }, + }; + + private static readonly HashSet TextExtensions = new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".html", ".htm", ".js", ".mjs", ".jsx", ".css", ".json", ".map", ".svg", ".txt", ".xml" + }; + + /// + /// Constructor. CORS is enabled so browser dev-tools requests succeed. + /// + public ServeDebugAppRequestHandler() : base(true) { } + + /// + /// Handles GET requests for the debug app and its static assets. + /// + protected override void HandleGet(HttpCwsContext context) + { + // When acting as the server-level fallback handler, only handle + // requests that are actually for the /debug path; defer everything + // else to the base class (which returns 501 Not Implemented). + var rawUrl = context.Request.RawUrl ?? string.Empty; + if (rawUrl.IndexOf("/debug", StringComparison.OrdinalIgnoreCase) < 0) + { + base.HandleGet(context); + return; + } + + try + { + var htmlDebugPath = GetHtmlDebugPath(); + if (htmlDebugPath == null) + { + SendResponse(context, 500, "Internal Server Error"); + return; + } + + var requestedPath = GetRequestedFilePath(context); + + // Paths with no file extension are SPA client-side routes — serve index.html + string candidate; + if (string.IsNullOrEmpty(requestedPath) || !System.IO.Path.HasExtension(requestedPath)) + { + candidate = System.IO.Path.Combine(htmlDebugPath, "index.html"); + } + else + { + var relativePart = requestedPath.Replace('/', System.IO.Path.DirectorySeparatorChar); + candidate = System.IO.Path.Combine(htmlDebugPath, relativePart); + } + + // Resolve to an absolute path and guard against path-traversal attacks + var resolvedCandidate = System.IO.Path.GetFullPath(candidate); + var resolvedBase = System.IO.Path.GetFullPath(htmlDebugPath) + + System.IO.Path.DirectorySeparatorChar; + + if (!resolvedCandidate.StartsWith(resolvedBase, StringComparison.OrdinalIgnoreCase)) + { + SendResponse(context, 403, "Forbidden"); + return; + } + + // Missing static asset → fall back to index.html (SPA deep-link support) + if (!File.Exists(resolvedCandidate) && System.IO.Path.HasExtension(requestedPath ?? string.Empty)) + { + resolvedCandidate = System.IO.Path.Combine(htmlDebugPath, "index.html"); + Debug.LogMessage(LogEventLevel.Debug, + "ServeDebugAppRequestHandler: '{requestedPath:l}' not found, falling back to index.html", + requestedPath); + } + + if (!File.Exists(resolvedCandidate)) + { + SendResponse(context, 404, "Not Found"); + return; + } + + var ext = System.IO.Path.GetExtension(resolvedCandidate); + var contentType = MimeTypes.TryGetValue(ext, out var mime) ? mime : "application/octet-stream"; + + context.Response.StatusCode = 200; + context.Response.StatusDescription = "OK"; + context.Response.ContentType = contentType; + + if (TextExtensions.Contains(ext)) + { + string content; + using (var reader = new StreamReader(resolvedCandidate, Encoding.UTF8)) + content = reader.ReadToEnd(); + + context.Response.ContentEncoding = Encoding.UTF8; + context.Response.Write(content, false); + } + else + { + var bytes = System.IO.File.ReadAllBytes(resolvedCandidate); + context.Response.OutputStream.Write(bytes, 0, bytes.Length); + } + + context.Response.End(); + } + catch (Exception ex) + { + Debug.LogMessage(LogEventLevel.Error, ex, + "ServeDebugAppRequestHandler: Unhandled error serving '{rawUrl:l}'", + context.Request.RawUrl); + try { SendResponse(context, 500, "Internal Server Error"); } + catch { /* best-effort */ } + } + } + + /// + /// Resolves the absolute path of the /HTML/debug folder on the processor. + /// + /// + /// Global.FilePathPrefix is always {root}/user/programX/ (or + /// equivalent), so walking up two parents gives the processor root that + /// contains the html folder. This mirrors the two-hop strategy used + /// by AssetLoader.ExtractDevToolsZip so that serving and extraction + /// always resolve to the same directory. + /// + private static string GetHtmlDebugPath() + { + try + { + var separators = new[] { System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar }; + var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(separators)); + + // Walk up two levels: {root}/user/programX/ → {root}/user/ → {root} + // This matches the path calculation used in AssetLoader.ExtractDevToolsZip. + var userOrNvramDir = programDir.Parent; + var rootDir = userOrNvramDir?.Parent; + + if (rootDir == null) + { + Debug.LogMessage(LogEventLevel.Error, + "ServeDebugAppRequestHandler: Cannot resolve HTML root from FilePathPrefix '{prefix:l}'", + Global.FilePathPrefix); + return null; + } + + return System.IO.Path.Combine(rootDir.FullName, "html", "debug"); + } + catch (Exception ex) + { + Debug.LogMessage(LogEventLevel.Error, ex, + "ServeDebugAppRequestHandler: Error resolving HTML debug path"); + return null; + } + } + + /// + /// Extracts the file sub-path from the request by parsing RawUrl. + /// Returns an empty string when the URL ends at /debug (root hit). + /// + private static string GetRequestedFilePath(HttpCwsContext context) + { + var rawUrl = context.Request.RawUrl ?? string.Empty; + + // Locate the /debug segment in the URL + const string debugToken = "/debug"; + var idx = rawUrl.IndexOf(debugToken, StringComparison.OrdinalIgnoreCase); + if (idx < 0) + return string.Empty; + + var afterDebug = rawUrl.Substring(idx + debugToken.Length); + + // Strip query string + var qIdx = afterDebug.IndexOf('?'); + if (qIdx >= 0) + afterDebug = afterDebug.Substring(0, qIdx); + + // Strip leading slash to get a relative file path + return afterDebug.TrimStart('/'); + } + + private static void SendResponse(HttpCwsContext context, int statusCode, string statusDescription) + { + context.Response.StatusCode = statusCode; + context.Response.StatusDescription = statusDescription; + context.Response.End(); + } + } +} \ No newline at end of file diff --git a/src/PepperDash.Essentials/ControlSystem.cs b/src/PepperDash.Essentials/ControlSystem.cs index 03c01a0c..be020e2e 100644 --- a/src/PepperDash.Essentials/ControlSystem.cs +++ b/src/PepperDash.Essentials/ControlSystem.cs @@ -1,5 +1,4 @@ using System; -using System.IO.Compression; using System.Linq; using System.Reflection; using Crestron.SimplSharp;