mirror of
https://github.com/PepperDash/Essentials.git
synced 2026-07-02 10:38:16 +00:00
feat: add ServeDebugAppRequestHandler for serving debug app assets and improve error handling in LoginRequestHandler
Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
parent
9ea5ec5d1a
commit
ee240ca378
6 changed files with 288 additions and 36 deletions
|
|
@ -5,6 +5,7 @@ using Crestron.SimplSharp;
|
||||||
using Crestron.SimplSharp.WebScripting;
|
using Crestron.SimplSharp.WebScripting;
|
||||||
using Newtonsoft.Json;
|
using Newtonsoft.Json;
|
||||||
using Newtonsoft.Json.Linq;
|
using Newtonsoft.Json.Linq;
|
||||||
|
using PepperDash.Core.Logging;
|
||||||
using PepperDash.Core.Web.RequestHandlers;
|
using PepperDash.Core.Web.RequestHandlers;
|
||||||
|
|
||||||
namespace PepperDash.Core.Web
|
namespace PepperDash.Core.Web
|
||||||
|
|
@ -25,24 +26,24 @@ namespace PepperDash.Core.Web
|
||||||
private readonly CCriticalSection _serverLock = new CCriticalSection();
|
private readonly CCriticalSection _serverLock = new CCriticalSection();
|
||||||
private HttpCwsServer _server;
|
private HttpCwsServer _server;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the Key
|
/// Gets or sets the Key
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Key { get; private set; }
|
public string Key { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the Name
|
/// Gets or sets the Name
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string Name { get; private set; }
|
public string Name { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the BasePath
|
/// Gets or sets the BasePath
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string BasePath { get; private set; }
|
public string BasePath { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets or sets the IsRegistered
|
/// Gets or sets the IsRegistered
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsRegistered { get; private set; }
|
public bool IsRegistered { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|
@ -91,7 +92,7 @@ namespace PepperDash.Core.Web
|
||||||
/// <param name="key"></param>
|
/// <param name="key"></param>
|
||||||
/// <param name="name"></param>
|
/// <param name="name"></param>
|
||||||
/// <param name="basePath"></param>
|
/// <param name="basePath"></param>
|
||||||
public WebApiServer(string key, string name, string basePath)
|
public WebApiServer(string key, string name, string basePath)
|
||||||
{
|
{
|
||||||
Key = key;
|
Key = key;
|
||||||
Name = string.IsNullOrEmpty(name) ? DefaultName : name;
|
Name = string.IsNullOrEmpty(name) ? DefaultName : name;
|
||||||
|
|
@ -139,9 +140,9 @@ namespace PepperDash.Core.Web
|
||||||
Start();
|
Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Initialize method
|
/// Initialize method
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Initialize(string key, string basePath)
|
public void Initialize(string key, string basePath)
|
||||||
{
|
{
|
||||||
Key = key;
|
Key = key;
|
||||||
|
|
@ -167,9 +168,9 @@ namespace PepperDash.Core.Web
|
||||||
/// Removes a route from CWS
|
/// Removes a route from CWS
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="route"></param>
|
/// <param name="route"></param>
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// RemoveRoute method
|
/// RemoveRoute method
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void RemoveRoute(HttpCwsRoute route)
|
public void RemoveRoute(HttpCwsRoute route)
|
||||||
{
|
{
|
||||||
if (route == null)
|
if (route == null)
|
||||||
|
|
@ -181,9 +182,25 @@ namespace PepperDash.Core.Web
|
||||||
_server.Routes.Remove(route);
|
_server.Routes.Remove(route);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// GetRouteCollection method
|
/// Sets the fallback request handler that is invoked when no registered route
|
||||||
/// </summary>
|
/// matches an incoming request. Must be called before <see cref="Start"/>.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="handler">The handler to use as the server-level fallback.</param>
|
||||||
|
public void SetFallbackHandler(IHttpCwsHandler handler)
|
||||||
|
{
|
||||||
|
if (handler == null)
|
||||||
|
{
|
||||||
|
this.LogWarning("SetFallbackHandler: handler parameter is null, ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_server.HttpRequestHandler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// GetRouteCollection method
|
||||||
|
/// </summary>
|
||||||
public HttpCwsRouteCollection GetRouteCollection()
|
public HttpCwsRouteCollection GetRouteCollection()
|
||||||
{
|
{
|
||||||
return _server.Routes;
|
return _server.Routes;
|
||||||
|
|
@ -227,9 +244,9 @@ namespace PepperDash.Core.Web
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Stop method
|
/// Stop method
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void Stop()
|
public void Stop()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ namespace PepperDash.Essentials.Core.Web
|
||||||
{
|
{
|
||||||
private readonly WebApiServer _server;
|
private readonly WebApiServer _server;
|
||||||
|
|
||||||
|
private readonly WebApiServer _debugServer;
|
||||||
|
|
||||||
|
|
||||||
///<example>
|
///<example>
|
||||||
/// http(s)://{ipaddress}/cws/{basePath}
|
/// http(s)://{ipaddress}/cws/{basePath}
|
||||||
/// http(s)://{ipaddress}/VirtualControl/Rooms/{roomId}/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);
|
_server = new WebApiServer(Key, Name, BasePath);
|
||||||
|
|
||||||
|
_debugServer = new WebApiServer($"{key}-debug-app", $"{name} Debug App", "/debug");
|
||||||
|
_debugServer.SetFallbackHandler(new ServeDebugAppRequestHandler());
|
||||||
|
|
||||||
SetupRoutes();
|
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");
|
Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on {0} Appliance", is4Series ? "4-series" : "3-series");
|
||||||
|
|
||||||
_server.Start();
|
_server.Start();
|
||||||
|
_debugServer.Start();
|
||||||
|
|
||||||
GetPaths();
|
GetPaths();
|
||||||
|
|
||||||
|
|
@ -252,7 +259,8 @@ namespace PepperDash.Essentials.Core.Web
|
||||||
Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server");
|
Debug.LogMessage(LogEventLevel.Verbose, "Starting Essentials Web API on Virtual Control Server");
|
||||||
|
|
||||||
_server.Start();
|
_server.Start();
|
||||||
|
_debugServer.Start();
|
||||||
|
|
||||||
GetPaths();
|
GetPaths();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ namespace PepperDash.Essentials.Core.Web
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// MapToDeviceListObject method
|
/// MapToDeviceListObject method
|
||||||
|
|
@ -119,9 +119,5 @@ namespace PepperDash.Essentials.Core.Web
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static bool IsAuthenticated(HttpCwsContext context)
|
|
||||||
{
|
|
||||||
throw new NotImplementedException();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -71,14 +71,17 @@ namespace PepperDash.Essentials.Core.Web.RequestHandlers
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = 401;
|
context.Response.StatusCode = 401;
|
||||||
context.Response.StatusDescription = "Bad Request";
|
context.Response.StatusDescription = "Bad Request";
|
||||||
|
context.Response.StatusDescription = "Unauthorized";
|
||||||
|
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();
|
context.Response.End();
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!token.Valid)
|
if (!token.Valid)
|
||||||
{
|
{
|
||||||
context.Response.StatusCode = 401;
|
context.Response.StatusCode = 401;
|
||||||
context.Response.StatusDescription = "Unauthorized";
|
context.Response.StatusDescription = "Unauthorized";
|
||||||
context.Response.End();
|
context.Response.End();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,230 @@
|
||||||
|
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
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 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.
|
||||||
|
/// </summary>
|
||||||
|
public class ServeDebugAppRequestHandler : WebApiBaseRequestHandler
|
||||||
|
{
|
||||||
|
private static readonly Dictionary<string, string> MimeTypes = new Dictionary<string, string>(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<string> TextExtensions = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
".html", ".htm", ".js", ".mjs", ".jsx", ".css", ".json", ".map", ".svg", ".txt", ".xml"
|
||||||
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constructor. CORS is enabled so browser dev-tools requests succeed.
|
||||||
|
/// </summary>
|
||||||
|
public ServeDebugAppRequestHandler() : base(true) { }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles GET requests for the debug app and its static assets.
|
||||||
|
/// </summary>
|
||||||
|
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 */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Resolves the absolute path of the /HTML/debug folder on the processor.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// On a 4-series appliance, <c>Global.FilePathPrefix</c> is
|
||||||
|
/// <c>{root}/user/programX/</c>, so walking up two parents gives the
|
||||||
|
/// processor root that contains the <c>html</c> folder.
|
||||||
|
/// On Virtual Control, <c>Global.FilePathPrefix</c> is <c>{root}/User/</c>,
|
||||||
|
/// so only one parent hop is needed.
|
||||||
|
/// </remarks>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Extracts the file sub-path from the request by parsing <c>RawUrl</c>.
|
||||||
|
/// Returns an empty string when the URL ends at <c>/debug</c> (root hit).
|
||||||
|
/// </summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.IO.Compression;
|
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using Crestron.SimplSharp;
|
using Crestron.SimplSharp;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue