using System.Linq; using System.Text; using System; using System.Collections.Generic; using System.IO; using System.Net; using System.Runtime.Serialization.Formatters.Binary; using System.Threading.Tasks; using CefSharp; using Newtonsoft.Json; namespace VRCX { public class WorldDBManager { public static WorldDBManager Instance; private readonly HttpListener listener; private readonly WorldDatabase worldDB; private static NLog.Logger logger = NLog.LogManager.GetCurrentClassLogger(); private string lastError = null; private bool debugWorld = false; public WorldDBManager(string url) { Instance = this; // http://localhost:22500 listener = new HttpListener(); listener.Prefixes.Add(url); worldDB = new WorldDatabase(Path.Combine(Program.AppDataDirectory, "VRCX-WorldData.db")); } public async Task Start() { // typing this in vr gonna kms try { listener.Start(); } catch (HttpListenerException e) { logger.Error(e, "Failed to start HTTP listener. Is VRCX already running?"); return; } logger.Info("Listening for requests on {0}", listener.Prefixes.First()); while (true) { var context = await listener.GetContextAsync(); var request = context.Request; var responseData = new WorldDataRequestResponse(false, null, null); try { if (MainForm.Instance?.Browser == null || MainForm.Instance.Browser.IsLoading || !MainForm.Instance.Browser.CanExecuteJavascriptInMainFrame) { logger.Error("Received a request to {0} while VRCX is still initializing the browser window. Responding with error 503.", request.Url); responseData.Error = "VRCX not yet initialized. Try again in a moment."; responseData.StatusCode = 503; responseData.ConnectionKey = null; SendJsonResponse(context.Response, responseData); continue; }; logger.Debug("Received a request to '{0}'", request.Url); // TODO: Maybe an endpoint for getting a group of arbitrary keys by a group 'name'? eg; /getgroup?name=testgroup1 would return all keys with the column group set to 'testgroup1' switch (request.Url.LocalPath) { case "/vrcx/data/init": responseData = await HandleInitRequest(context); SendJsonResponse(context.Response, responseData); break; case "/vrcx/data/get": responseData = await HandleDataRequest(context); SendJsonResponse(context.Response, responseData); break; case "/vrcx/data/getall": responseData = await HandleAllDataRequest(context); SendJsonResponse(context.Response, responseData); break; case "/vrcx/data/lasterror": responseData.OK = lastError == null; responseData.StatusCode = 200; responseData.Data = lastError; responseData.ConnectionKey = null; lastError = null; SendJsonResponse(context.Response, responseData); break; case "/vrcx/data/getbulk": responseData = await HandleBulkDataRequest(context); SendJsonResponse(context.Response, responseData); break; case "/vrcx/data/settings": responseData = await HandleSetSettingsRequest(context); SendJsonResponse(context.Response, responseData); break; case "/vrcx/status": // Send a blank 200 response to indicate that the server is running. context.Response.StatusCode = 200; context.Response.Close(); break; default: responseData.Error = "Invalid VRCX endpoint."; responseData.StatusCode = 404; responseData.ConnectionKey = null; SendJsonResponse(context.Response, responseData); break; } if (context.Response.StatusCode != 200) { logger.Warn("Received a request to '{0}' that returned a non-successful response. Error: {1} - {2}", request.Url, responseData.StatusCode, responseData.Error); } } catch (Exception ex) { logger.Error(ex, $"Exception while processing a request to endpoint '{request.Url}'."); responseData.Error = $"VRCX has encountered an exception while processing the url '{request.Url}': {ex.Message}"; responseData.StatusCode = 500; responseData.ConnectionKey = null; SendJsonResponse(context.Response, responseData); } } } private async Task HandleSetSettingsRequest(HttpListenerContext context) { var request = context.Request; string worldId = await GetCurrentWorldID(); string set = request.QueryString["set"]; string value = request.QueryString["value"]; if (!TryInitializeWorld(worldId, out string connectionKey)) { return ConstructErrorResponse(500, "Failed to get/verify current world ID."); } if (set != null && value != null) { switch (set) { case "externalReads": if (request.QueryString["value"] == "true") { worldDB.SetWorldAllowExternalRead(worldId, true); } else if (request.QueryString["value"] == "false") { worldDB.SetWorldAllowExternalRead(worldId, false); } else { return ConstructErrorResponse(400, "Invalid value for 'externalReads' setting."); } break; default: return ConstructErrorResponse(400, "Invalid setting name."); } } return ConstructSuccessResponse(null, connectionKey); } /// /// Handles an HTTP listener request to initialize a connection to the world db manager. /// /// The HTTP listener context object. /// A object containing the response data. private async Task HandleInitRequest(HttpListenerContext context) { var request = context.Request; if (request.QueryString["debug"] == "true") { debugWorld = true; } else if (request.QueryString["debug"] == "false") { debugWorld = false; } string worldId = await GetCurrentWorldID(); if (TryInitializeWorld(worldId, out string connectionKey)) { logger.Info("Initialized a connection to the world database for world ID '{0}' with connection key {1}.", worldId, connectionKey); return ConstructSuccessResponse(connectionKey, connectionKey); } else { return ConstructErrorResponse(500, "Failed to get/verify current world ID."); } } /// /// Handles an HTTP listener request for data from the world database. /// /// The HTTP listener context object. /// A object containing the response data. private async Task HandleDataRequest(HttpListenerContext context) { var request = context.Request; var key = request.QueryString["key"]; if (key == null) { return ConstructErrorResponse(400, "Missing key parameter."); } var worldId = await GetCurrentWorldID(); if (!TryInitializeWorld(worldId, out string connectionKey)) { return ConstructErrorResponse(500, "Failed to get/verify current world ID."); } var worldOverride = request.QueryString["world"]; if (worldOverride != null && worldId != worldOverride) { var allowed = worldDB.GetWorldAllowExternalRead(worldOverride); if (!allowed) { return ConstructSuccessResponse(null, connectionKey); } var otherValue = worldDB.GetDataEntry(worldOverride, key); logger.Debug("Serving a request for data with key '{0}' from world ID '{1}' requested by world ID '{2}' with connection key {3}.", key, worldOverride, worldId, connectionKey); // This value is intended to be null if the key doesn't exist. return ConstructSuccessResponse(otherValue?.Value, connectionKey); } var value = worldDB.GetDataEntry(worldId, key); logger.Debug("Serving a request for data with key '{0}' from world ID '{1}' with connection key {2}.", key, worldId, connectionKey); // This value is intended to be null if the key doesn't exist. return ConstructSuccessResponse(value?.Value, connectionKey); } /// /// Handles an HTTP listener request for all data from the world database for a given world. /// /// The HTTP listener context object. /// A object containing the response data. private async Task HandleAllDataRequest(HttpListenerContext context) { var request = context.Request; var worldId = await GetCurrentWorldID(); if (!TryInitializeWorld(worldId, out string connectionKey)) { return ConstructErrorResponse(500, "Failed to get/verify current world ID."); } var worldOverride = request.QueryString["world"]; if (worldOverride != null && worldId != worldOverride) { var allowed = worldDB.GetWorldAllowExternalRead(worldOverride); if (!allowed) { return ConstructSuccessResponse(null, connectionKey); } var otherEntries = worldDB.GetAllDataEntries(worldOverride); var otherData = new Dictionary(); foreach (var entry in otherEntries) { otherData.Add(entry.Key, entry.Value); } logger.Debug("Serving a request for all data ({0} entries) for world ID '{1}' requested by {2} with connection key {3}.", otherData.Count, worldOverride, worldId, connectionKey); return ConstructSuccessResponse(JsonConvert.SerializeObject(otherData), connectionKey); } var entries = worldDB.GetAllDataEntries(worldId); var data = new Dictionary(); foreach (var entry in entries) { data.Add(entry.Key, entry.Value); } logger.Debug("Serving a request for all data ({0} entries) for world ID '{1}' with connection key {2}.", data.Count, worldId, connectionKey); return ConstructSuccessResponse(JsonConvert.SerializeObject(data), connectionKey); } /// /// Handles an HTTP listener request for bulk data from the world database. /// /// The HTTP listener context object. /// A object containing the response data. private async Task HandleBulkDataRequest(HttpListenerContext context) { var request = context.Request; var keys = request.QueryString["keys"]; if (keys == null) { return ConstructErrorResponse(400, "Missing/invalid keys parameter."); } var keyArray = keys.Split(','); var worldId = await GetCurrentWorldID(); if (!TryInitializeWorld(worldId, out string connectionKey)) { return ConstructErrorResponse(500, "Failed to get/verify current world ID."); } var worldOverride = request.QueryString["world"]; if (worldOverride != null && worldId != worldOverride) { var allowed = worldDB.GetWorldAllowExternalRead(worldOverride); if (!allowed) { return ConstructSuccessResponse(null, connectionKey); } var otherEntries = worldDB.GetAllDataEntries(worldOverride); var otherData = new Dictionary(); foreach (var entry in otherEntries) { otherData.Add(entry.Key, entry.Value); } logger.Debug("Serving a request for all data ({0} entries) for world ID '{1}' requested by {2} with connection key {3}.", otherData.Count, worldOverride, worldId, connectionKey); return ConstructSuccessResponse(JsonConvert.SerializeObject(otherData), connectionKey); } var values = worldDB.GetDataEntries(worldId, keyArray).ToList(); // Build a dictionary of key/value pairs to send back. If a key doesn't exist in the database, the key will be included in the response as requested but with a null value. var data = new Dictionary(); for (int i = 0; i < keyArray.Length; i++) { string dataKey = keyArray[i]; string dataValue = values?.Where(x => x.Key == dataKey).FirstOrDefault()?.Value; // Get the value from the list of data entries, if it exists, otherwise null data.Add(dataKey, dataValue); } logger.Debug("Serving a request for bulk data with keys '{0}' from world ID '{1}' with connection key {2}.", keys, worldId, connectionKey); return ConstructSuccessResponse(JsonConvert.SerializeObject(data), connectionKey); } /// /// Attempts to initialize a world with the given ID by generating a connection key and adding it to the world database if it does not already exist. /// /// The ID of the world to initialize. /// The connection key generated for the world. /// True if the world was successfully initialized, false otherwise. private bool TryInitializeWorld(string worldId, out string connectionKey) { if (string.IsNullOrEmpty(worldId)) { connectionKey = null; return false; } var existsInDB = worldDB.DoesWorldExist(worldId); if (!existsInDB) { connectionKey = GenerateWorldConnectionKey(); worldDB.AddWorld(worldId, connectionKey); logger.Info("Added new world ID '{0}' with connection key '{1}' to the database.", worldId, connectionKey); } else { connectionKey = worldDB.GetWorldConnectionKey(worldId); } return true; } /// /// Generates a unique identifier for a world connection request. /// /// A string representation of a GUID that can be used to identify the world on requests. private string GenerateWorldConnectionKey() { if (debugWorld) return "12345"; // Ditched the old method of generating a short key, since we're just going with json anyway who cares about a longer identifier // Since we can rely on this GUID being unique, we can use it to identify the world on requests instead of trying to keep track of the user's current world. // I uhh, should probably make sure this is actually unique though. Just in case. I'll do that later. return Guid.NewGuid().ToString(); } /// /// Gets the ID of the current world by evaluating a JavaScript function in the main browser instance. /// /// The ID of the current world as a string, or null if it could not be retrieved. private async Task GetCurrentWorldID() { if (debugWorld) return "wrld_12345"; JavascriptResponse funcResult; try { funcResult = await MainForm.Instance.Browser.EvaluateScriptAsync("$app.API.actuallyGetCurrentLocation();", TimeSpan.FromSeconds(5)); } catch (Exception ex) { logger.Error(ex, "Failed to evaluate actuallyGetCurrentLocation JS function to get current world ID."); return null; } string worldId = funcResult?.Result?.ToString(); if (string.IsNullOrEmpty(worldId)) { // implement // wait what was i going to do here again // seriously i forgot, hope it wasn't important logger.Warn("actuallyGetCurrentLocation returned null or empty."); return null; } return worldId; } private WorldDataRequestResponse ConstructSuccessResponse(string data = null, string connectionKey = null) { var responseData = new WorldDataRequestResponse(true, null, null); responseData.StatusCode = 200; responseData.Error = null; responseData.OK = true; responseData.Data = data; responseData.ConnectionKey = connectionKey; return responseData; } private WorldDataRequestResponse ConstructErrorResponse(int statusCode, string error) { var responseData = new WorldDataRequestResponse(true, null, null); responseData.StatusCode = statusCode; responseData.Error = error; responseData.OK = false; responseData.Data = null; responseData.ConnectionKey = null; return responseData; } /// /// Sends a JSON response to an HTTP listener request with the specified response data and status code. /// /// The HTTP listener response object. /// The response data to be serialized to JSON. /// The HTTP status code to be returned. /// The HTTP listener response object. private HttpListenerResponse SendJsonResponse(HttpListenerResponse response, WorldDataRequestResponse responseData) { response.ContentType = "application/json"; response.StatusCode = responseData.StatusCode; response.AddHeader("Cache-Control", "no-cache"); // Use newtonsoft.json to serialize WorldDataRequestResponse to json var json = JsonConvert.SerializeObject(responseData); var buffer = System.Text.Encoding.UTF8.GetBytes(json); response.ContentLength64 = buffer.Length; response.OutputStream.Write(buffer, 0, buffer.Length); response.Close(); return response; } /// /// Processes a JSON request containing world data and logs it to the world database. /// /// The JSON request containing the world data. public void ProcessLogWorldDataRequest(string json) { // Current format: // { // "requestType": "store", // "connectionKey": "abc123", // "key": "example_key", // "value": "example_value" // } // * I could rate limit the processing of this, but I don't think it's necessary. // * At the amount of data you'd need to be spitting out to lag vrcx, you'd fill up the log file and lag out VRChat far before VRCX would have any issues; at least in my testing. // As long as malicious worlds can't permanently *store* stupid amounts of unculled data, this is pretty safe with the 10MB cap. If a world wants to just fill up a users HDD with logs, they can do that already anyway. WorldDataRequest request; try // try to deserialize the json into a WorldDataRequest object { request = JsonConvert.DeserializeObject(json); } catch (JsonReaderException ex) { logger.Error(ex, json.ToString()); this.lastError = ex.Message; // invalid json return; } catch (Exception ex) { logger.Error(ex, json.ToString()); this.lastError = ex.Message; // something else happened lol return; } if (string.IsNullOrEmpty(request.RequestType)) { logger.Warn("World tried to store data with no request type provided. Request: ", json); this.lastError = "`requestType` is missing or null"; return; } // Make sure the connection key is a valid GUID. No point in doing anything else if it's not. if (!debugWorld && !Guid.TryParse(request.ConnectionKey, out Guid _)) { logger.Warn("World tried to store data with an invalid GUID as a connection key '{0}'", request.ConnectionKey); this.lastError = "Invalid GUID provided as connection key"; // invalid guid return; } // Get the world ID from the connection key string worldId = worldDB.GetWorldByConnectionKey(request.ConnectionKey); // World ID is null, which means the connection key is invalid (or someone just deleted a world from the DB while VRCX was running lol). if (worldId == null) { logger.Warn("World tried to store data under {0} with an invalid connection key {1}", request.Key, request.ConnectionKey); this.lastError = "Invalid connection key"; // invalid connection key return; } try { string requestType = request.RequestType.ToLower(); switch (requestType) { case "store": if (String.IsNullOrEmpty(request.Key)) { logger.Warn("World {0} tried to store data with no key provided", worldId); this.lastError = "`key` is missing or null"; return; } if (request.Key.Length > 255) { logger.Warn("World {0} tried to store data with a key that was too long ({1}/256 characters)", worldId, request.Key.Length); this.lastError = "`key` is too long. Keep it below <256 characters."; return; } if (String.IsNullOrEmpty(request.Value)) { logger.Warn("World {0} tried to store data under key {1} with no value provided", worldId, request.Key); this.lastError = "`value` is missing or null"; return; } StoreWorldData(worldId, request.Key, request.Value); break; case "delete": if (String.IsNullOrEmpty(request.Key)) { logger.Warn("World {0} tried to delete data with no key provided", worldId); this.lastError = "`key` is missing or null"; return; } DeleteWorldData(worldId, request.Key); break; case "delete-all": logger.Info("World {0} requested to delete all data.", worldId); worldDB.DeleteAllDataEntriesForWorld(worldId); worldDB.UpdateWorldDataSize(worldId, 0); break; case "set-setting": if (String.IsNullOrEmpty(request.Key)) { logger.Warn("World {0} tried to delete data with no key provided", worldId); this.lastError = "`key` is missing or null"; return; } if (String.IsNullOrEmpty(request.Value)) { logger.Warn("World {0} tried to set settings with no value provided", worldId); this.lastError = "`value` is missing or null"; return; } SetWorldProperty(worldId, request.Key, request.Value); break; default: logger.Warn("World {0} sent an invalid request type '{0}'", worldId, request.RequestType); this.lastError = "Invalid request type"; // invalid request type return; } } catch (Exception ex) { logger.Error(ex, "Failed to process world data request for world {0}", worldId); logger.Error("Failed Request: {0}", json); this.lastError = ex.Message; return; } } /// /// Sets a property for a given world in the world database. /// /// The ID of the world to set the property for. /// The key of the property to set. /// The value to set the property to. public void SetWorldProperty(string worldId, string key, string value) { switch (key) { case "externalReads": if (bool.TryParse(value, out bool result)) { worldDB.SetWorldAllowExternalRead(worldId, result); } else { logger.Warn("World {0} tried to set externalReads to an invalid value '{1}'", worldId, value); this.lastError = "Invalid value for externalReads"; return; } break; } } /// /// Stores a data entry for a given world in the world database. /// /// The ID of the world to store the data entry for. /// The key of the data entry to store. /// The value of the data entry to store. public void StoreWorldData(string worldId, string key, string value) { // Get/calculate the old and new data sizes for this key/the world int oldTotalDataSize = worldDB.GetWorldDataSize(worldId); int oldDataSize = worldDB.GetDataEntrySize(worldId, key); int newDataSize = Encoding.UTF8.GetByteCount(value); int newTotalDataSize = oldTotalDataSize + newDataSize - oldDataSize; // Make sure we don't exceed 10MB total size for a world // This works, I tested it. Hopefully this prevents/limits any possible abuse. if (newTotalDataSize > 1024 * 1024 * 10) { logger.Warn("World {0} exceeded 10MB total data size trying to store key {0}. {1}:{2} + {3} = {4}", key, worldId, oldTotalDataSize - oldDataSize, newDataSize, newTotalDataSize); this.lastError = $"You have hit the 10MB total data cap. The previous data entry was *not* stored. Your request was {newDataSize} bytes, your current shared byte total is {oldTotalDataSize} and you went over the table limit by {newTotalDataSize - (1024 * 1024 * 10)} bytes."; return; } worldDB.AddDataEntry(worldId, key, value, newDataSize); worldDB.UpdateWorldDataSize(worldId, newTotalDataSize); logger.Info("World {0} stored data entry {1} with size {2} bytes", worldId, key, newDataSize); logger.Debug("{0} : {1}", key, value); } /// /// Deletes a data entry for a given world from the world database. /// /// The ID of the world to delete the data entry from. /// The key of the data entry to delete. public void DeleteWorldData(string worldId, string key) { int oldTotalDataSize = worldDB.GetWorldDataSize(worldId); int oldDataSize = worldDB.GetDataEntrySize(worldId, key); int newTotalDataSize = oldTotalDataSize - oldDataSize; worldDB.DeleteDataEntry(worldId, key); worldDB.UpdateWorldDataSize(worldId, newTotalDataSize); logger.Info("World {0} deleted data entry {1} with size {2} bytes.", worldId, key, oldDataSize); } public void Stop() { listener.Stop(); listener.Close(); worldDB.Close(); } } }