From 41d9532f1eb45d8974ec39de2f7d3e9b4145e176 Mon Sep 17 00:00:00 2001 From: Jason-Morcos <10710367+Jason-Morcos@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:43:25 -0700 Subject: [PATCH] Support separate OpenSpeedTest result URLs --- docs/MACOS-INSTALLATION.md | 2 + .../SpeedTest/config.js.template | 10 ++-- .../Services/NginxHostedService.cs | 49 +++++++++++++++-- .../NginxHostedServiceTests.cs | 53 +++++++++++++++++++ 4 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 tests/NetworkOptimizer.Web.Tests/NginxHostedServiceTests.cs diff --git a/docs/MACOS-INSTALLATION.md b/docs/MACOS-INSTALLATION.md index 6471fe578a..deb1adfa53 100644 --- a/docs/MACOS-INSTALLATION.md +++ b/docs/MACOS-INSTALLATION.md @@ -32,6 +32,8 @@ export TZ="America/Chicago" Additional environment variables can be added to `start.sh` - see [docker/.env.example](../docker/.env.example) for all available options including: - `HOST_NAME` - Hostname for canonical URL enforcement - `REVERSE_PROXIED_HOST_NAME` - Hostname when behind a reverse proxy (enables HTTPS) +- `OPENSPEEDTEST_SAVE_DATA_URL` - Optional absolute HTTP(S) URL or root-relative path for submitting browser speed-test results +- `OPENSPEEDTEST_CLIENT_RESULTS_URL` - Optional absolute HTTP(S) URL or root-relative path for viewing client results - `OPENSPEEDTEST_HTTPS` - Enable HTTPS for speed tests (required for geolocation) - `Logging__LogLevel__NetworkOptimizer` / `Logging__LogLevel__Default` - Logging verbosity (see [Enable Debug Logging](#enable-debug-logging)) diff --git a/src/NetworkOptimizer.Installer/SpeedTest/config.js.template b/src/NetworkOptimizer.Installer/SpeedTest/config.js.template index 1bd69d3e43..b48fe54475 100644 --- a/src/NetworkOptimizer.Installer/SpeedTest/config.js.template +++ b/src/NetworkOptimizer.Installer/SpeedTest/config.js.template @@ -5,17 +5,19 @@ // These will be replaced by the service at startup var saveData = {{SAVE_DATA}}; -var saveDataURL = "{{SAVE_DATA_URL}}"; +var saveDataURL = {{SAVE_DATA_URL}}; var apiPath = "{{API_PATH}}"; +var clientResultsUrl = {{CLIENT_RESULTS_URL}}; // If __DYNAMIC__, construct URL from browser location (same host, port 8042) if (saveDataURL === "__DYNAMIC__") { saveDataURL = window.location.protocol + "//" + window.location.hostname + ":8042" + apiPath; } -// URL for viewing client speed test results (derived from saveDataURL) -// Extract base URL by splitting on /api -var clientResultsUrl = saveDataURL.split("/api")[0] + "/client-speedtest"; +// By default, view results on the same Network Optimizer origin that receives them. +if (clientResultsUrl === "__FROM_SAVE_DATA_URL__") { + clientResultsUrl = saveDataURL.split("/api")[0] + "/client-speedtest"; +} // Fix for missing variable bug in OpenSpeedTest var OpenSpeedTestdb = ""; diff --git a/src/NetworkOptimizer.Web/Services/NginxHostedService.cs b/src/NetworkOptimizer.Web/Services/NginxHostedService.cs index fac0f8cdce..7c9ae7156a 100644 --- a/src/NetworkOptimizer.Web/Services/NginxHostedService.cs +++ b/src/NetworkOptimizer.Web/Services/NginxHostedService.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text.Json; using NetworkOptimizer.Core.Helpers; namespace NetworkOptimizer.Web.Services; @@ -89,13 +90,15 @@ private async Task GenerateConfigJsAsync(string speedTestFolder) // Construct the save URL based on configuration const string apiPath = "/api/public/speedtest/results"; var saveDataUrl = ConstructSaveDataUrl(config, apiPath); + var clientResultsUrl = ConstructClientResultsUrl(config); // Read template and replace placeholders (matches OpenSpeedTest format) var template = await File.ReadAllTextAsync(templatePath); var configJs = template .Replace("{{SAVE_DATA}}", "true") - .Replace("{{SAVE_DATA_URL}}", saveDataUrl) + .Replace("{{SAVE_DATA_URL}}", JsonSerializer.Serialize(saveDataUrl)) + .Replace("{{CLIENT_RESULTS_URL}}", JsonSerializer.Serialize(clientResultsUrl)) .Replace("{{API_PATH}}", apiPath); // Ensure output directory exists @@ -106,7 +109,10 @@ private async Task GenerateConfigJsAsync(string speedTestFolder) } await File.WriteAllTextAsync(outputPath, configJs); - _logger.LogInformation("Generated config.js with save URL: {SaveUrl}", saveDataUrl); + _logger.LogInformation( + "Generated config.js with save URL {SaveUrl} and client results URL {ClientResultsUrl}", + saveDataUrl, + clientResultsUrl); } private Task> LoadConfigurationAsync() @@ -126,6 +132,8 @@ private Task> LoadConfigurationAsync() LoadRegistryValue(config, key, "REVERSE_PROXIED_HOST_NAME"); LoadRegistryValue(config, key, "REVERSE_PROXIED_PORT"); LoadRegistryValue(config, key, "OPENSPEEDTEST_PORT"); + LoadRegistryValue(config, key, "OPENSPEEDTEST_SAVE_DATA_URL"); + LoadRegistryValue(config, key, "OPENSPEEDTEST_CLIENT_RESULTS_URL"); } } catch (Exception ex) @@ -140,6 +148,8 @@ private Task> LoadConfigurationAsync() OverrideFromConfiguration(config, "REVERSE_PROXIED_HOST_NAME"); OverrideFromConfiguration(config, "REVERSE_PROXIED_PORT"); OverrideFromConfiguration(config, "OPENSPEEDTEST_PORT"); + OverrideFromConfiguration(config, "OPENSPEEDTEST_SAVE_DATA_URL"); + OverrideFromConfiguration(config, "OPENSPEEDTEST_CLIENT_RESULTS_URL"); return Task.FromResult(config); } @@ -165,8 +175,14 @@ private void OverrideFromConfiguration(Dictionary config, string } } - private string ConstructSaveDataUrl(Dictionary config, string apiPath) + internal static string ConstructSaveDataUrl(IReadOnlyDictionary config, string apiPath) { + if (config.TryGetValue("OPENSPEEDTEST_SAVE_DATA_URL", out var configuredUrl) + && !string.IsNullOrWhiteSpace(configuredUrl)) + { + return ValidateConfiguredUrl(configuredUrl, "OPENSPEEDTEST_SAVE_DATA_URL"); + } + // Priority: REVERSE_PROXIED_HOST_NAME (https) > HOST_NAME (http) > HOST_IP (http) > __DYNAMIC__ // IMPORTANT: Keep this logic in sync with docker/openspeedtest/entrypoint.sh (Docker deployment) config.TryGetValue("REVERSE_PROXIED_HOST_NAME", out var reverseProxy); @@ -196,6 +212,33 @@ private string ConstructSaveDataUrl(Dictionary config, string ap } } + internal static string ConstructClientResultsUrl(IReadOnlyDictionary config) + { + if (config.TryGetValue("OPENSPEEDTEST_CLIENT_RESULTS_URL", out var configuredUrl) + && !string.IsNullOrWhiteSpace(configuredUrl)) + { + return ValidateConfiguredUrl(configuredUrl, "OPENSPEEDTEST_CLIENT_RESULTS_URL"); + } + + return "__FROM_SAVE_DATA_URL__"; + } + + private static string ValidateConfiguredUrl(string value, string key) + { + var url = value.Trim(); + if (url.StartsWith('/') && !url.StartsWith("//", StringComparison.Ordinal)) + return url; + + if (Uri.TryCreate(url, UriKind.Absolute, out var parsed) + && (parsed.Scheme == Uri.UriSchemeHttp || parsed.Scheme == Uri.UriSchemeHttps)) + { + return url; + } + + throw new InvalidOperationException( + $"{key} must be an absolute HTTP(S) URL or a root-relative path"); + } + /// /// Gets the path to the nginx executable. /// On Windows, looks for bundled nginx.exe in SpeedTest folder. diff --git a/tests/NetworkOptimizer.Web.Tests/NginxHostedServiceTests.cs b/tests/NetworkOptimizer.Web.Tests/NginxHostedServiceTests.cs new file mode 100644 index 0000000000..0112208061 --- /dev/null +++ b/tests/NetworkOptimizer.Web.Tests/NginxHostedServiceTests.cs @@ -0,0 +1,53 @@ +using FluentAssertions; +using NetworkOptimizer.Web.Services; +using Xunit; + +namespace NetworkOptimizer.Web.Tests; + +public class NginxHostedServiceTests +{ + [Fact] + public void ConstructUrls_UsesIndependentConfiguredRoutes() + { + var config = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["OPENSPEEDTEST_SAVE_DATA_URL"] = "/api/public/speedtest/results", + ["OPENSPEEDTEST_CLIENT_RESULTS_URL"] = "https://optimizer.example/client-speedtest" + }; + + NginxHostedService.ConstructSaveDataUrl(config, "/ignored") + .Should().Be("/api/public/speedtest/results"); + NginxHostedService.ConstructClientResultsUrl(config) + .Should().Be("https://optimizer.example/client-speedtest"); + } + + [Fact] + public void ConstructUrls_PreservesExistingDefaults() + { + var config = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["REVERSE_PROXIED_HOST_NAME"] = "optimizer.example" + }; + + NginxHostedService.ConstructSaveDataUrl(config, "/api/public/speedtest/results") + .Should().Be("https://optimizer.example/api/public/speedtest/results"); + NginxHostedService.ConstructClientResultsUrl(config) + .Should().Be("__FROM_SAVE_DATA_URL__"); + } + + [Theory] + [InlineData("javascript:alert(1)")] + [InlineData("//unexpected.example/results")] + [InlineData("relative/results")] + public void ConstructSaveDataUrl_RejectsUnsafeConfiguredValues(string value) + { + var config = new Dictionary + { + ["OPENSPEEDTEST_SAVE_DATA_URL"] = value + }; + + var act = () => NginxHostedService.ConstructSaveDataUrl(config, "/api/results"); + + act.Should().Throw(); + } +}