Files
Essentials/src/PepperDash.Essentials.MobileControl/WebSocketServer/MobileControlWebsocketServer.cs
2025-07-17 12:16:32 -05:00

1294 lines
48 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text;
using Crestron.SimplSharp;
using Crestron.SimplSharp.WebScripting;
using Newtonsoft.Json;
using PepperDash.Core;
using PepperDash.Core.Logging;
using PepperDash.Essentials.Core;
using PepperDash.Essentials.Core.DeviceTypeInterfaces;
using PepperDash.Essentials.Core.Web;
using PepperDash.Essentials.RoomBridges;
using PepperDash.Essentials.WebApiHandlers;
using Serilog.Events;
using WebSocketSharp;
using WebSocketSharp.Net;
using WebSocketSharp.Server;
namespace PepperDash.Essentials.WebSocketServer
{
public class MobileControlWebsocketServer : EssentialsDevice
{
private readonly string userAppPath = Global.FilePathPrefix + "mcUserApp" + Global.DirectorySeparator;
private readonly string localConfigFolderName = "_local-config";
private readonly string appConfigFileName = "_config.local.json";
private readonly string appConfigCsFileName = "_config.cs.json";
/// <summary>
/// Where the key is the join token and the value is the room key
/// </summary>
//private Dictionary<string, JoinToken> _joinTokens;
private HttpServer _server;
public HttpServer Server => _server;
public Dictionary<string, UiClientContext> UiClients { get; private set; }
private readonly MobileControlSystemController _parent;
private WebSocketServerSecretProvider _secretProvider;
private ServerTokenSecrets _secret;
private static readonly HttpClient LogClient = new HttpClient();
private string SecretProviderKey
{
get
{
return string.Format("{0}:{1}-tokens", Global.ControlSystem.ProgramNumber, Key);
}
}
private string lanIpAddress => CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetLANAdapter));
private System.Net.IPAddress csIpAddress;
private System.Net.IPAddress csSubnetMask;
/// <summary>
/// The path for the WebSocket messaging
/// </summary>
private readonly string _wsPath = "/mc/api/ui/join/";
public string WsPath => _wsPath;
/// <summary>
/// The path to the location of the files for the user app (single page Angular app)
/// </summary>
private readonly string _appPath = string.Format("{0}mcUserApp", Global.FilePathPrefix);
/// <summary>
/// The base HREF that the user app uses
/// </summary>
private string _userAppBaseHref = "/mc/app";
/// <summary>
/// The port the server will run on
/// </summary>
public int Port { get; private set; }
public string UserAppUrlPrefix
{
get
{
return string.Format("http://{0}:{1}{2}?token=",
CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0),
Port,
_userAppBaseHref);
}
}
public int ConnectedUiClientsCount
{
get
{
var count = 0;
foreach (var client in UiClients)
{
if (client.Value.Client != null && client.Value.Client.Context.WebSocket.IsAlive)
{
count++;
}
}
return count;
}
}
public MobileControlWebsocketServer(string key, int customPort, MobileControlSystemController parent)
: base(key)
{
_parent = parent;
// Set the default port to be 50000 plus the slot number of the program
Port = 50000 + (int)Global.ControlSystem.ProgramNumber;
if (customPort != 0)
{
Port = customPort;
}
if (parent.Config.DirectServer.AutomaticallyForwardPortToCSLAN == true)
{
try
{
Debug.LogMessage(LogEventLevel.Information, "Automatically forwarding port {0} to CS LAN", Port);
var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetCSAdapter);
var csIp = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
var result = CrestronEthernetHelper.AddPortForwarding((ushort)Port, (ushort)Port, csIp, CrestronEthernetHelper.ePortMapTransport.TCP);
if (result != CrestronEthernetHelper.PortForwardingUserPatRetCodes.NoErr)
{
Debug.LogMessage(LogEventLevel.Error, "Error adding port forwarding: {0}", result);
}
}
catch (ArgumentException)
{
Debug.LogMessage(LogEventLevel.Information, "This processor does not have a CS LAN", this);
}
catch (Exception ex)
{
Debug.LogMessage(ex, "Error automatically forwarding port to CS LAN");
}
}
try
{
var csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetCSAdapter);
var csSubnetMask = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_MASK, csAdapterId);
var csIpAddress = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
this.csSubnetMask = System.Net.IPAddress.Parse(csSubnetMask);
this.csIpAddress = System.Net.IPAddress.Parse(csIpAddress);
}
catch (ArgumentException)
{
if (parent.Config.DirectServer.AutomaticallyForwardPortToCSLAN == false)
{
Debug.LogMessage(LogEventLevel.Information, "This processor does not have a CS LAN", this);
}
}
UiClients = new Dictionary<string, UiClientContext>();
//_joinTokens = new Dictionary<string, JoinToken>();
if (Global.Platform == eDevicePlatform.Appliance)
{
AddConsoleCommands();
}
AddPreActivationAction(() => AddWebApiPaths());
}
private void AddWebApiPaths()
{
var apiServer = DeviceManager.AllDevices.OfType<EssentialsWebApi>().FirstOrDefault();
if (apiServer == null)
{
this.LogInformation("No API Server available");
return;
}
var routes = new List<HttpCwsRoute>
{
new HttpCwsRoute($"devices/{Key}/client")
{
Name = "ClientHandler",
RouteHandler = new UiClientHandler(this)
},
};
apiServer.AddRoute(routes);
}
private void AddConsoleCommands()
{
CrestronConsole.AddNewConsoleCommand(GenerateClientTokenFromConsole, "MobileAddUiClient", "Adds a client and generates a token. ? for more help", ConsoleAccessLevelEnum.AccessOperator);
CrestronConsole.AddNewConsoleCommand(RemoveToken, "MobileRemoveUiClient", "Removes a client. ? for more help", ConsoleAccessLevelEnum.AccessOperator);
CrestronConsole.AddNewConsoleCommand((s) => PrintClientInfo(), "MobileGetClientInfo", "Displays the current client info", ConsoleAccessLevelEnum.AccessOperator);
CrestronConsole.AddNewConsoleCommand(RemoveAllTokens, "MobileRemoveAllClients", "Removes all clients", ConsoleAccessLevelEnum.AccessOperator);
}
public override void Initialize()
{
try
{
base.Initialize();
_server = new HttpServer(Port, false);
_server.OnGet += Server_OnGet;
_server.OnOptions += Server_OnOptions;
if (_parent.Config.DirectServer.Logging.EnableRemoteLogging)
{
_server.OnPost += Server_OnPost;
}
CrestronEnvironment.ProgramStatusEventHandler += CrestronEnvironment_ProgramStatusEventHandler;
_server.Start();
if (_server.IsListening)
{
Debug.LogMessage(LogEventLevel.Information, "Mobile Control WebSocket Server listening on port {port}", this, _server.Port);
}
CrestronEnvironment.ProgramStatusEventHandler += OnProgramStop;
RetrieveSecret();
CreateFolderStructure();
AddClientsForTouchpanels();
}
catch (Exception ex)
{
Debug.LogMessage(ex, "Exception intializing websocket server", this);
}
}
private void AddClientsForTouchpanels()
{
var touchpanels = DeviceManager.AllDevices
.OfType<IMobileControlTouchpanelController>().Where(tp => tp.UseDirectServer);
var touchpanelsToAdd = new List<IMobileControlTouchpanelController>();
if (_secret != null)
{
var newTouchpanels = touchpanels.Where(tp => !_secret.Tokens.Any(t => t.Value.TouchpanelKey != null && t.Value.TouchpanelKey.Equals(tp.Key, StringComparison.InvariantCultureIgnoreCase)));
touchpanelsToAdd.AddRange(newTouchpanels);
}
else
{
touchpanelsToAdd.AddRange(touchpanels);
}
foreach (var client in touchpanelsToAdd)
{
var bridge = _parent.GetRoomBridge(client.DefaultRoomKey);
if (bridge == null)
{
this.LogWarning("Unable to find room with key: {defaultRoomKey}", client.DefaultRoomKey);
return;
}
var (key, path) = GenerateClientToken(bridge, client.Key);
if (key == null)
{
this.LogWarning("Unable to generate a client for {clientKey}", client.Key);
continue;
}
}
var lanAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetLANAdapter);
var processorIp = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, lanAdapterId);
foreach (var touchpanel in touchpanels.Select(tp =>
{
var token = _secret.Tokens.FirstOrDefault((t) => t.Value.TouchpanelKey.Equals(tp.Key, StringComparison.InvariantCultureIgnoreCase));
var messenger = _parent.GetRoomBridge(tp.DefaultRoomKey);
return new { token.Key, Touchpanel = tp, Messenger = messenger };
}))
{
if (touchpanel.Key == null)
{
this.LogWarning("Token for touchpanel {touchpanelKey} not found", touchpanel.Touchpanel.Key);
continue;
}
if (touchpanel.Messenger == null)
{
this.LogWarning("Unable to find room messenger for {defaultRoomKey}", touchpanel.Touchpanel.DefaultRoomKey);
continue;
}
string ip = processorIp;
if (touchpanel.Touchpanel is IMobileControlCrestronTouchpanelController crestronTouchpanel)
{
ip = crestronTouchpanel.ConnectedIps.Any(ipInfo =>
{
if (System.Net.IPAddress.TryParse(ipInfo.DeviceIpAddress, out var parsedIp))
{
return csIpAddress.IsInSameSubnet(parsedIp, csSubnetMask);
}
this.LogWarning("Invalid IP address: {deviceIpAddress}", ipInfo.DeviceIpAddress);
return false;
}) ? csIpAddress.ToString() : processorIp;
}
var appUrl = $"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}";
this.LogVerbose("Sending URL {appUrl}", appUrl);
touchpanel.Messenger.UpdateAppUrl($"http://{ip}:{_parent.Config.DirectServer.Port}/mc/app?token={touchpanel.Key}");
}
}
private void OnProgramStop(eProgramStatusEventType programEventType)
{
switch (programEventType)
{
case eProgramStatusEventType.Stopping:
_server.Stop();
break;
}
}
private void CreateFolderStructure()
{
if (!Directory.Exists(userAppPath))
{
Directory.CreateDirectory(userAppPath);
}
if (!Directory.Exists($"{userAppPath}{localConfigFolderName}"))
{
Directory.CreateDirectory($"{userAppPath}{localConfigFolderName}");
}
using (var sw = new StreamWriter(File.Open($"{userAppPath}{localConfigFolderName}{Global.DirectorySeparator}{appConfigFileName}", FileMode.Create, FileAccess.ReadWrite)))
{
// Write the LAN application configuration file. Used when a request comes in for the application config from the LAN
var lanAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetLANAdapter);
this.LogDebug("LAN Adapter ID: {lanAdapterId}", lanAdapterId);
var processorIp = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, lanAdapterId);
var config = GetApplicationConfig(processorIp);
var contents = JsonConvert.SerializeObject(config, Formatting.Indented);
sw.Write(contents);
}
short csAdapterId;
try
{
csAdapterId = CrestronEthernetHelper.GetAdapterdIdForSpecifiedAdapterType(EthernetAdapterType.EthernetCSAdapter);
}
catch (ArgumentException)
{
this.LogDebug("This processor does not have a CS LAN");
return;
}
if (csAdapterId == -1)
{
this.LogDebug("CS LAN Adapter not found");
return;
}
this.LogDebug("CS LAN Adapter ID: {csAdapterId}. Adding CS Config", csAdapterId);
using (var sw = new StreamWriter(File.Open($"{userAppPath}{localConfigFolderName}{Global.DirectorySeparator}{appConfigCsFileName}", FileMode.Create, FileAccess.ReadWrite)))
{
// Write the CS application configuration file. Used when a request comes in for the application config from the CS
var processorIp = CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, csAdapterId);
var config = GetApplicationConfig(processorIp);
var contents = JsonConvert.SerializeObject(config, Formatting.Indented);
sw.Write(contents);
}
}
private MobileControlApplicationConfig GetApplicationConfig(string processorIp)
{
try
{
var config = new MobileControlApplicationConfig
{
ApiPath = string.Format("http://{0}:{1}/mc/api", processorIp, _parent.Config.DirectServer.Port),
GatewayAppPath = "",
LogoPath = _parent.Config.ApplicationConfig?.LogoPath ?? "logo/logo.png",
EnableDev = _parent.Config.ApplicationConfig?.EnableDev ?? false,
IconSet = _parent.Config.ApplicationConfig?.IconSet ?? MCIconSet.GOOGLE,
LoginMode = _parent.Config.ApplicationConfig?.LoginMode ?? "room-list",
Modes = _parent.Config.ApplicationConfig?.Modes ?? new Dictionary<string, McMode>
{
{
"room-list",
new McMode {
ListPageText = "Please select your room",
LoginHelpText = "Please select your room from the list, then enter the code shown on the display.",
PasscodePageText = "Please enter the code shown on this room's display"
}
}
},
Logging = _parent.Config.ApplicationConfig?.Logging ?? false,
PartnerMetadata = _parent.Config.ApplicationConfig?.PartnerMetadata ?? new List<MobileControlPartnerMetadata>()
};
return config;
}
catch (Exception ex)
{
this.LogError(ex, "Error getting application configuration");
return null;
}
}
/// <summary>
/// Attempts to retrieve secrets previously stored in memory
/// </summary>
private void RetrieveSecret()
{
try
{
// Add secret provider
_secretProvider = new WebSocketServerSecretProvider(SecretProviderKey);
// Check for existing secrets
var secret = _secretProvider.GetSecret(SecretProviderKey);
if (secret != null)
{
Debug.LogMessage(LogEventLevel.Information, "Secret successfully retrieved", this);
Debug.LogMessage(LogEventLevel.Debug, "Secret: {0}", this, secret.Value.ToString());
// populate the local secrets object
_secret = JsonConvert.DeserializeObject<ServerTokenSecrets>(secret.Value.ToString());
if (_secret != null && _secret.Tokens != null)
{
// populate the _uiClient collection
foreach (var token in _secret.Tokens)
{
if (token.Value == null)
{
Debug.LogMessage(LogEventLevel.Warning, "Token value is null", this);
continue;
}
Debug.LogMessage(LogEventLevel.Information, "Adding token: {0} for room: {1}", this, token.Key, token.Value.RoomKey);
if (UiClients == null)
{
Debug.LogMessage(LogEventLevel.Warning, "UiClients is null", this);
UiClients = new Dictionary<string, UiClientContext>();
}
UiClients.Add(token.Key, new UiClientContext(token.Value));
}
}
if (UiClients.Count > 0)
{
Debug.LogMessage(LogEventLevel.Information, "Restored {uiClientCount} UiClients from secrets data", this, UiClients.Count);
foreach (var client in UiClients)
{
var key = client.Key;
var path = _wsPath + key;
var roomKey = client.Value.Token.RoomKey;
_server.AddWebSocketService(path, () =>
{
var c = new UiClient();
Debug.LogMessage(LogEventLevel.Debug, "Constructing UiClient with id: {key}", this, key);
c.Controller = _parent;
c.RoomKey = roomKey;
UiClients[key].SetClient(c);
return c;
});
//_server.WebSocketServices.AddService<UiClient>(path, (c) =>
//{
// Debug.Console(2, this, "Constructing UiClient with id: {0}", key);
// c.Controller = _parent;
// c.RoomKey = roomKey;
// UiClients[key].SetClient(c);
//});
}
}
}
else
{
Debug.LogMessage(LogEventLevel.Warning, "No secret found");
}
Debug.LogMessage(LogEventLevel.Debug, "{uiClientCount} UiClients restored from secrets data", this, UiClients.Count);
}
catch (Exception ex)
{
Debug.LogMessage(ex, "Exception retrieving secret", this);
}
}
/// <summary>
/// Stores secrets to memory to persist through reboot
/// </summary>
public void UpdateSecret()
{
try
{
if (_secret == null)
{
Debug.LogMessage(LogEventLevel.Error, "Secret is null", this);
_secret = new ServerTokenSecrets(string.Empty);
}
_secret.Tokens.Clear();
foreach (var uiClientContext in UiClients)
{
_secret.Tokens.Add(uiClientContext.Key, uiClientContext.Value.Token);
}
var serializedSecret = JsonConvert.SerializeObject(_secret);
_secretProvider.SetSecret(SecretProviderKey, serializedSecret);
}
catch (Exception ex)
{
Debug.LogMessage(ex, "Exception updating secret", this);
}
}
/// <summary>
/// Generates a new token based on validating a room key and grant code passed in. If valid, returns a token and adds a service to the server for that token's path
/// </summary>
/// <param name="s"></param>
private void GenerateClientTokenFromConsole(string s)
{
if (s == "?" || string.IsNullOrEmpty(s))
{
CrestronConsole.ConsoleCommandResponse(@"[RoomKey] [GrantCode] Validates the room key against the grant code and returns a token for use in a UI client");
return;
}
var values = s.Split(' ');
if (values.Length < 2)
{
CrestronConsole.ConsoleCommandResponse("Invalid number of arguments. Please provide a room key and a grant code");
return;
}
var roomKey = values[0];
var grantCode = values[1];
var bridge = _parent.GetRoomBridge(roomKey);
if (bridge == null)
{
CrestronConsole.ConsoleCommandResponse(string.Format("Unable to find room with key: {0}", roomKey));
return;
}
var (token, path) = ValidateGrantCode(grantCode, bridge);
if (token == null)
{
CrestronConsole.ConsoleCommandResponse("Grant Code is not valid");
return;
}
CrestronConsole.ConsoleCommandResponse($"Added new WebSocket UiClient service at path: {path}");
CrestronConsole.ConsoleCommandResponse($"Token: {token}");
}
public (string, string) ValidateGrantCode(string grantCode, string roomKey)
{
var bridge = _parent.GetRoomBridge(roomKey);
if (bridge == null)
{
this.LogWarning("Unable to find room with key: {roomKey}", roomKey);
return (null, null);
}
return ValidateGrantCode(grantCode, bridge);
}
public (string, string) ValidateGrantCode(string grantCode, MobileControlBridgeBase bridge)
{
// TODO: Authenticate grant code passed in
// For now, we just generate a random guid as the token and use it as the ClientId as well
var grantCodeIsValid = true;
if (grantCodeIsValid)
{
if (_secret == null)
{
_secret = new ServerTokenSecrets(grantCode);
}
return GenerateClientToken(bridge, "");
}
else
{
return (null, null);
}
}
public (string, string) GenerateClientToken(MobileControlBridgeBase bridge, string touchPanelKey = "")
{
var key = Guid.NewGuid().ToString();
var token = new JoinToken { Code = bridge.UserCode, RoomKey = bridge.RoomKey, Uuid = _parent.SystemUuid, TouchpanelKey = touchPanelKey };
UiClients.Add(key, new UiClientContext(token));
var path = _wsPath + key;
_server.AddWebSocketService(path, () =>
{
var c = new UiClient();
Debug.LogMessage(LogEventLevel.Verbose, "Constructing UiClient with id: {0}", this, key);
c.Controller = _parent;
c.RoomKey = bridge.RoomKey;
UiClients[key].SetClient(c);
return c;
});
Debug.LogMessage(LogEventLevel.Information, "Added new WebSocket UiClient service at path: {path}", this, path);
Debug.LogMessage(LogEventLevel.Information, "Token: {@token}", this, token);
Debug.LogMessage(LogEventLevel.Verbose, "{serviceCount} websocket services present", this, _server.WebSocketServices.Count);
UpdateSecret();
return (key, path);
}
/// <summary>
/// Removes all clients from the server
/// </summary>
private void RemoveAllTokens(string s)
{
if (s == "?" || string.IsNullOrEmpty(s))
{
CrestronConsole.ConsoleCommandResponse(@"Removes all clients from the server. To execute add 'confirm' to command");
return;
}
if (s != "confirm")
{
CrestronConsole.ConsoleCommandResponse(@"To remove all clients, add 'confirm' to the command");
return;
}
foreach (var client in UiClients)
{
if (client.Value.Client != null && client.Value.Client.Context.WebSocket.IsAlive)
{
client.Value.Client.Context.WebSocket.Close(CloseStatusCode.Normal, "Server Shutting Down");
}
var path = _wsPath + client.Key;
if (_server.RemoveWebSocketService(path))
{
CrestronConsole.ConsoleCommandResponse(string.Format("Client removed with token: {0}", client.Key));
}
else
{
CrestronConsole.ConsoleCommandResponse(string.Format("Unable to remove client with token : {0}", client.Key));
}
}
UiClients.Clear();
UpdateSecret();
}
/// <summary>
/// Removes a client with the specified token value
/// </summary>
/// <param name="s"></param>
private void RemoveToken(string s)
{
if (s == "?" || string.IsNullOrEmpty(s))
{
CrestronConsole.ConsoleCommandResponse(@"[token] Removes the client with the specified token value");
return;
}
var key = s;
if (UiClients.ContainsKey(key))
{
var uiClientContext = UiClients[key];
if (uiClientContext.Client != null && uiClientContext.Client.Context.WebSocket.IsAlive)
{
uiClientContext.Client.Context.WebSocket.Close(CloseStatusCode.Normal, "Token removed from server");
}
var path = _wsPath + key;
if (_server.RemoveWebSocketService(path))
{
UiClients.Remove(key);
UpdateSecret();
CrestronConsole.ConsoleCommandResponse(string.Format("Client removed with token: {0}", key));
}
else
{
CrestronConsole.ConsoleCommandResponse(string.Format("Unable to remove client with token : {0}", key));
}
}
else
{
CrestronConsole.ConsoleCommandResponse(string.Format("Unable to find client with token: {0}", key));
}
}
/// <summary>
/// Prints out info about current client IDs
/// </summary>
private void PrintClientInfo()
{
CrestronConsole.ConsoleCommandResponse("Mobile Control UI Client Info:\r");
CrestronConsole.ConsoleCommandResponse(string.Format("{0} clients found:\r", UiClients.Count));
foreach (var client in UiClients)
{
CrestronConsole.ConsoleCommandResponse(string.Format("RoomKey: {0} Token: {1}\r", client.Value.Token.RoomKey, client.Key));
}
}
private void CrestronEnvironment_ProgramStatusEventHandler(eProgramStatusEventType programEventType)
{
if (programEventType == eProgramStatusEventType.Stopping)
{
foreach (var client in UiClients.Values)
{
if (client.Client != null && client.Client.Context.WebSocket.IsAlive)
{
client.Client.Context.WebSocket.Close(CloseStatusCode.Normal, "Server Shutting Down");
}
}
StopServer();
}
}
/// <summary>
/// Handler for GET requests to server
/// </summary>
/// <param name="sender"></param>
/// <param name="e"></param>
private void Server_OnGet(object sender, HttpRequestEventArgs e)
{
try
{
var req = e.Request;
var res = e.Response;
res.ContentEncoding = Encoding.UTF8;
res.AddHeader("Access-Control-Allow-Origin", "*");
var path = req.RawUrl;
this.LogVerbose("GET Request received at path: {path}", path);
// Call for user app to join the room with a token
if (path.StartsWith("/mc/api/ui/joinroom"))
{
HandleJoinRequest(req, res);
}
// Call to get the server version
else if (path.StartsWith("/mc/api/version"))
{
HandleVersionRequest(res);
}
else if (path.StartsWith("/mc/app/logo"))
{
HandleImageRequest(req, res);
}
// Call to serve the user app
else if (path.StartsWith(_userAppBaseHref))
{
HandleUserAppRequest(req, res, path);
}
else
{
// All other paths
res.StatusCode = 404;
res.Close();
}
}
catch (Exception ex)
{
Debug.LogMessage(ex, "Caught an exception in the OnGet handler", this);
}
}
private async void Server_OnPost(object sender, HttpRequestEventArgs e)
{
try
{
var req = e.Request;
var res = e.Response;
res.AddHeader("Access-Control-Allow-Origin", "*");
var path = req.RawUrl;
var ip = req.RemoteEndPoint.Address.ToString();
this.LogVerbose("POST Request received at path: {path} from host {host}", path, ip);
var body = new StreamReader(req.InputStream).ReadToEnd();
if (path.StartsWith("/mc/api/log"))
{
res.StatusCode = 200;
res.Close();
var logRequest = new HttpRequestMessage(HttpMethod.Post, $"http://{_parent.Config.DirectServer.Logging.Host}:{_parent.Config.DirectServer.Logging.Port}/logs")
{
Content = new StringContent(body, Encoding.UTF8, "application/json"),
};
logRequest.Headers.Add("x-pepperdash-host", ip);
await LogClient.SendAsync(logRequest);
this.LogVerbose("Log data sent to {host}:{port}", _parent.Config.DirectServer.Logging.Host, _parent.Config.DirectServer.Logging.Port);
}
else
{
res.StatusCode = 404;
res.Close();
}
}
catch (Exception ex)
{
this.LogException(ex, "Caught an exception in the OnPost handler");
}
}
private void Server_OnOptions(object sender, HttpRequestEventArgs e)
{
try
{
var res = e.Response;
res.AddHeader("Access-Control-Allow-Origin", "*");
res.AddHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
res.AddHeader("Access-Control-Allow-Headers", "Content-Type, Accept, X-Requested-With, remember-me");
res.StatusCode = 200;
res.Close();
}
catch (Exception ex)
{
Debug.LogMessage(ex, "Caught an exception in the OnPost handler", this);
}
}
/// <summary>
/// Handle the request to join the room with a token
/// </summary>
/// <param name="req"></param>
/// <param name="res"></param>
private void HandleJoinRequest(HttpListenerRequest req, HttpListenerResponse res)
{
var qp = req.QueryString;
var token = qp["token"];
this.LogVerbose("Join Room Request with token: {token}", token);
if (UiClients.TryGetValue(token, out UiClientContext clientContext))
{
var bridge = _parent.GetRoomBridge(clientContext.Token.RoomKey);
if (bridge != null)
{
res.StatusCode = 200;
res.ContentType = "application/json";
// Construct the response object
JoinResponse jRes = new JoinResponse
{
ClientId = token,
RoomKey = bridge.RoomKey,
SystemUuid = _parent.SystemUuid,
RoomUuid = _parent.SystemUuid,
Config = _parent.GetConfigWithPluginVersion(),
CodeExpires = new DateTime().AddYears(1),
UserCode = bridge.UserCode,
UserAppUrl = string.Format("http://{0}:{1}/mc/app",
CrestronEthernetHelper.GetEthernetParameter(CrestronEthernetHelper.ETHERNET_PARAMETER_TO_GET.GET_CURRENT_IP_ADDRESS, 0),
Port),
EnableDebug = false
};
// Serialize to JSON and convert to Byte[]
var json = JsonConvert.SerializeObject(jRes);
var body = Encoding.UTF8.GetBytes(json);
res.ContentLength64 = body.LongLength;
// Send the response
res.Close(body, true);
}
else
{
var message = string.Format("Unable to find bridge with key: {0}", clientContext.Token.RoomKey);
res.StatusCode = 404;
res.ContentType = "application/json";
this.LogVerbose("{message}", message);
var body = Encoding.UTF8.GetBytes(message);
res.ContentLength64 = body.LongLength;
res.Close(body, true);
}
}
else
{
var message = "Token invalid or has expired";
res.StatusCode = 401;
res.ContentType = "application/json";
this.LogVerbose("{message}", message);
var body = Encoding.UTF8.GetBytes(message);
res.ContentLength64 = body.LongLength;
res.Close(body, true);
}
}
/// <summary>
/// Handles a server version request
/// </summary>
/// <param name="res"></param>
private void HandleVersionRequest(HttpListenerResponse res)
{
res.StatusCode = 200;
res.ContentType = "application/json";
var version = new Version() { ServerVersion = _parent.GetConfigWithPluginVersion().RuntimeInfo.PluginVersion };
var message = JsonConvert.SerializeObject(version);
this.LogVerbose("{message}", message);
var body = Encoding.UTF8.GetBytes(message);
res.ContentLength64 = body.LongLength;
res.Close(body, true);
}
/// <summary>
/// Handler to return images requested by the user app
/// </summary>
/// <param name="req"></param>
/// <param name="res"></param>
private void HandleImageRequest(HttpListenerRequest req, HttpListenerResponse res)
{
var path = req.RawUrl;
Debug.LogMessage(LogEventLevel.Verbose, "Requesting Image: {0}", this, path);
var imageBasePath = Global.DirectorySeparator + "html" + Global.DirectorySeparator + "logo" + Global.DirectorySeparator;
var image = path.Split('/').Last();
var filePath = imageBasePath + image;
Debug.LogMessage(LogEventLevel.Verbose, "Retrieving Image: {0}", this, filePath);
if (File.Exists(filePath))
{
if (filePath.EndsWith(".png"))
{
res.ContentType = "image/png";
}
else if (filePath.EndsWith(".jpg"))
{
res.ContentType = "image/jpeg";
}
else if (filePath.EndsWith(".gif"))
{
res.ContentType = "image/gif";
}
else if (filePath.EndsWith(".svg"))
{
res.ContentType = "image/svg+xml";
}
byte[] contents = File.ReadAllBytes(filePath);
res.ContentLength64 = contents.LongLength;
res.Close(contents, true);
}
else
{
res.StatusCode = (int)HttpStatusCode.NotFound;
res.Close();
}
}
/// <summary>
/// Handles requests to serve files for the Angular single page app
/// </summary>
/// <param name="req"></param>
/// <param name="res"></param>
/// <param name="path"></param>
private void HandleUserAppRequest(HttpListenerRequest req, HttpListenerResponse res, string path)
{
this.LogVerbose("Requesting User app file");
string filePath = path.Split('?')[0];
// remove the token from the path if found
//string filePath = path.Replace(string.Format("?token={0}", token), "");
// if there's no file suffix strip any extra path data after the base href
if (filePath != _userAppBaseHref && !filePath.Contains(".") && (!filePath.EndsWith(_userAppBaseHref) || !filePath.EndsWith(_userAppBaseHref += "/")))
{
var suffix = filePath.Substring(_userAppBaseHref.Length, filePath.Length - _userAppBaseHref.Length);
if (suffix != "/")
{
//Debug.Console(2, this, "Suffix: {0}", suffix);
filePath = filePath.Replace(suffix, "");
}
}
// swap the base href prefix for the file path prefix
filePath = filePath.Replace(_userAppBaseHref, _appPath);
this.LogVerbose("filepath: {filePath}", filePath);
// append index.html if no specific file is specified
if (!filePath.Contains("."))
{
if (filePath.EndsWith("/"))
{
filePath += "index.html";
}
else
{
filePath += "/index.html";
}
}
// Set ContentType based on file type
if (filePath.EndsWith(".html"))
{
this.LogVerbose("Client requesting User App");
res.ContentType = "text/html";
}
else
{
if (path.EndsWith(".js"))
{
res.ContentType = "application/javascript";
}
else if (path.EndsWith(".css"))
{
res.ContentType = "text/css";
}
else if (path.EndsWith(".json"))
{
res.ContentType = "application/json";
}
}
this.LogVerbose("Attempting to serve file: {filePath}", filePath);
var remoteIp = req.RemoteEndPoint.Address;
// Check if the request is coming from the CS LAN and if so, send the CS config instead of the LAN config
if (csSubnetMask != null && csIpAddress != null && remoteIp.IsInSameSubnet(csIpAddress, csSubnetMask) && filePath.Contains(appConfigFileName))
{
filePath = filePath.Replace(appConfigFileName, appConfigCsFileName);
}
byte[] contents;
if (File.Exists(filePath))
{
this.LogVerbose("File found: {filePath}", filePath);
contents = File.ReadAllBytes(filePath);
}
else
{
this.LogVerbose("File not found: {filePath}", filePath);
res.StatusCode = (int)HttpStatusCode.NotFound;
res.Close();
return;
}
res.ContentLength64 = contents.LongLength;
res.Close(contents, true);
}
public void StopServer()
{
this.LogVerbose("Stopping WebSocket Server");
_server.Stop(CloseStatusCode.Normal, "Server Shutting Down");
}
/// <summary>
/// Sends a message to all connectd clients
/// </summary>
/// <param name="message"></param>
public void SendMessageToAllClients(string message)
{
foreach (var clientContext in UiClients.Values)
{
if (clientContext.Client != null && clientContext.Client.Context.WebSocket.IsAlive)
{
clientContext.Client.Context.WebSocket.Send(message);
}
}
}
/// <summary>
/// Sends a message to a specific client
/// </summary>
/// <param name="clientId"></param>
/// <param name="message"></param>
public void SendMessageToClient(object clientId, string message)
{
if (clientId == null)
{
return;
}
if (UiClients.TryGetValue((string)clientId, out UiClientContext clientContext))
{
if (clientContext.Client != null)
{
var socket = clientContext.Client.Context.WebSocket;
if (socket.IsAlive)
{
socket.Send(message);
}
}
}
else
{
this.LogWarning("Unable to find client with ID: {clientId}", clientId);
}
}
}
/// <summary>
/// Class to describe the server version info
/// </summary>
public class Version
{
[JsonProperty("serverVersion")]
public string ServerVersion { get; set; }
[JsonProperty("serverIsRunningOnProcessorHardware")]
public bool ServerIsRunningOnProcessorHardware { get; private set; }
public Version()
{
ServerIsRunningOnProcessorHardware = true;
}
}
/// <summary>
/// Represents an instance of a UiClient and the associated Token
/// </summary>
public class UiClientContext
{
public UiClient Client { get; private set; }
public JoinToken Token { get; private set; }
public UiClientContext(JoinToken token)
{
Token = token;
}
public void SetClient(UiClient client)
{
Client = client;
}
}
/// <summary>
/// Represents the data structure for the grant code and UiClient tokens to be stored in the secrets manager
/// </summary>
public class ServerTokenSecrets
{
public string GrantCode { get; set; }
public Dictionary<string, JoinToken> Tokens { get; set; }
public ServerTokenSecrets(string grantCode)
{
GrantCode = grantCode;
Tokens = new Dictionary<string, JoinToken>();
}
}
/// <summary>
/// Represents a join token with the associated properties
/// </summary>
public class JoinToken
{
public string Code { get; set; }
public string RoomKey { get; set; }
public string Uuid { get; set; }
public string TouchpanelKey { get; set; } = "";
public string Token { get; set; } = null;
}
/// <summary>
/// Represents the structure of the join response
/// </summary>
public class JoinResponse
{
[JsonProperty("clientId")]
public string ClientId { get; set; }
[JsonProperty("roomKey")]
public string RoomKey { get; set; }
[JsonProperty("systemUUid")]
public string SystemUuid { get; set; }
[JsonProperty("roomUUid")]
public string RoomUuid { get; set; }
[JsonProperty("config")]
public object Config { get; set; }
[JsonProperty("codeExpires")]
public DateTime CodeExpires { get; set; }
[JsonProperty("userCode")]
public string UserCode { get; set; }
[JsonProperty("userAppUrl")]
public string UserAppUrl { get; set; }
[JsonProperty("enableDebug")]
public bool EnableDebug { get; set; }
}
}