diff --git a/src/PepperDash.Core/Logging/Debug.cs b/src/PepperDash.Core/Logging/Debug.cs index cf166c78..2e4b25e4 100644 --- a/src/PepperDash.Core/Logging/Debug.cs +++ b/src/PepperDash.Core/Logging/Debug.cs @@ -51,7 +51,7 @@ public static class Debug private static readonly LoggingLevelSwitch errorLogLevelSwitch; - private static readonly LoggingLevelSwitch fileLevelSwitch; + private static readonly LoggingLevelSwitch fileLoggingLevelSwitch; /// /// The minimum log level for messages to be sent to the console sink @@ -168,7 +168,7 @@ public static class Debug errorLogLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultErrorLogLevel); - fileLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultFileLogLevel); + fileLoggingLevelSwitch = new LoggingLevelSwitch(initialMinimumLevel: defaultFileLogLevel); websocketSink = new DebugWebsocketSink(new JsonFormatter(renderMessage: true)); @@ -193,7 +193,7 @@ public static class Debug rollingInterval: RollingInterval.Day, restrictedToMinimumLevel: LogEventLevel.Debug, retainedFileCountLimit: CrestronEnvironment.DevicePlatform == eDevicePlatform.Appliance ? 30 : 60, - levelSwitch: fileLevelSwitch + levelSwitch: fileLoggingLevelSwitch ); // Instantiate the root logger @@ -413,7 +413,8 @@ public static class Debug return; } - if (Enum.TryParse(levelString, out var levelEnum)) + // make this parse attempt case-insensitive to allow for more flexible command usage + if (Enum.TryParse(levelString, true, out var levelEnum)) { SetDebugLevel(levelEnum); return; @@ -483,7 +484,7 @@ public static class Debug var err = CrestronDataStoreStatic.SetLocalUintValue(WebSocketLevelStoreKey, (uint)level); if (err != CrestronDataStore.CDS_ERROR.CDS_SUCCESS) - LogMessage(LogEventLevel.Information, "Error saving websocket debug level setting: {erro}", err); + LogMessage(LogEventLevel.Information, "Error saving websocket debug level setting: {error}", err); LogMessage(LogEventLevel.Information, "Websocket debug level set to {0}", websocketLoggingLevelSwitch.MinimumLevel); } @@ -502,7 +503,7 @@ public static class Debug if (err != CrestronDataStore.CDS_ERROR.CDS_SUCCESS) LogMessage(LogEventLevel.Information, "Error saving Error Log debug level setting: {error}", err); - LogMessage(LogEventLevel.Information, "Error log debug level set to {0}", websocketLoggingLevelSwitch.MinimumLevel); + LogMessage(LogEventLevel.Information, "Error log debug level set to {0}", errorLogLevelSwitch.MinimumLevel); } /// @@ -510,14 +511,14 @@ public static class Debug /// public static void SetFileMinimumDebugLevel(LogEventLevel level) { - errorLogLevelSwitch.MinimumLevel = level; + fileLoggingLevelSwitch.MinimumLevel = level; - var err = CrestronDataStoreStatic.SetLocalUintValue(ErrorLogLevelStoreKey, (uint)level); + var err = CrestronDataStoreStatic.SetLocalUintValue(FileLevelStoreKey, (uint)level); if (err != CrestronDataStore.CDS_ERROR.CDS_SUCCESS) LogMessage(LogEventLevel.Information, "Error saving File debug level setting: {error}", err); - LogMessage(LogEventLevel.Information, "File debug level set to {0}", websocketLoggingLevelSwitch.MinimumLevel); + LogMessage(LogEventLevel.Information, "File debug level set to {0}", fileLoggingLevelSwitch.MinimumLevel); } /// diff --git a/src/PepperDash.Core/Web/WebApiServer.cs b/src/PepperDash.Core/Web/WebApiServer.cs index df8e4b58..bccd52aa 100644 --- a/src/PepperDash.Core/Web/WebApiServer.cs +++ b/src/PepperDash.Core/Web/WebApiServer.cs @@ -160,6 +160,22 @@ public class WebApiServer : IKeyName _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 237edd62..8fbc6c34 100644 --- a/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs +++ b/src/PepperDash.Essentials.Core/Web/EssentialsWebApi.cs @@ -18,6 +18,7 @@ namespace PepperDash.Essentials.Core.Web; public class EssentialsWebApi : EssentialsDevice { private readonly WebApiServer _server; + private readonly WebApiServer _debugServer; /// /// http(s)://{ipaddress}/cws/{basePath} @@ -68,6 +69,9 @@ public class EssentialsWebApi : EssentialsDevice _server = new WebApiServer(Key, Name, BasePath); + _debugServer = new WebApiServer($"{key}-debug-app", $"{name} Debug App", "/debug"); + _debugServer.SetFallbackHandler(new ServeDebugAppRequestHandler()); + SetupRoutes(); } @@ -226,6 +230,7 @@ public class EssentialsWebApi : EssentialsDevice Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series"); _server.Start(); + _debugServer.Start(); PrintPaths(); @@ -236,6 +241,7 @@ public class EssentialsWebApi : EssentialsDevice Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server"); _server.Start(); + _debugServer.Start(); PrintPaths(); } @@ -277,6 +283,12 @@ public class EssentialsWebApi : EssentialsDevice { Debug.LogMessage(LogEventLevel.Information, this, "{routeName:l}: {routePath:l}/{routeUrl:l}", route.Name, path, route.Url); } + + var debugPath = CrestronEnvironment.DevicePlatform == eDevicePlatform.Server + ? $"https://{hostname}/VirtualControl/Rooms/{InitialParametersClass.RoomId}/cws/debug" + : $"https://{currentIp}/cws/debug"; + Debug.LogMessage(LogEventLevel.Information, this, "Debug App: {debugPath:l}", debugPath); + Debug.LogMessage(LogEventLevel.Information, this, new string('-', 50)); } } \ No newline at end of file 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..85b17bd6 --- /dev/null +++ b/src/PepperDash.Essentials.Core/Web/RequestHandlers/ServeDebugAppRequestHandler.cs @@ -0,0 +1,229 @@ +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(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(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)) + 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, + "ServeDebugAppRequestHandler: Unhandled error serving '{rawUrl:l}': {ex}", + context.Request.RawUrl, ex.Message); + try { SendResponse(context, 500, "Internal Server Error"); } catch { /* best-effort */ } + } + } + + /// + /// Resolves the absolute path of the /HTML/debug folder on the processor. + /// + /// + /// On a 4-series appliance, Global.FilePathPrefix is + /// {root}/user/programX/, so walking up two parents gives the + /// processor root that contains the html folder. + /// On Virtual Control, Global.FilePathPrefix is {root}/User/, + /// so only one parent hop is needed. + /// + private static string GetHtmlDebugPath() + { + try + { + var separators = new[] { System.IO.Path.DirectorySeparatorChar, System.IO.Path.AltDirectorySeparatorChar }; + var programDir = new DirectoryInfo(Global.FilePathPrefix.TrimEnd(separators)); + + DirectoryInfo rootDir; + if (CrestronEnvironment.DevicePlatform == eDevicePlatform.Server) + { + // Virtual Control: {root}/User/ → one parent up = {root} + rootDir = programDir.Parent; + } + else + { + // 4-series appliance: {root}/user/programX/ → two parents up = {root} + rootDir = programDir.Parent?.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, + "ServeDebugAppRequestHandler: Error resolving HTML debug path: {ex}", ex.Message); + 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(); + } +}