ソースを参照

Implement the new design from #649 (#694)

* Implement fromScratch design

* Add some documentation

* enable new design in docker (no alpine yet)

* merge fixed docker images

* alpine docker added new design

* fix #685

* Implement fromScratch design

* Add some documentation

* enable new design in docker (no alpine yet)

* alpine docker added new design

* fix #685

* fix database permissions alpine, remove baby

* hide serverselector on only one server

* Update frontend/styling/server-selector.css

Co-authored-by: qodo-merge-pro-for-open-source[bot] <189517486+qodo-merge-pro-for-open-source[bot]@users.noreply.github.com>

* fix alpine image again

* adjust settings.json in entrypoint

so env vars are honored

* Update frontend/javascript/index.js

Co-authored-by: qodo-merge-for-open-source[bot] <189517486+qodo-merge-for-open-source[bot]@users.noreply.github.com>

* Add feature switch for new design via config file, URL parameters, and Docker (#742)

* Initial plan

* Add feature switch for new design with config and URL parameter support

Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>

* Improve error handling and prevent infinite redirect loops

Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>

* Update Dockerfiles and entrypoint to support design feature switch

Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>

* Update design-switch.js

Co-authored-by: qodo-free-for-open-source-projects[bot] <189517486+qodo-free-for-open-source-projects[bot]@users.noreply.github.com>

* fix: copy actions in entrypoint

* Restructure design switch to place both designs at root level

Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>

* Flatten frontend assets in Docker to eliminate frontend directory

Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>

* fix: entrypoint settings & server-list

disable entrypoint bash debug

* add link to modern design

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>
Co-authored-by: sstidl <sstidl@users.noreply.github.com>
Co-authored-by: qodo-free-for-open-source-projects[bot] <189517486+qodo-free-for-open-source-projects[bot]@users.noreply.github.com>

* add armv7

* reformat

* Add GDPR_EMAIL environment variable for Docker deployments (#743)

* Initial plan

* Add GDPR_EMAIL environment variable for Docker deployments

Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>

* Improve GDPR_EMAIL handling with proper escaping and loop

Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>

* Refine GDPR_EMAIL processing - skip index.html and improve escaping

Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>

* Optimize GDPR_EMAIL sed commands and improve escaping

Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>

* Clarify sed escaping comment for GDPR_EMAIL

Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>

* cleanup old EMAIL ENV Var

* fix: line break in html prevented sed replacement

* version 6.0.0pre1

* test: add mssql docker compose tests

* Update Speedtest screen recording link in README

* Filter unreachable servers from selector (newdesign UI) (#769)

* Filter unreachable servers from selector (newdesign UI)

* Apply suggestions from code review

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* keep // servers in list

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix misleading comment

---------

Co-authored-by: Lumi <lumi@openclaw.local>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Stefan Stidl <sti-github@stidl.com>

* fix: server.json handling

* Use server-list.json in classic frontend by default

* Add configurable server list URLs to frontend and Docker

---------

Co-authored-by: Timendus <mail@timendus.com>
Co-authored-by: Stefan Stidl <stefan.stidl@ffg.at>
Co-authored-by: qodo-merge-pro-for-open-source[bot] <189517486+qodo-merge-pro-for-open-source[bot]@users.noreply.github.com>
Co-authored-by: qodo-merge-for-open-source[bot] <189517486+qodo-merge-for-open-source[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>
Co-authored-by: qodo-free-for-open-source-projects[bot] <189517486+qodo-free-for-open-source-projects[bot]@users.noreply.github.com>
Co-authored-by: Stefan Stidl <sti-github@stidl.com>
Co-authored-by: Lumi <lumi@openclaw.local>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
sstidl 3 ヶ月 前
コミット
3a7dc31ff2

+ 7 - 2
.eslintrc.json

@@ -27,10 +27,15 @@
     "addEventListener": "readonly"
   },
   "rules": {
-    "no-unused-vars": ["warn", { "args": "none" }],
+    "no-unused-vars": [
+      "warn",
+      {
+        "args": "none"
+      }
+    ],
     "no-console": "off",
     "no-empty": "warn",
     "no-undef": "warn",
     "no-const-assign": "error"
   }
-}
+}

+ 1 - 1
.prettierrc.json

@@ -9,4 +9,4 @@
   "arrowParens": "avoid",
   "endOfLine": "lf",
   "htmlWhitespaceSensitivity": "ignore"
-}
+}

+ 96 - 0
DESIGN_SWITCH.md

@@ -0,0 +1,96 @@
+# Design Feature Switch
+
+LibreSpeed now supports switching between the classic design and the new modern design.
+
+## Default Behavior
+
+By default, LibreSpeed uses the **classic design** (located in `index-classic.html`).
+
+## Architecture
+
+### File Structure (Non-Docker)
+- **`index.html`** - Entry point (lightweight switcher)
+- **`index-classic.html`** - Classic design at root
+- **`index-modern.html`** - Modern design at root (references assets in subdirectories)
+- **`frontend/`** - Directory containing modern design assets (CSS, JS, images, fonts) - kept for non-Docker deployments
+
+### File Structure (Docker)
+In Docker deployments, the frontend assets are flattened to root-level subdirectories:
+- **`index.html`** - Entry point (lightweight switcher)
+- **`index-classic.html`** - Classic design
+- **`index-modern.html`** - Modern design  
+- **`styling/`** - CSS files for modern design
+- **`javascript/`** - JS files for modern design
+- **`images/`** - Images for modern design
+- **`fonts/`** - Fonts for modern design
+- **No `frontend/` directory** - Assets are copied directly to root subdirectories
+
+### Benefits of Root-Level Design Files
+✅ Both designs at same level - no path confusion
+✅ `results/` accessible from both designs with same relative path
+✅ `backend/` accessible from both designs with same relative path  
+✅ No subdirectory nesting issues
+✅ Clean separation of concerns
+✅ Docker containers have no `frontend/` parent directory
+
+## Browser Compatibility
+
+The feature switch uses modern JavaScript features (URLSearchParams, fetch API). It is compatible with all modern browsers. The new design itself requires modern browser features and has no backwards compatibility with older browsers (see `frontend/README.md`).
+
+## Enabling the New Design
+
+There are two ways to enable the new design:
+
+### Method 1: Configuration File (Persistent)
+
+Edit the `config.json` file in the root directory and set `useNewDesign` to `true`:
+
+```json
+{
+  "useNewDesign": true
+}
+```
+
+This will make the new design the default for all users visiting your site.
+
+### Method 2: URL Parameter (Temporary Override)
+
+You can override the configuration by adding a URL parameter:
+
+- To use the new design: `http://yoursite.com/?design=new`
+- To use the old design: `http://yoursite.com/?design=old`
+
+URL parameters take precedence over the configuration file, making them useful for testing or allowing users to choose their preferred design.
+
+## Design Locations
+
+### Non-Docker Deployments
+- **Entry Point**: Root `index.html` file (lightweight redirect page)
+- **Old Design**: `index-classic.html` at root
+- **New Design**: `index-modern.html` at root (references assets in `frontend/` subdirectory)
+- **Assets**: Frontend assets (CSS, JS, images, fonts) in `frontend/` subdirectory
+
+### Docker Deployments
+- **Entry Point**: Root `index.html` file (lightweight redirect page)
+- **Old Design**: `index-classic.html` at root
+- **New Design**: `index-modern.html` at root (references assets in root subdirectories)
+- **Assets**: Frontend assets copied directly to root subdirectories (`styling/`, `javascript/`, `images/`, `fonts/`)
+- **No `frontend/` directory** - Assets are flattened to root level
+
+Both designs are at the same directory level, ensuring that relative paths to shared resources like `backend/` and `results/` work correctly for both.
+
+## Technical Details
+
+The feature switch is implemented in `design-switch.js`, which is loaded by the root `index.html`. It checks:
+
+1. First, URL parameters (`?design=new` or `?design=old`)
+2. Then, the `config.json` configuration file
+3. Redirects to either `index-classic.html` or `index-modern.html`
+
+Both design HTML files are at the root level, eliminating path issues.
+
+### Non-Docker
+The modern design references assets from the `frontend/` subdirectory (e.g., `frontend/styling/index.css`), while both designs can access shared resources like `backend/` and `results/` using the same relative paths.
+
+### Docker
+In Docker deployments, the `frontend/` directory is flattened during container startup. Assets are copied directly to root-level subdirectories (`styling/`, `javascript/`, `images/`, `fonts/`), and `index-modern.html` references these root-level paths. This eliminates the `frontend/` parent directory in the container.

+ 6 - 2
Dockerfile

@@ -15,15 +15,18 @@ RUN mkdir -p /speedtest/
 
 # Copy sources
 COPY backend/ /speedtest/backend
+COPY frontend/ /speedtest/frontend
 
 COPY results/*.php /speedtest/results/
 COPY results/*.ttf /speedtest/results/
 
 COPY *.js /speedtest/
+COPY index.html /speedtest/
+COPY index-classic.html /speedtest/
+COPY index-modern.html /speedtest/
+COPY config.json /speedtest/
 COPY favicon.ico /speedtest/
 
-COPY docker/servers.json /servers.json
-
 COPY docker/*.php /speedtest/
 COPY docker/entrypoint.sh /
 
@@ -35,6 +38,7 @@ ENV TELEMETRY=false
 ENV ENABLE_ID_OBFUSCATION=false
 ENV REDACT_IP_ADDRESSES=false
 ENV WEBPORT=8080
+ENV USE_NEW_DESIGN=false
 
 # https://httpd.apache.org/docs/2.4/stopping.html#gracefulstop
 STOPSIGNAL SIGWINCH

+ 6 - 2
Dockerfile.alpine

@@ -29,15 +29,18 @@ RUN mkdir -p /speedtest/
 
 # Copy sources
 COPY backend/ /speedtest/backend
+COPY frontend/ /speedtest/frontend
 
 COPY results/*.php /speedtest/results/
 COPY results/*.ttf /speedtest/results/
 
 COPY *.js /speedtest/
+COPY index.html /speedtest/
+COPY index-classic.html /speedtest/
+COPY index-modern.html /speedtest/
+COPY config.json /speedtest/
 COPY favicon.ico /speedtest/
 
-COPY docker/servers.json /servers.json
-
 COPY docker/*.php /speedtest/
 COPY docker/entrypoint.sh /
 
@@ -49,6 +52,7 @@ ENV TELEMETRY=false
 ENV ENABLE_ID_OBFUSCATION=false
 ENV REDACT_IP_ADDRESSES=false
 ENV WEBPORT=8080
+ENV USE_NEW_DESIGN=false
 
 # https://httpd.apache.org/docs/2.4/stopping.html#gracefulstop
 STOPSIGNAL SIGWINCH

+ 1 - 1
README.md

@@ -26,7 +26,7 @@ Works with mobile versions too.
 * Results sharing (optional)
 * Multiple Points of Test (optional)
 
-![Screenrecording of a running Speedtest](https://speedtest.fdossena.com/mpot_v6.gif)
+![Screenrecording of a running Speedtest](https://speedtest.fdossena.com/mpot_v7.gif)
 
 ## Server requirements
 

+ 3 - 0
config.json

@@ -0,0 +1,3 @@
+{
+  "useNewDesign": false
+}

+ 72 - 0
design-switch.js

@@ -0,0 +1,72 @@
+/**
+ * Feature switch for enabling the new LibreSpeed design
+ * 
+ * This script checks for:
+ * 1. URL parameter: ?design=new or ?design=old
+ * 2. Configuration file: config.json with useNewDesign flag
+ * 
+ * Default behavior: Shows the old design
+ * 
+ * Note: This script is only loaded on the root index.html
+ */
+(function () {
+    'use strict';
+
+    // Don't run this script if we're already on a specific design page
+    // This prevents infinite redirect loops
+    const currentPath = window.location.pathname;
+    if (currentPath.includes('index-classic.html') || currentPath.includes('index-modern.html')) {
+        return;
+    }
+
+    // Check URL parameters first (they override config)
+    const urlParams = new URLSearchParams(window.location.search);
+    const designParam = urlParams.get('design');
+
+    if (designParam === 'new') {
+        redirectToNewDesign();
+        return;
+    }
+
+    if (designParam === 'old' || designParam === 'classic') {
+        redirectToOldDesign();
+        return;
+    }
+
+    // Check config.json for design preference
+    try {
+        const xhr = new XMLHttpRequest();
+        // Use a synchronous request to prevent a flash of the old design before redirecting
+        xhr.open('GET', 'config.json', false);
+        xhr.send(null);
+
+        // Check for a successful response, but not 304 Not Modified, which can have an empty response body
+        if (xhr.status >= 200 && xhr.status < 300) {
+            const config = JSON.parse(xhr.responseText);
+            if (config.useNewDesign === true) {
+                redirectToNewDesign();
+            } else {
+                redirectToOldDesign();
+            }
+        } else {
+            // Config not found or error - default to old design
+            redirectToOldDesign();
+        }
+    } catch (error) {
+        // If there's any error (e.g., network, JSON parse), default to old design
+        console.log('Using default (old) design:', error.message || 'config error');
+        redirectToOldDesign();
+    }
+
+    function redirectToNewDesign() {
+        // Preserve any URL parameters when redirecting
+        const currentParams = window.location.search;
+        window.location.href = 'index-modern.html' + currentParams;
+    }
+
+    function redirectToOldDesign() {
+        // Preserve any URL parameters when redirecting
+        const currentParams = window.location.search;
+        window.location.href = 'index-classic.html' + currentParams;
+    }
+})();

+ 2 - 21
doc.md

@@ -1,7 +1,7 @@
 # LibreSpeed
 
 > by Federico Dossena
-> Version 5.5.1
+> Version 6.0.0pre1
 > [https://github.com/librespeed/speedtest/](https://github.com/librespeed/speedtest/)
 
 ## Introduction
@@ -83,23 +83,7 @@ Log into your database using phpMyAdmin or a similar software and create a new d
 
 Open `results/telemetry_settings.php` in a text editor. Set `$db_type` to either `mysql`,`postgresql`, `mssql` or `sqlite`.
 
-If you chose to use SQLite, the default configuration stores the database at `__DIR__ . '/../../speedtest_telemetry.db'`, which places it two directories up from the `results/` folder. This is designed to keep the database **outside your web-accessible directory** for security.
-
-**Critical Security Requirements**:
-1. **Web Server Configuration**: Configure your web server's document root to point to the application directory, NOT its parent. For example:
-   - Install application files to: `/var/www/speedtest/`
-   - Set Apache/nginx document root to: `/var/www/speedtest/` (NOT `/var/www/`)
-   - Database will be at: `/var/www/speedtest_telemetry.db` (outside document root, not web-accessible)
-
-2. **Alternative: Use Absolute Path**: For maximum security, especially if you cannot control the document root configuration, modify `$Sqlite_db_file` in `results/telemetry_settings.php` to use an absolute path completely outside your web directories:
-   ```php
-   $Sqlite_db_file = '/var/lib/speedtest/speedtest_telemetry.db';  // or /opt/speedtest_data/speedtest_telemetry.db
-   ```
-   Ensure the web server has write permissions to this directory.
-
-3. **Verification**: After running at least one speed test or accessing `sanitycheck.php` (which creates the database), try accessing `http://yourserver/speedtest_telemetry.db` in a browser - you should get a 404 error. If the file downloads, your configuration is insecure. Note: The database file won't exist until the first test is recorded, so you'll get a 404 initially even with correct configuration.
-
-SQLite doesn't require any additional configuration beyond setting a secure path and ensuring proper permissions.
+If you chose to use SQLite, you might want to change `$Sqlite_db_file` to another path where you want the database to be stored. Just make sure that the file cannot be downloaded by users. Sqlite doesn't require any additional configuration, you can skip the rest of this section.
 
 If you chose to use MySQL, you must set your database credentials:
 
@@ -872,9 +856,6 @@ s.setParameter("test_order","P_D_U");
 
 This will point to our static files and set the test to only do ping/jitter, download and upload tests.
 
-There is also an example to achieve this with nginx backend here:
-https://github.com/librespeed/speedtest/issues/375#issuecomment-3769211254
-
 ## Troubleshooting
 
 These are the most common issues reported by users, and how to fix them. If you still need help, contact me at [info@fdossena.com](mailto:info@fdossena.com).

+ 10 - 2
doc_docker.md

@@ -37,7 +37,7 @@ services:
       #ENABLE_ID_OBFUSCATION: "false"
       #REDACT_IP_ADDRESSES: "false"
       #PASSWORD:
-      #EMAIL:
+      #GDPR_EMAIL: "privacy@example.com"
       #DISABLE_IPINFO: "false"
       #IPINFO_APIKEY: "your api key"
       #DISTANCE: "km"
@@ -57,6 +57,8 @@ The test can be accessed on port 80.
 Here's a list of additional environment variables available in this mode:
 
 * __`TITLE`__: Title of your speed test. Default value: `LibreSpeed`
+* __`USE_NEW_DESIGN`__: When set to `true`, enables the new modern frontend design. When set to `false` (default), uses the classic design. The design can also be switched using URL parameters (`?design=new` or `?design=old`). Default value: `false`
+* __`SERVER_LIST_URL`__: When set, both frontend designs load their server list from this URL instead of the generated or mounted `server-list.json`. This is useful if you want the containerized frontend to consume a remote shared server list.
 * __`TELEMETRY`__: Whether to enable telemetry or not. If enabled, you maybe want your data to be persisted. See below. Default value: `false`
 * __`ENABLE_ID_OBFUSCATION`__: When set to true with telemetry enabled, test IDs are obfuscated, to avoid exposing the database internal sequential IDs. Default value: `false`
 * __`OBFUSCATION_SALT`__: The salt string that is used to obfuscate the test IDs. The format shoud be a 2 byte hex string (e.g. `0x1234abcd`). If not specified, a random one will be generated.
@@ -70,7 +72,7 @@ Here's a list of additional environment variables available in this mode:
     * DB_USERNAME, DB_PASSWORD - credentials of the user with read and update permissions to the db
   * mssql - not supported in docker image yet (feel free to open a PR with that, has to be done in `entrypoint.sh`)
 * __`PASSWORD`__: Password to access the stats page. If not set, stats page will not allow accesses.
-* __`EMAIL`__: Email address for GDPR requests. Must be specified when telemetry is enabled.
+* __`GDPR_EMAIL`__: Email address displayed in the privacy policy for data deletion requests. If not set, the default placeholder text will be shown. This should be set to comply with GDPR requirements when running in production. Must be specified when telemetry is enabled.
 * __`DISABLE_IPINFO`__: If set to `true`, ISP info and distance will not be fetched from either [ipinfo.io](https://ipinfo.io) or the offline database. Default: value: `false`
 * __`IPINFO_APIKEY`__: API key for [ipinfo.io](https://ipinfo.io). Optional, but required if you want to use the full [ipinfo.io](https://ipinfo.io) APIs (required for distance measurement)
 * __`DISTANCE`__: When `DISABLE_IPINFO` is set to false, this specifies how the distance from the server is measured. Can be either `km` for kilometers, `mi` for miles, or an empty string to disable distance measurement. Requires an [ipinfo.io](https://ipinfo.io) API key. Default value: `km`
@@ -150,6 +152,12 @@ The test can be accessed on port 80.
 
 The list of environment variables available in this mode is the same as [above in standalone mode](#standalone-mode).
 
+If you want the Docker frontend to load its server list from another URL instead of `/servers.json`, set `SERVER_LIST_URL`:
+
+```shell
+docker run -e MODE=frontend -e SERVER_LIST_URL="https://example.com/custom-server-list.json" -p 80:8080 -it ghcr.io/librespeed/speedtest
+```
+
 #### Example Frontend mode
 
 This command starts LibreSpeed in frontend mode, with a given `servers.json` file, and with telemetry, ID obfuscation, and a stats password and a persistant sqlite database for results:

+ 62 - 4
docker/entrypoint.sh

@@ -1,7 +1,17 @@
 #!/bin/bash
 
+echo "Setting up docker env..."
+echo "MODE: $MODE"
+echo "USE_NEW_DESIGN: $USE_NEW_DESIGN"
+echo "SERVER_LIST_URL: $SERVER_LIST_URL"
+echo "WEBPORT: $WEBPORT"
+echo "REDACT_IP_ADDRESSES: $REDACT_IP_ADDRESSES"
+echo "DB_TYPE: $DB_TYPE"
+echo "ENABLE_ID_OBFUSCATION: $ENABLE_ID_OBFUSCATION"
+echo "GDPR_EMAIL: $GDPR_EMAIL"
+
 set -e
-set -x
+#set -x
 
 is_alpine() {
   [ -f /etc/alpine-release ]
@@ -13,6 +23,10 @@ rm -rf /var/www/html/*
 # Copy frontend files
 cp /speedtest/*.js /var/www/html/
 
+# Copy design switch files
+cp /speedtest/config.json /var/www/html/
+cp /speedtest/design-switch.js /var/www/html/
+
 # Copy favicon
 cp /speedtest/favicon.ico /var/www/html/
 
@@ -40,14 +54,58 @@ if [ "$MODE" == "backend" ]; then
   fi
 fi
 
-# Set up unified index.php
-if [ "$MODE" != "backend" ]; then
-  cp /speedtest/ui.php /var/www/html/index.php
+# Set up index.php for frontend-only or standalone modes
+if [[ "$MODE" == "frontend" || "$MODE" == "dual" ||  "$MODE" == "standalone" ]]; then
+  # Copy design files (switcher + both designs)
+  cp /speedtest/index.html /var/www/html/
+  cp /speedtest/index-classic.html /var/www/html/
+  cp /speedtest/index-modern.html /var/www/html/
+  
+  # Copy frontend assets directly to root-level subdirectories (no frontend/ parent dir)
+  mkdir -p /var/www/html/styling /var/www/html/javascript /var/www/html/images /var/www/html/fonts
+  cp -a /speedtest/frontend/styling/* /var/www/html/styling/
+  cp -a /speedtest/frontend/javascript/* /var/www/html/javascript/
+  cp -a /speedtest/frontend/images/* /var/www/html/images/
+  cp -a /speedtest/frontend/fonts/* /var/www/html/fonts/ 2>/dev/null || true
+  
+  # Copy frontend config files
+  cp /speedtest/frontend/settings.json /var/www/html/settings.json 2>/dev/null || true
+  if [ -f /servers.json ]; then
+    echo "using mounted /servers.json for server-list.json"
+    cp /servers.json /var/www/html/server-list.json
+  else
+    echo "no /servers.json found, create one for local host"
+    # generate config for just the local server
+    echo '[{"name":"local","server":"/backend",  "dlURL": "garbage.php", "ulURL": "empty.php", "pingURL": "empty.php", "getIpURL": "getIP.php", "sponsorName": "", "sponsorURL": "", "id":1 }]' > /var/www/html/server-list.json
+  fi
+  if [ ! -z "$SERVER_LIST_URL" ]; then
+    echo "using SERVER_LIST_URL for frontend server list"
+    SERVER_LIST_URL_ESCAPED=$(printf '%s\n' "$SERVER_LIST_URL" | sed 's/[&/\\]/\\&/g; s/\$/\\$/g')
+    sed -i "s/var SPEEDTEST_SERVERS = \"server-list.json\";/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";/" /var/www/html/index-modern.html
+    sed -i "s/var SPEEDTEST_SERVERS = \\[/var SPEEDTEST_SERVERS = \"$SERVER_LIST_URL_ESCAPED\";\\n\\t\\t\\/\\*/" /var/www/html/index-classic.html
+  fi
+  
+  # Replace GDPR email placeholder if GDPR_EMAIL is set
+  if [ ! -z "$GDPR_EMAIL" ]; then
+    # Escape special sed characters: & (replacement), / (delimiter), \ (escape), $ (variable)
+    GDPR_EMAIL_ESCAPED=$(printf '%s\n' "$GDPR_EMAIL" | sed 's/[&/\\]/\\&/g; s/\$/\\$/g')
+    
+    for html_file in /var/www/html/index-modern.html /var/www/html/index-classic.html; do
+      if [ -f "$html_file" ]; then
+        sed -i "s/TO BE FILLED BY DEVELOPER/$GDPR_EMAIL_ESCAPED/g; s/PUT@YOUR_EMAIL.HERE/$GDPR_EMAIL_ESCAPED/g" "$html_file"
+      fi
+    done
+  fi
+fi
+# Configure design preference via config.json
+if [ "$USE_NEW_DESIGN" == "true" ]; then
+  sed -i 's/"useNewDesign": false/"useNewDesign": true/' /var/www/html/config.json
 fi
 
 # Apply Telemetry settings when running in standalone or frontend mode and telemetry is enabled
 if [[ "$TELEMETRY" == "true" && ("$MODE" == "frontend" || "$MODE" == "standalone" || "$MODE" == "dual") ]]; then
   cp -r /speedtest/results /var/www/html/results
+  sed -i 's/telemetry_level": ".*"/telemetry_level": "basic"/' /var/www/html/settings.json
 
   if [ "$MODE" == "frontend" ]; then
     mkdir /var/www/html/backend

+ 75 - 8
docker/test/docker-compose.yml

@@ -4,14 +4,14 @@ x-shared:
   speedtest-service: &speedtest-service
     environment:
       - PASSWORD=gimmeTheStats1337
-      - EMAIL=test@example.com
+      - GDPR_EMAIL=test@example.com
       - ENABLE_ID_OBFUSCATION=true
       - MODE=standalone
       - REDACT_IP_ADDRESSES=true
       - TELEMETRY=true
 
 services:
-###################### POSTGRESQL ################################
+  ###################### POSTGRESQL ################################
   pg:
     image: postgres:alpine
     environment:
@@ -32,7 +32,7 @@ services:
       - DB_PASSWORD=Not4SecureProduction
       - DB_TYPE=postgresql
       - DB_USERNAME=librespeed
-      - EMAIL=test@example.com
+      - GDPR_EMAIL=test@example.com
       - ENABLE_ID_OBFUSCATION=true
       - MODE=standalone
       - REDACT_IP_ADDRESSES=true
@@ -48,7 +48,7 @@ services:
     ports:
       - 9124:8080
 
-####################### MYSQL ##############################
+  ####################### MYSQL ##############################
 
   mysql:
     image: mysql:lts
@@ -73,7 +73,7 @@ services:
       - DB_PASSWORD=Not4SecureProduction
       - DB_TYPE=mysql
       - DB_USERNAME=librespeed
-      - EMAIL=test@example.com
+      - GDPR_EMAIL=test@example.com
       - ENABLE_ID_OBFUSCATION=true
       - MODE=standalone
       - REDACT_IP_ADDRESSES=true
@@ -88,7 +88,72 @@ services:
     ports:
       - 9126:8080
 
-################ SQLITE ####################################
+  ####################### MSSQL ##############################
+
+  mssql:
+    image: mcr.microsoft.com/mssql/server:2022-latest
+    environment:
+      - ACCEPT_EULA=Y
+      - SA_PASSWORD=Not4SecureProduction1337
+      - MSSQL_PID=Express
+    healthcheck:
+      test:
+        ["CMD-SHELL", "/opt/mssql-tools18/bin/sqlcmd -S localhost -U sa -P $$SA_PASSWORD -C -Q 'SELECT 1' || exit 1"]
+      interval: 10s
+      retries: 5
+      start_period: 30s
+
+  mssql-init:
+    image: mcr.microsoft.com/mssql/server:2022-latest
+    depends_on:
+      mssql:
+        condition: service_healthy
+    environment:
+      - SA_PASSWORD=Not4SecureProduction1337
+    volumes:
+      - ../../results/telemetry_mssql.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
+    command: >
+      bash -c '
+        echo "Waiting for MSSQL to be ready...";
+        until /opt/mssql-tools18/bin/sqlcmd -S mssql -U sa -P "$$SA_PASSWORD" -C -Q "SELECT 1"; do
+          sleep 1;
+        done;
+        echo "Running init scripts...";
+        for f in /docker-entrypoint-initdb.d/*.sql; do
+          echo "Executing $$f...";
+          /opt/mssql-tools18/bin/sqlcmd -S mssql -U sa -P "$$SA_PASSWORD" -C -i "$$f";
+        done;
+        echo "Initialization complete!";
+      '
+
+  speedtest-debian-mssql:
+    build:
+      context: ../..
+      dockerfile: Dockerfile
+    environment: &env_vars_mssql
+      - PASSWORD=gimmeTheStats1337
+      - DB_HOSTNAME=mssql
+      - DB_NAME=librespeed
+      - DB_PASSWORD=Not4SecureProduction1337
+      - DB_TYPE=mssql
+      - DB_USERNAME=sa
+      - GDPR_EMAIL=test@example.com
+      - ENABLE_ID_OBFUSCATION=true
+      - MODE=standalone
+      - REDACT_IP_ADDRESSES=true
+      - TELEMETRY=true
+    ports:
+      - 9130:8080
+
+  speedtest-alpine-mssql:
+    build:
+      context: ../..
+      dockerfile: Dockerfile.alpine
+    environment: *env_vars_mssql
+    ports:
+      - 9131:8080
+
+  ################ SQLITE ####################################
 
   speedtest-debian-sqlite:
     # check at http://localhost:9125/results/sanitycheck.php
@@ -97,7 +162,7 @@ services:
       dockerfile: Dockerfile
     environment: &env_vars_sqlite
       - PASSWORD=gimmeTheStats1337
-      - EMAIL=test@example.com
+      - GDPR_EMAIL=test@example.com
       - ENABLE_ID_OBFUSCATION=true
       - MODE=standalone
       - REDACT_IP_ADDRESSES=true
@@ -126,14 +191,16 @@ services:
     ports:
       - 9129:8080
 
-##############  TEST CONTAINER  ###############################################################
+  ##############  TEST CONTAINER  ###############################################################
   test-container:
     image: alpine
     depends_on:
       - pg
       - mysql
+      - mssql-init
       - speedtest-alpine-mysql
       - speedtest-alpine-pg
+      - speedtest-alpine-mssql
     volumes:
       - ./test-script.sh:/test-script.sh
     command:

+ 1 - 1
docker/test/test-script.sh

@@ -12,7 +12,7 @@ fi
 echo sleeping a little to get things settled...
 sleep 15
 
-for db in sqlite pg mysql; do
+for db in sqlite pg mysql mssql; do
   for distro in alpine debian; do
 	hostname=speedtest-$distro-$db
 	echo $hostname

+ 0 - 528
docker/ui.php

@@ -1,528 +0,0 @@
-<!DOCTYPE html>
-<html>
-<head>
-<link rel="shortcut icon" href="favicon.ico">
-<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no" />
-<meta charset="UTF-8" />
-<script type="text/javascript" src="speedtest.js"></script>
-<script type="text/javascript">
-function I(i){return document.getElementById(i);}
-
-//LIST OF TEST SERVERS. See documentation for details if needed
-<?php
-$mode=getenv("MODE");
-if($mode=="standalone" || $mode=="backend"){ ?>
-var SPEEDTEST_SERVERS=[];
-<?php } else { ?>
-var SPEEDTEST_SERVERS= <?= file_get_contents('/servers.json') ?: '[]' ?>;
-<?php } 
-if ($mode=="dual"){ ?>
-// add own server in dual mode
-SPEEDTEST_SERVERS.unshift({
-	"name":"This Server",
-	"server":document.location.href+"backend/",
-	"id":1,
-	"dlURL":"garbage.php",
-	"ulURL":"empty.php",
-	"pingURL":"empty.php",
-	"getIpURL":"getIP.php",
-	"pingT":-1
-})
-<?php } ?>
-//INITIALIZE SPEED TEST
-var s=new Speedtest(); //create speed test object
-<?php if(getenv("TELEMETRY")=="true"){ ?>
-s.setParameter("telemetry_level","basic");
-<?php } ?>
-<?php if(getenv("DISABLE_IPINFO")=="true"){ ?>
-s.setParameter("getIp_ispInfo",false);
-<?php } ?>
-<?php if(getenv("DISTANCE")){ ?>
-s.setParameter("getIp_ispInfo_distance","<?=getenv("DISTANCE") ?>");
-<?php } ?>
-
-//SERVER AUTO SELECTION
-function initServers(){
-	if(SPEEDTEST_SERVERS.length==0){ //standalone installation
-		//just make the UI visible
-		I("loading").className="hidden";
-		I("serverArea").style.display="none";
-		I("testWrapper").className="visible";
-		initUI();
-	}else{ //multiple servers
-		var noServersAvailable=function(){
-			I("message").innerHTML="No servers available";
-		}
-		var runServerSelect=function(){
-			s.selectServer(function(server){
-				if(server!=null){ //at least 1 server is available
-					I("loading").className="hidden"; //hide loading message
-					//populate server list for manual selection
-					for(var i=0;i<SPEEDTEST_SERVERS.length;i++){
-						if(SPEEDTEST_SERVERS[i].pingT==-1) continue;
-						var option=document.createElement("option");
-						option.value=i;
-						option.textContent=SPEEDTEST_SERVERS[i].name;
-						if(SPEEDTEST_SERVERS[i]===server) option.selected=true;
-						I("server").appendChild(option);
-					}
-					//show test UI
-					I("testWrapper").className="visible";
-					initUI();
-				}else{ //no servers are available, the test cannot proceed
-					noServersAvailable();
-				}
-			});
-		}
-		if(typeof SPEEDTEST_SERVERS === "string"){
-			//need to fetch list of servers from specified URL
-			s.loadServerList(SPEEDTEST_SERVERS,function(servers){
-				if(servers==null){ //failed to load server list
-					noServersAvailable();
-				}else{ //server list loaded
-					SPEEDTEST_SERVERS=servers;
-					runServerSelect();
-				}
-			});
-		}else{
-			//hardcoded server list
-			s.addTestPoints(SPEEDTEST_SERVERS);
-			runServerSelect();
-		}
-	}
-}
-
-var meterBk=/Trident.*rv:(\d+\.\d+)/i.test(navigator.userAgent)?"#EAEAEA":"#80808040";
-var dlColor="#6060AA",
-	ulColor="#616161";
-var progColor=meterBk;
-
-//CODE FOR GAUGES
-function drawMeter(c,amount,bk,fg,progress,prog){
-	var ctx=c.getContext("2d");
-	var dp=window.devicePixelRatio||1;
-	var cw=c.clientWidth*dp, ch=c.clientHeight*dp;
-	var sizScale=ch*0.0055;
-	if(c.width==cw&&c.height==ch){
-		ctx.clearRect(0,0,cw,ch);
-	}else{
-		c.width=cw;
-		c.height=ch;
-	}
-	ctx.beginPath();
-	ctx.strokeStyle=bk;
-	ctx.lineWidth=12*sizScale;
-	ctx.arc(c.width/2,c.height-58*sizScale,c.height/1.8-ctx.lineWidth,-Math.PI*1.1,Math.PI*0.1);
-	ctx.stroke();
-	ctx.beginPath();
-	ctx.strokeStyle=fg;
-	ctx.lineWidth=12*sizScale;
-	ctx.arc(c.width/2,c.height-58*sizScale,c.height/1.8-ctx.lineWidth,-Math.PI*1.1,amount*Math.PI*1.2-Math.PI*1.1);
-	ctx.stroke();
-	if(typeof progress !== "undefined"){
-		ctx.fillStyle=prog;
-		ctx.fillRect(c.width*0.3,c.height-16*sizScale,c.width*0.4*progress,4*sizScale);
-	}
-}
-function mbpsToAmount(s){
-	return 1-(1/(Math.pow(1.3,Math.sqrt(s))));
-}
-function format(d){
-    d=Number(d);
-    if(d<10) return d.toFixed(2);
-    if(d<100) return d.toFixed(1);
-    return d.toFixed(0);
-}
-
-//UI CODE
-var uiData=null;
-function startStop(){
-    if(s.getState()==3){
-		//speed test is running, abort
-		s.abort();
-		data=null;
-		I("startStopBtn").className="";
-		I("server").disabled=false;
-		initUI();
-	}else{
-		//test is not running, begin
-		I("startStopBtn").className="running";
-		I("shareArea").style.display="none";
-		I("server").disabled=true;
-		s.onupdate=function(data){
-            uiData=data;
-		};
-		s.onend=function(aborted){
-            I("startStopBtn").className="";
-            I("server").disabled=false;
-            updateUI(true);
-            if(!aborted){
-                //if testId is present, show sharing panel, otherwise do nothing
-                try{
-                    var testId=uiData.testId;
-                    if(testId!=null){
-                        var shareURL=window.location.href.substring(0,window.location.href.lastIndexOf("/"))+"/results/?id="+testId;
-                        I("resultsImg").src=shareURL;
-                        I("resultsImg").alt="Download: "+uiData.dlStatus+" Mbps, Upload: "+uiData.ulStatus+" Mbps, Ping: "+uiData.pingStatus+" ms, Jitter: "+uiData.jitterStatus+" ms";
-                        I("resultsURL").value=shareURL;
-                        I("testId").innerHTML=testId;
-                        I("shareArea").style.display="";
-                    }
-                }catch(e){}
-            }
-		};
-		s.start();
-	}
-}
-//this function reads the data sent back by the test and updates the UI
-function updateUI(forced){
-	if(!forced&&s.getState()!=3) return;
-	if(uiData==null) return;
-	var status=uiData.testState;
-	I("ip").textContent=uiData.clientIp;
-	I("dlText").textContent=(status==1&&uiData.dlStatus==0)?"...":format(uiData.dlStatus);
-	drawMeter(I("dlMeter"),mbpsToAmount(Number(uiData.dlStatus*(status==1?oscillate():1))),meterBk,dlColor,Number(uiData.dlProgress),progColor);
-	I("ulText").textContent=(status==3&&uiData.ulStatus==0)?"...":format(uiData.ulStatus);
-	drawMeter(I("ulMeter"),mbpsToAmount(Number(uiData.ulStatus*(status==3?oscillate():1))),meterBk,ulColor,Number(uiData.ulProgress),progColor);
-	I("pingText").textContent=format(uiData.pingStatus);
-	I("jitText").textContent=format(uiData.jitterStatus);
-}
-function oscillate(){
-	return 1+0.02*Math.sin(Date.now()/100);
-}
-//update the UI every frame
-window.requestAnimationFrame=window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.msRequestAnimationFrame||(function(callback,element){setTimeout(callback,1000/60);});
-function frame(){
-	requestAnimationFrame(frame);
-	updateUI();
-}
-frame(); //start frame loop
-//function to (re)initialize UI
-function initUI(){
-	drawMeter(I("dlMeter"),0,meterBk,dlColor,0);
-	drawMeter(I("ulMeter"),0,meterBk,ulColor,0);
-	I("dlText").textContent="";
-	I("ulText").textContent="";
-	I("pingText").textContent="";
-	I("jitText").textContent="";
-	I("ip").textContent="";
-}
-</script>
-<style type="text/css">
-	html,body{
-		border:none; padding:0; margin:0;
-		background:#FFFFFF;
-		color:#202020;
-	}
-	body{
-		text-align:center;
-		font-family:"Roboto",sans-serif;
-	}
-	h1{
-		color:#404040;
-	}
-	#loading{
-		background-color:#FFFFFF;
-		color:#404040;
-		text-align:center;
-	}
-	span.loadCircle{
-		display:inline-block;
-		width:2em;
-		height:2em;
-		vertical-align:middle;
-		background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAP1BMVEUAAAB2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZyFzwnAAAAFHRSTlMAEvRFvX406baecwbf0casimhSHyiwmqgAAADpSURBVHja7dbJbQMxAENRahnN5lkc//5rDRAkDeRgHszXgACJoKiIiIiIiIiIiIiIiIiIiIj4HHspsrpAVhdVVguzrA4OWc10WcEqpwKbnBo0OU1Q5NSpsoJFTgOecrrdEag85DRgktNqfoEdTjnd7hrEHMEJvmRUYJbTYk5Agy6nau6Abp5Cm7mDBtRdPi9gyKdU7w4p1fsLvyqs8hl4z9/w3n/Hmr9WoQ65lAU4d7lMYOz//QboRR5jBZibLMZdAR6O/Vfa1PlxNr3XdS3HzK/HVPRu/KnLs8iAOh993VpRRERERMT/fAN60wwWaVyWwAAAAABJRU5ErkJggg==');
-		background-size:2em 2em;
-		margin-right:0.5em;
-		animation: spin 0.6s linear infinite;
-	}
-	@keyframes spin{
-		0%{transform:rotate(0deg);}
-		100%{transform:rotate(359deg);}
-	}
-	#startStopBtn{
-		display:inline-block;
-		margin:0 auto;
-		color:#6060AA;
-		background-color:rgba(0,0,0,0);
-		border:0.15em solid #6060FF;
-		border-radius:0.3em;
-		transition:all 0.3s;
-		box-sizing:border-box;
-		width:8em; height:3em;
-		line-height:2.7em;
-		cursor:pointer;
-		box-shadow: 0 0 0 rgba(0,0,0,0.1), inset 0 0 0 rgba(0,0,0,0.1);
-	}
-	#startStopBtn:hover{
-		box-shadow: 0 0 2em rgba(0,0,0,0.1), inset 0 0 1em rgba(0,0,0,0.1);
-	}
-	#startStopBtn.running{
-		background-color:#FF3030;
-		border-color:#FF6060;
-		color:#FFFFFF;
-	}
-	#startStopBtn:before{
-		content:"Start";
-	}
-	#startStopBtn.running:before{
-		content:"Abort";
-	}
-	#serverArea{
-		margin-top:1em;
-	}
-	#server{
-		font-size:1em;
-		padding:0.2em;
-	}
-	#test{
-		margin-top:2em;
-		margin-bottom:12em;
-	}
-	div.testArea{
-		display:inline-block;
-		width:16em;
-		height:12.5em;
-		position:relative;
-		box-sizing:border-box;
-	}
-	div.testArea2{
-		display:inline-block;
-		width:14em;
-		height:7em;
-		position:relative;
-		box-sizing:border-box;
-		text-align:center;
-	}
-	div.testArea div.testName{
-		position:absolute;
-		top:0.1em; left:0;
-		width:100%;
-		font-size:1.4em;
-		z-index:9;
-	}
-	div.testArea2 div.testName{
-        display:block;
-        text-align:center;
-        font-size:1.4em;
-	}
-	div.testArea div.meterText{
-		position:absolute;
-		bottom:1.55em; left:0;
-		width:100%;
-		font-size:2.5em;
-		z-index:9;
-	}
-	div.testArea2 div.meterText{
-        display:inline-block;
-        font-size:2.5em;
-	}
-	div.meterText:empty:before{
-		content:"0.00";
-	}
-	div.testArea div.unit{
-		position:absolute;
-		bottom:2em; left:0;
-		width:100%;
-		z-index:9;
-	}
-	div.testArea2 div.unit{
-		display:inline-block;
-	}
-	div.testArea canvas{
-		position:absolute;
-		top:0; left:0; width:100%; height:100%;
-		z-index:1;
-	}
-	div.testGroup{
-		display:block;
-        margin: 0 auto;
-	}
-	#shareArea{
-		width:95%;
-		max-width:40em;
-		margin:0 auto;
-		margin-top:2em;
-	}
-	#shareArea > *{
-		display:block;
-		width:100%;
-		height:auto;
-		margin: 0.25em 0;
-	}
-	#privacyPolicy{
-        position:fixed;
-        top:2em;
-        bottom:2em;
-        left:2em;
-        right:2em;
-        overflow-y:auto;
-        width:auto;
-        height:auto;
-        box-shadow:0 0 3em 1em #000000;
-        z-index:999999;
-        text-align:left;
-        background-color:#FFFFFF;
-        padding:1em;
-	}
-	a.privacy{
-        text-align:center;
-        font-size:0.8em;
-        color:#808080;
-        padding: 0 3em;
-	}
-    div.closePrivacyPolicy {
-        width: 100%;
-        text-align: center;
-    }
-    div.closePrivacyPolicy a.privacy {
-        padding: 1em 3em;
-    }
-	@media all and (max-width:40em){
-		body{
-			font-size:0.8em;
-		}
-	}
-	div.visible{
-		animation: fadeIn 0.4s;
-		display:block;
-	}
-	div.hidden{
-		animation: fadeOut 0.4s;
-		display:none;
-	}
-	@keyframes fadeIn{
-		0%{
-			opacity:0;
-		}
-		100%{
-			opacity:1;
-		}
-	}
-	@keyframes fadeOut{
-		0%{
-			display:block;
-			opacity:1;
-		}
-		100%{
-			display:block;
-			opacity:0;
-		}
-	}
-	@media all and (prefers-color-scheme: dark){
-		html,body,#loading{
-			background:#202020;
-			color:#F4F4F4;
-			color-scheme:dark;
-		}
-		h1{
-			color:#E0E0E0;
-		}
-		a{
-			color:#9090FF;
-		}
-		#privacyPolicy{
-			background:#000000;
-		}
-		#resultsImg{
-			filter: invert(1);
-		}
-	}
-</style>
-<title><?= getenv('TITLE') ?: 'LibreSpeed' ?></title>
-</head>
-<body onload="initServers()">
-<h1><?= getenv('TITLE') ?: 'LibreSpeed' ?></h1>
-<div id="loading" class="visible">
-	<p id="message"><span class="loadCircle"></span>Selecting a server...</p>
-</div>
-<div id="testWrapper" class="hidden">
-	<div id="startStopBtn" onclick="startStop()"></div><br/>
-	<?php if(getenv("TELEMETRY")=="true"){ ?>
-		<a class="privacy" href="#" onclick="I('privacyPolicy').style.display=''">Privacy</a>
-	<?php } ?>
-	<div id="serverArea">
-		Server: <select id="server" onchange="s.setSelectedServer(SPEEDTEST_SERVERS[this.value])"></select>
-	</div>
-	<div id="test">
-		<div class="testGroup">
-            <div class="testArea2">
-				<div class="testName">Ping</div>
-				<div id="pingText" class="meterText" style="color:#AA6060"></div>
-				<div class="unit">ms</div>
-			</div>
-			<div class="testArea2">
-				<div class="testName">Jitter</div>
-				<div id="jitText" class="meterText" style="color:#AA6060"></div>
-				<div class="unit">ms</div>
-			</div>
-		</div>
-		<div class="testGroup">
-			<div class="testArea">
-				<div class="testName">Download</div>
-				<canvas id="dlMeter" class="meter"></canvas>
-				<div id="dlText" class="meterText"></div>
-				<div class="unit">Mbit/s</div>
-			</div>
-			<div class="testArea">
-				<div class="testName">Upload</div>
-				<canvas id="ulMeter" class="meter"></canvas>
-				<div id="ulText" class="meterText"></div>
-				<div class="unit">Mbit/s</div>
-			</div>
-		</div>
-		<div id="ipArea">
-			<span id="ip"></span>
-		</div>
-		<div id="shareArea" style="display:none">
-			<h3>Share results</h3>
-			<p>Test ID: <span id="testId"></span></p>
-			<input type="text" value="" id="resultsURL" readonly="readonly" onclick="this.select();this.focus();this.select();document.execCommand('copy');alert('Link copied')"/>
-			<img src="" id="resultsImg" />
-		</div>
-	</div>
-	<a href="https://github.com/librespeed/speedtest">Source code</a>
-</div>
-<div id="privacyPolicy" style="display:none">
-    <h2>Privacy Policy</h2>
-    <p>This HTML5 speed test server is configured with telemetry enabled.</p>
-    <h4>What data we collect</h4>
-    <p>
-        At the end of the test, the following data is collected and stored:
-        <ul>
-            <li>Test ID</li>
-            <li>Time of testing</li>
-            <li>Test results (download and upload speed, ping and jitter)</li>
-            <li>IP address</li>
-            <li>ISP information</li>
-            <li>Approximate location (inferred from IP address, not GPS)</li>
-            <li>User agent and browser locale</li>
-            <li>Test log (contains no personal information)</li>
-        </ul>
-    </p>
-    <h4>How we use the data</h4>
-    <p>
-        Data collected through this service is used to:
-        <ul>
-            <li>Allow sharing of test results (sharable image for forums, etc.)</li>
-            <li>To improve the service offered to you (for instance, to detect problems on our side)</li>
-        </ul>
-        No personal information is disclosed to third parties.
-    </p>
-    <h4>Your consent</h4>
-    <p>
-        By starting the test, you consent to the terms of this privacy policy.
-    </p>
-    <h4>Data removal</h4>
-    <p>
-        If you want to have your information deleted, you need to provide either the ID of the test or your IP address. This is the only way to identify your data, without this information we won't be able to comply with your request.<br/><br/>
-        Contact this email address for all deletion requests: <a href="mailto:<?=getenv("EMAIL") ?>"><?=getenv("EMAIL") ?></a>.
-    </p>
-    <br/><br/>
-    <div class="closePrivacyPolicy">
-        <a class="privacy" href="#" onclick="I('privacyPolicy').style.display='none'">Close</a>
-    </div>
-    <br/>
-</div>
-</body>
-</html>

+ 90 - 0
frontend/README.md

@@ -0,0 +1,90 @@
+# LibreSpeed frontend
+
+This is a re-implementation of the LibreSpeed user interface based on [the
+design by fromScratch](https://github.com/librespeed/speedtest/issues/585). The
+code that's doing the actual speed test is still the same.
+
+### Desktop
+
+![Desktop screenshot](./screenshots/desktop.png)
+
+### Mobile
+
+![Mobile screenshot](./screenshots/mobile.png)
+
+## How to use
+
+Copy everything in this directory into the LibreSpeed root, next to
+`speedtest.js` and `speedtest_worker.js`, overwriting the `index.html` file that
+may already be there. That's it!
+
+## Configuration
+
+In the [`server-list.json`](./server-list.json) file you can provide a list of
+testing servers. If you only have a single testing server, just provide a list
+with one item in it, being your server. The frontend will then skip doing an
+automatic server selection and will not allow the user to change servers.
+
+If you want to load the server list from a different URL, change the
+`SPEEDTEST_SERVERS` variable in `index-modern.html` before
+`javascript/index.js` is loaded. For example:
+
+```html
+<script type="text/javascript">
+  var SPEEDTEST_SERVERS = "https://example.com/custom-server-list.json";
+</script>
+<script type="text/javascript" src="javascript/index.js"></script>
+```
+
+You can also provide a relative URL, for example
+`var SPEEDTEST_SERVERS = "/speedtest/servers.json";`.
+
+For more advanced applications, you can override any of the settings that are
+defined in `speedtest_worker.js` using the file
+[`settings.json`](./settings.json). See
+[`speedtest_worker.js`](../speedtest_worker.js) for documentation on the
+different settings (scroll down a bit to where you find the definition of the
+`settings` object).
+
+## Features
+
+- Shows upload and download speed and progress
+- Shows ping and jitter
+- Shows your IP address and internet service provider if the testing server
+  supports it
+- Can handle a single testing server or a list of servers
+- Can save telemetry and share results if the hosting server supports it (set
+  `telemetry_level` in [`settings.json`](./settings.json))
+- Does not require any build steps; implementation is pure JS & CSS
+- Has zero dependencies
+
+## Limitations
+
+- This frontend relies heavily on modern browser features. It should work very
+  well in all modern ("evergreen") browsers, but has **no** backwards compatibility
+  with older browsers.
+
+## Credits
+
+**Design** by fromScratch Studio - 2022, 2023
+([www.fromscratch.io](https://www.fromscratch.io))
+
+> "During Hacktoberfest 2022 & 2023, fromScratch Studio took on the request for
+> LibreSpeed redesign and UI improvements. We ran 2 design sprints one on '22 and
+> one on '23, and produced high-fidelity screens for LibreSpeed redesign.
+> Furthermore, this year, we produced high-fidelity screens for mobile-view as
+> well."
+
+_-- Chris-ZoGo, https://github.com/librespeed/speedtest/issues/585_
+
+**Implementation** by Timendus - 2024
+([https://github.com/Timendus](https://github.com/Timendus))
+
+> "I had a couple of days of free time, and I came across the design by
+> fromScratch. I thought it looked great, and that it deserved a good
+> implementation, so I set out to make one. I've taken the liberty of changing a
+> couple of details, coming up with some animations and adding a few small
+> features, but otherwise I've tried to stay as close to the "intention" of the
+> design as I could."
+
+_-- Timendus, https://github.com/librespeed/speedtest/pull/649_

BIN
frontend/fonts/Inter-latin-ext.woff2


BIN
frontend/fonts/Inter-latin.woff2


BIN
frontend/images/background-original.jpeg


BIN
frontend/images/background.jpeg


+ 22 - 0
frontend/images/chevron.svg

@@ -0,0 +1,22 @@
+<svg
+    width="32"
+    height="32"
+    viewBox="0 0 32 32"
+    fill="none"
+    xmlns="http://www.w3.org/2000/svg"
+>
+    <g clip-path="url(#clip0_373_537)">
+        <path
+            d="M26 12L16 22L6 12"
+            stroke="#625B6B"
+            stroke-width="1.5"
+            stroke-linecap="round"
+            stroke-linejoin="round"
+        />
+    </g>
+    <defs>
+        <clipPath id="clip0_373_537">
+            <rect width="32" height="32" fill="white" />
+        </clipPath>
+    </defs>
+</svg>

+ 14 - 0
frontend/images/close-button.svg

@@ -0,0 +1,14 @@
+<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <g clip-path="url(#clip0_373_731)">
+        <path d="M25 7L7 25" stroke="#F5F5F5" stroke-width="2" stroke-linecap="round"
+            stroke-linejoin="round" />
+        <path d="M25 25L7 7" stroke="#F5F5F5" stroke-width="2" stroke-linecap="round"
+            stroke-linejoin="round" />
+    </g>
+    <defs>
+        <clipPath id="clip0_373_731">
+            <rect width="32" height="32" fill="white" />
+        </clipPath>
+    </defs>
+</svg>
+    

+ 19 - 0
frontend/images/favicon.svg

@@ -0,0 +1,19 @@
+<svg width="19" height="18" viewBox="0 0 19 18" fill="none" xmlns="http://www.w3.org/2000/svg">
+    <path
+        d="M16.0345 0.123047H3.20289C1.88703 0.123047 0.820312 1.18976 0.820312 2.50562V15.3373C0.820312 16.6531 1.88703 17.7198 3.20289 17.7198H16.0345C17.3504 17.7198 18.4171 16.6531 18.4171 15.3373V2.50562C18.4171 1.18976 17.3504 0.123047 16.0345 0.123047Z"
+        fill="#F5F5F5" />
+    <path
+        d="M17.1947 2.38135H3.55467L3.42638 9.32426L8.84467 9.49601L8.25888 9.99686L2.04297 15.4616L16.3914 15.3137V8.62233L10.1332 7.52898L17.1947 2.38135Z"
+        fill="url(#paint0_linear_373_542)" />
+    <defs>
+        <linearGradient id="paint0_linear_373_542" x1="2.04297" y1="8.92149" x2="17.1947"
+            y2="8.92149" gradientUnits="userSpaceOnUse">
+            <stop stop-color="#D63BC6" />
+            <stop offset="0.3478" stop-color="#7419B1" />
+            <stop offset="0.5389" stop-color="#3D06A5" />
+            <stop offset="0.7532" stop-color="#485DC4" />
+            <stop offset="1" stop-color="#5CF9FD" />
+        </linearGradient>
+    </defs>
+</svg>
+    

+ 66 - 0
frontend/images/logo.svg

@@ -0,0 +1,66 @@
+<svg
+    width="153"
+    height="18"
+    viewBox="0 0 153 18"
+    fill="none"
+    xmlns="http://www.w3.org/2000/svg"
+    class="logo"
+>
+    <path
+        d="M87.0345 0.123047H74.2029C72.887 0.123047 71.8203 1.18976 71.8203 2.50562V15.3373C71.8203 16.6531 72.887 17.7198 74.2029 17.7198H87.0345C88.3504 17.7198 89.4171 16.6531 89.4171 15.3373V2.50562C89.4171 1.18976 88.3504 0.123047 87.0345 0.123047Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M88.1947 2.38135H74.5547L74.4264 9.32426L79.8447 9.49601L79.2589 9.99686L73.043 15.4616L87.3914 15.3137V8.62233L81.1332 7.52898L88.1947 2.38135Z"
+        fill="url(#paint0_linear_373_540)"
+    />
+    <path
+        d="M0 17.4134V0H3.68171V14.378H11.1473V17.4134H0Z"
+        fill="#F5F5F5"
+    />
+    <path d="M17.2686 0V17.4134H13.5869V0H17.2686Z" fill="#F5F5F5" />
+    <path
+        d="M20.3038 17.4134V0H27.2761C28.5572 0 29.6258 0.189989 30.4818 0.569601C31.3374 0.94958 31.9808 1.47506 32.4117 2.14682C32.8425 2.81857 33.058 3.59104 33.058 4.46387C33.058 5.14385 32.9218 5.74068 32.6497 6.25365C32.3776 6.76661 32.0048 7.18598 31.5317 7.51218C31.0582 7.83802 30.5183 8.06886 29.9118 8.20508V8.37499C30.5749 8.40366 31.1972 8.59042 31.7783 8.93633C32.3593 9.28225 32.8311 9.76544 33.1938 10.3859C33.5566 11.0068 33.7379 11.7452 33.7379 12.6008C33.7379 13.5249 33.51 14.3483 33.0536 15.0709C32.5973 15.7936 31.9242 16.365 31.0341 16.7844C30.144 17.2038 29.0475 17.4135 27.7438 17.4135L20.3038 17.4134ZM23.9855 7.28666H26.7148C27.2195 7.28666 27.6686 7.19739 28.0626 7.01889C28.4565 6.84039 28.7684 6.58661 28.9978 6.25791C29.2276 5.92921 29.3423 5.53524 29.3423 5.07609C29.3423 4.44666 29.1197 3.93942 28.6748 3.55408C28.2296 3.16874 27.5991 2.97589 26.7829 2.97589H23.9855V7.28666ZM23.9855 14.4034H26.9869C28.0127 14.4034 28.7612 14.2067 29.2319 13.8123C29.7021 13.4184 29.9373 12.8929 29.9373 12.2351C29.9373 11.7534 29.8212 11.3282 29.5889 10.9597C29.3563 10.5913 29.0261 10.3023 28.5981 10.0926C28.1701 9.88294 27.6615 9.77792 27.0722 9.77792H23.9855L23.9855 14.4034Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M36.127 17.4134V0H42.9971C44.3123 0 45.436 0.233706 46.3684 0.701485C47.3011 1.1689 48.0123 1.82953 48.5027 2.68232C48.993 3.5358 49.2382 4.53769 49.2382 5.68834C49.2382 6.84472 48.9887 7.83798 48.4901 8.66853C47.9911 9.4987 47.2699 10.135 46.3261 10.5773C45.3823 11.0193 44.2413 11.2404 42.9036 11.2404H38.3039V8.28139H42.3086C43.0115 8.28139 43.5955 8.18533 44.06 7.99248C44.5249 7.79963 44.8723 7.51071 45.1017 7.12501C45.3315 6.73967 45.4462 6.26077 45.4462 5.68831C45.4462 5.11011 45.3315 4.62262 45.1017 4.2258C44.8723 3.82897 44.5235 3.52717 44.0557 3.31997C43.5883 3.11313 43.0001 3.00992 42.2917 3.00992H39.8087V17.4134L36.127 17.4134ZM45.531 9.48901L49.859 17.4134H45.7945L41.5604 9.48901H45.531Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M51.8398 17.4134V0H63.5735V3.03539H55.5216V7.18451H62.9699V10.2203H55.5216V14.378H63.6076V17.4134L51.8398 17.4134Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M92.8662 17.4134V0H99.7364C101.058 0 102.182 0.250554 103.112 0.752396C104.042 1.25387 104.752 1.94857 105.242 2.83538C105.733 3.72256 105.978 4.74452 105.978 5.90091C105.978 7.05729 105.728 8.07745 105.229 8.96177C104.73 9.84609 104.009 10.5347 103.066 11.0279C102.122 11.5212 100.981 11.7677 99.6432 11.7677H95.2643V8.8173H99.0481C99.7564 8.8173 100.341 8.694 100.804 8.44737C101.266 8.20075 101.612 7.85773 101.841 7.41862C102.071 6.97914 102.185 6.47337 102.185 5.90091C102.185 5.32272 102.071 4.81658 101.841 4.38319C101.612 3.94948 101.264 3.61215 100.8 3.37129C100.335 3.13042 99.745 3.00995 99.0309 3.00995H96.5482V17.4134H92.8662Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M108.375 17.4134V0H120.109V3.03539H112.057V7.18451H119.505V10.2203H112.057V14.378H120.143V17.4134L108.375 17.4134Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M123.042 17.4134V0H134.776V3.03539H126.723V7.18451H134.172V10.2203H126.723V14.378H134.809V17.4134L123.042 17.4134Z"
+        fill="#F5F5F5"
+    />
+    <path
+        d="M143.882 17.4134H137.709V0H143.933C145.685 0 147.192 0.34698 148.457 1.04131C149.721 1.736 150.694 2.73213 151.378 4.03012C152.06 5.32844 152.402 6.88165 152.402 8.68967C152.402 10.5035 152.06 12.0624 151.378 13.3661C150.694 14.6698 149.716 15.6702 148.444 16.3674C147.171 17.0647 145.651 17.4134 143.882 17.4134ZM141.391 14.259H143.729C144.817 14.259 145.734 14.0647 146.48 13.6765C147.225 13.2883 147.787 12.6847 148.163 11.8652C148.54 11.0465 148.729 9.98763 148.729 8.68963C148.729 7.40276 148.54 6.35143 148.163 5.53521C147.787 4.71865 147.227 4.11788 146.484 3.73254C145.741 3.34721 144.826 3.15435 143.737 3.15435H141.391L141.391 14.259Z"
+        fill="#F5F5F5"
+    />
+    <defs>
+        <linearGradient
+            id="paint0_linear_373_540"
+            x1="73.043"
+            y1="8.92149"
+            x2="88.1947"
+            y2="8.92149"
+            gradientUnits="userSpaceOnUse"
+        >
+            <stop stop-color="#D63BC6" />
+            <stop offset="0.3478" stop-color="#7419B1" />
+            <stop offset="0.5389" stop-color="#3D06A5" />
+            <stop offset="0.7532" stop-color="#485DC4" />
+            <stop offset="1" stop-color="#5CF9FD" />
+        </linearGradient>
+    </defs>
+</svg>

+ 151 - 0
frontend/index.html

@@ -0,0 +1,151 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta
+      name="viewport"
+      content="width=device-width, initial-scale=1, shrink-to-fit=no"
+    />
+    <meta
+      name="description"
+      content="Free and Open Source Speedtest. Run it right now in your browser, or self-host on a PHP, Golang, Rust or Node server. License: LGPL."
+    />
+    <link rel="shortcut icon" href="images/favicon.svg" />
+    <script type="text/javascript" src="speedtest.js"></script>
+    <script type="text/javascript" src="javascript/index.js"></script>
+    <link rel="stylesheet" type="text/css" href="styling/index.css" />
+    <title>LibreSpeed - Free and Open Source Speedtest</title>
+  </head>
+
+  <body>
+    <header>
+      <img src="images/logo.svg" alt="LibreSpeed" />
+    </header>
+    <main>
+      <h1>Free and Open Source Speedtest.</h1>
+      <p class="tagline">No Flash, No Java, No Websockets, No Bullsh*t</p>
+
+      <div class="server-selector">
+        <div class="chosen">
+          <div class="chevron">
+            <img src="images/chevron.svg" alt="select..." />
+          </div>
+          <p>current server</p>
+          <h2 id="selected-server">searching nearest server...</h2>
+        </div>
+        <ul class="servers"></ul>
+        <p class="sponsor" id="sponsor">&nbsp;</p>
+      </div>
+
+      <p id="privacy-warning" class="hidden">
+        by clicking the start button you agree to our privacy policy<br />
+        <a href="#" id="choose-privacy">or choose your privacy options</a>
+      </p>
+      <button class="disabled" id="start-button"></button>
+
+      <div class="gauge-layout">
+        <div class="ping hidden">
+          <span class="label">Ping</span>:&nbsp;
+          <span class="value" id="ping">00</span>ms
+        </div>
+
+        <div class="gauge download" id="download-gauge">
+          <div class="progress"></div>
+          <div class="speed"></div>
+          <h1><span id="download-speed">00</span> Mbps</h1>
+          <h2>Download</h2>
+        </div>
+
+        <div class="gauge upload" id="upload-gauge">
+          <div class="progress"></div>
+          <div class="speed"></div>
+          <h1><span id="upload-speed">00</span> Mbps</h1>
+          <h2>Upload</h2>
+        </div>
+
+        <div class="jitter hidden">
+          <span class="label">Jitter</span>:&nbsp;
+          <span class="value" id="jitter">00</span>ms
+        </div>
+      </div>
+
+      <button class="small inverted hidden" id="share-results">
+        Share results
+      </button>
+    </main>
+    <footer>
+      <p class="source">
+        <a href="https://github.com/librespeed/speedtest">source code</a>
+      </p>
+    </footer>
+
+    <dialog id="share">
+      <div class="close-dialog">
+        <img src="images/close-button.svg" alt="Close" />
+      </div>
+      <img id="results" src="" alt="Test results in graphical form" />
+      <button id="copy-link">Copy link</button>
+    </dialog>
+
+    <dialog id="privacy">
+      <div class="close-dialog">
+        <img src="images/close-button.svg" alt="Close" />
+      </div>
+      <section>
+        <h1>Privacy Policy</h1>
+        <p>
+          This HTML5 speed test server is configured with telemetry enabled.
+        </p>
+
+        <h2>What data we collect</h2>
+        <p>
+          At the end of the test, the following data is collected and stored:
+        </p>
+
+        <ul>
+          <li>Test ID</li>
+          <li>Time of testing</li>
+          <li>Test results (download and upload speed, ping and jitter)</li>
+          <li>IP address</li>
+          <li>ISP information</li>
+          <li>Approximate location (inferred from IP address, not GPS)</li>
+          <li>User agent and browser locale</li>
+          <li>Test log (contains no personal information)</li>
+        </ul>
+
+        <h2>How we use the data</h2>
+        <p>Data collected through this service is used to:</p>
+
+        <ul>
+          <li>
+            Allow sharing of test results (sharable image for forums, etc.)
+          </li>
+          <li>
+            To improve the service offered to you (for instance, to detect
+            problems on our side)
+          </li>
+        </ul>
+
+        <p>No personal information is disclosed to third parties.</p>
+
+        <h2>Your consent</h2>
+        <p>
+          By starting the test, you consent to the terms of this privacy policy.
+        </p>
+
+        <h2>Data removal</h2>
+        <p>
+          If you want to have your information deleted, you need to provide
+          either the ID of the test or your IP address. This is the only way to
+          identify your data, without this information we won't be able to
+          comply with your request.
+        </p>
+        <p>
+          Contact this email address for all deletion requests:
+          <a href="mailto:PUT@YOUR_EMAIL.HERE">TO BE FILLED BY DEVELOPER</a>.
+        </p>
+      </section>
+      <button id="close-privacy">Close</button>
+    </dialog>
+  </body>
+</html>

+ 447 - 0
frontend/javascript/index.js

@@ -0,0 +1,447 @@
+/**
+ * Design by fromScratch Studio - 2022, 2023 (fromscratch.io)
+ * Implementation in HTML/CSS/JS by Timendus - 2024 (https://github.com/Timendus)
+ *
+ * See https://github.com/librespeed/speedtest/issues/585
+ */
+
+// States the UI can be in
+const INITIALIZING = 0;
+const READY = 1;
+const RUNNING = 2;
+const FINISHED = 3;
+
+// Keep some global state here
+const testState = {
+  state: INITIALIZING,
+  speedtest: null,
+  servers: [],
+  selectedServerDirty: false,
+  testData: null,
+  testDataDirty: false,
+  telemetryEnabled: false,
+};
+
+// Bootstrap the application when the DOM is ready
+window.addEventListener("DOMContentLoaded", async () => {
+  createSpeedtest();
+  hookUpButtons();
+  startRenderingLoop();
+  applySettingsJSON();
+  applyServerListJSON();
+});
+
+/**
+ * Create a new Speedtest and hook it into the global state
+ */
+function createSpeedtest() {
+  testState.speedtest = new Speedtest();
+  testState.speedtest.onupdate = (data) => {
+    testState.testData = data;
+    testState.testDataDirty = true;
+  };
+  testState.speedtest.onend = (aborted) =>
+    (testState.state = aborted ? READY : FINISHED);
+}
+
+/**
+ * Make all the buttons respond to the right clicks
+ */
+function hookUpButtons() {
+  document
+    .querySelector("#start-button")
+    .addEventListener("click", startButtonClickHandler);
+  document
+    .querySelector("#choose-privacy")
+    .addEventListener("click", () =>
+      document.querySelector("#privacy").showModal()
+    );
+  document
+    .querySelector("#share-results")
+    .addEventListener("click", () =>
+      document.querySelector("#share").showModal()
+    );
+  document
+    .querySelector("#copy-link")
+    .addEventListener("click", copyLinkButtonClickHandler);
+  document
+    .querySelectorAll(".close-dialog, #close-privacy")
+    .forEach((element) => {
+      element.addEventListener("click", () =>
+        document.querySelectorAll("dialog").forEach((modal) => modal.close())
+      );
+    });
+}
+
+/**
+ * Event listener for clicks on the main start button
+ */
+function startButtonClickHandler() {
+  switch (testState.state) {
+    case READY:
+    case FINISHED:
+      testState.speedtest.start();
+      testState.state = RUNNING;
+      return;
+    case RUNNING:
+      testState.speedtest.abort();
+      // testState.state is updated by `onend` handler of speedtest
+      return;
+    default:
+      return;
+  }
+}
+
+/**
+ * Event listener for clicks on the "Copy link" button in the modal
+ */
+async function copyLinkButtonClickHandler() {
+  const link = document.querySelector("img#results").src;
+  await navigator.clipboard.writeText(link);
+  const button = document.querySelector("#copy-link");
+  button.classList.add("active");
+  button.textContent = "Copied!";
+  setTimeout(() => {
+    button.classList.remove("active");
+    button.textContent = "Copy link";
+  }, 3000);
+}
+
+/**
+ * Load settings from settings.json on the server and apply them
+ */
+async function applySettingsJSON() {
+  try {
+    const response = await fetch("settings.json");
+    const settings = await response.json();
+    if (!settings || typeof settings !== "object") {
+      return console.error("Settings are empty or malformed");
+    }
+    for (let setting in settings) {
+      testState.speedtest.setParameter(setting, settings[setting]);
+      if (
+        setting == "telemetry_level" &&
+        settings[setting] &&
+        settings[setting] != "off" &&
+        settings[setting] != "disabled" &&
+        settings[setting] != "false"
+      ) {
+        testState.telemetryEnabled = true;
+        document.querySelector("#privacy-warning").classList.remove("hidden");
+      }
+    }
+  } catch (error) {
+    console.error("Failed to fetch settings:", error);
+  }
+}
+
+/**
+ * Load server list from the configured source and populate the dropdown
+ */
+async function applyServerListJSON() {
+  try {
+    const serverSource =
+      typeof globalThis.SPEEDTEST_SERVERS !== "undefined"
+        ? globalThis.SPEEDTEST_SERVERS
+        : "server-list.json";
+    const servers = Array.isArray(serverSource)
+      ? serverSource
+      : await fetch(serverSource).then((response) => response.json());
+    if (!servers || !Array.isArray(servers) || servers.length === 0) {
+      return console.error("Server list is empty or malformed");
+    }
+
+    testState.servers = servers;
+
+    // If there's only one server, just show it. No reachability checks needed.
+    if (servers.length === 1) {
+      populateDropdown(servers);
+      return;
+    }
+
+    // For multiple servers: first run the built-in selection (which pings servers
+    // and annotates them with pingT). Only then populate the dropdown so that
+    // dead servers don't appear.
+    testState.speedtest.addTestPoints(servers);
+    testState.speedtest.selectServer((bestServer) => {
+      const aliveServers = testState.servers.filter((s) => {
+        // Keep servers that responded to ping (pingT !== -1).
+        if (s.pingT !== -1) return true;
+        // Also keep protocol-relative servers ("//...") as a defensive fallback.
+        // LibreSpeed normalizes them to the page protocol before pinging, so they
+        // are normally treated like any other server and get a real pingT value.
+        return typeof s.server === "string" && s.server.startsWith("//");
+      });
+
+      // Prefer to show only reachable servers, but if none are reachable,
+      // fall back to the full list so users can still pick a server manually.
+      if (aliveServers.length > 0) {
+        testState.servers = aliveServers;
+      }
+      populateDropdown(testState.servers);
+
+
+      if (bestServer) {
+        selectServer(bestServer);
+      } else {
+        alert(
+          "Can't reach any of the speedtest servers! But you're on this page. Something weird is going on with your network."
+        );
+      }
+    });
+  } catch (error) {
+    console.error("Failed to load server list:", error);
+  }
+}
+
+/**
+ * Add all the servers to the server selection dropdown and make it actually
+ * work.
+ * @param {Array} servers - an array of server objects
+ */
+function populateDropdown(servers) {
+  const serverSelector = document.querySelector("div.server-selector");
+  const serverList = serverSelector.querySelector("ul.servers");
+
+  // Reset previous state (populateDropdown can be called multiple times)
+  serverSelector.classList.remove("single-server");
+  serverSelector.classList.remove("active");
+  serverList.classList.remove("active");
+  serverList.innerHTML = "";
+
+  // If we have only a single server, just show it
+  if (servers.length === 1) {
+    serverSelector.classList.add("single-server");
+    selectServer(servers[0]);
+    return;
+  }
+  serverSelector.classList.add("active");
+
+  // Make the dropdown open and close (hook only once)
+  if (serverSelector.dataset.hooked !== "1") {
+    serverSelector.dataset.hooked = "1";
+
+    serverSelector.addEventListener("click", () => {
+      serverList.classList.toggle("active");
+    });
+    document.addEventListener("click", (e) => {
+      if (e.target.closest("div.server-selector") !== serverSelector)
+        serverList.classList.remove("active");
+    });
+  }
+
+  // Populate the list to choose from
+  servers.forEach((server) => {
+    const item = document.createElement("li");
+    const link = document.createElement("a");
+    link.href = "#";
+    link.innerHTML = `${server.name}${
+      server.sponsorName ? ` <span>(${server.sponsorName})</span>` : ""
+    }`;
+    link.addEventListener("click", () => selectServer(server));
+    item.appendChild(link);
+    serverList.appendChild(item);
+  });
+}
+
+/**
+ * Set the given server as the selected server for the speedtest
+ * @param {Object} server - a server object
+ */
+function selectServer(server) {
+  testState.speedtest.setSelectedServer(server);
+  testState.selectedServerDirty = true;
+  testState.state = READY;
+}
+
+/**
+ * Start the requestAnimationFrame UI rendering loop
+ */
+function startRenderingLoop() {
+  // Do these queries once to speed up the rendering itself
+  const serverSelector = document.querySelector("div.server-selector");
+  const selectedServer = serverSelector.querySelector("#selected-server");
+  const sponsor = serverSelector.querySelector("#sponsor");
+  const startButton = document.querySelector("#start-button");
+  const privacyWarning = document.querySelector("#privacy-warning");
+
+  const gauges = document.querySelectorAll("#download-gauge, #upload-gauge");
+  const downloadProgress = document.querySelector("#download-gauge .progress");
+  const uploadProgress = document.querySelector("#upload-gauge .progress");
+  const downloadGauge = document.querySelector("#download-gauge .speed");
+  const uploadGauge = document.querySelector("#upload-gauge .speed");
+  const downloadText = document.querySelector("#download-gauge span");
+  const uploadText = document.querySelector("#upload-gauge span");
+
+  const pingAndJitter = document.querySelectorAll(".ping, .jitter");
+  const ping = document.querySelector("#ping");
+  const jitter = document.querySelector("#jitter");
+  const shareResults = document.querySelector("#share-results");
+  const copyLink = document.querySelector("#copy-link");
+  const resultsImage = document.querySelector("#results");
+
+  const buttonTexts = {
+    [INITIALIZING]: "Loading...",
+    [READY]: "Let's start",
+    [RUNNING]: "Abort",
+    [FINISHED]: "Restart",
+  };
+
+  // Show copy link button only if navigator.clipboard is available
+  copyLink.classList.toggle("hidden", !navigator.clipboard);
+
+  function renderUI() {
+    // Make the main button reflect the current state
+    startButton.textContent = buttonTexts[testState.state];
+    startButton.classList.toggle("disabled", testState.state === INITIALIZING);
+    startButton.classList.toggle("active", testState.state === RUNNING);
+
+    // Disable the server selector while test is running
+    serverSelector.classList.toggle("disabled", testState.state === RUNNING);
+
+    // Show selected server
+    if (testState.selectedServerDirty) {
+      const server = testState.speedtest.getSelectedServer();
+      selectedServer.textContent = server.name;
+      if (server.sponsorName) {
+        if (server.sponsorURL) {
+          sponsor.innerHTML = `Sponsor: <a href="${server.sponsorURL}">${server.sponsorName}</a>`;
+        } else {
+          sponsor.textContent = `Sponsor: ${server.sponsorName}`;
+        }
+      } else {
+        sponsor.innerHTML = "&nbsp;";
+      }
+      testState.selectedServerDirty = false;
+    }
+
+    // Activate the gauges when test running or finished
+    gauges.forEach((e) =>
+      e.classList.toggle(
+        "enabled",
+        testState.state === RUNNING || testState.state === FINISHED
+      )
+    );
+
+    // Show ping and jitter if data is available
+    pingAndJitter.forEach((e) =>
+      e.classList.toggle(
+        "hidden",
+        !(
+          testState.testData &&
+          testState.testData.pingStatus &&
+          testState.testData.jitterStatus
+        )
+      )
+    );
+
+    // Show share button after test if server supports it
+    shareResults.classList.toggle(
+      "hidden",
+      !(
+        testState.state === FINISHED &&
+        testState.telemetryEnabled &&
+        testState.testData.testId
+      )
+    );
+
+    if (testState.testDataDirty) {
+      // Set gauge rotations
+      downloadProgress.style = `--progress-rotation: ${
+        testState.testData.dlProgress * 180
+      }deg`;
+      uploadProgress.style = `--progress-rotation: ${
+        testState.testData.ulProgress * 180
+      }deg`;
+      downloadGauge.style = `--speed-rotation: ${mbpsToRotation(
+        testState.testData.dlStatus,
+        testState.testData.testState === 1
+      )}deg`;
+      uploadGauge.style = `--speed-rotation: ${mbpsToRotation(
+        testState.testData.ulStatus,
+        testState.testData.testState === 3
+      )}deg`;
+
+      // Set numeric values
+      downloadText.textContent = numberToText(testState.testData.dlStatus);
+      uploadText.textContent = numberToText(testState.testData.ulStatus);
+      ping.textContent = numberToText(testState.testData.pingStatus);
+      jitter.textContent = numberToText(testState.testData.jitterStatus);
+
+      // Set user's IP and provider
+      if (testState.testData.clientIp) {
+        // Clear previous content
+        privacyWarning.innerHTML = '';
+
+        const connectedThrough = document.createElement('span');
+        connectedThrough.textContent = 'You are connected through:';
+  
+        const ipAddress = document.createTextNode(testState.testData.clientIp);
+
+        privacyWarning.appendChild(connectedThrough);
+        privacyWarning.appendChild(document.createElement('br'));
+        privacyWarning.appendChild(ipAddress);
+  
+        privacyWarning.classList.remove("hidden");
+      }
+
+      // Set image for sharing results
+      if (testState.testData.testId) {
+        resultsImage.src =
+          window.location.href.substring(
+            0,
+            window.location.href.lastIndexOf("/")
+          ) +
+          "/results/?id=" +
+          testState.testData.testId;
+      }
+
+      testState.testDataDirty = false;
+    }
+
+    requestAnimationFrame(renderUI);
+  }
+
+  renderUI();
+}
+
+/**
+ * Convert a speed in Mbits per second to a rotation for the gauge
+ * @param {string} speed Speed in Mbits
+ * @param {boolean} oscillate If the gauge should wiggle a bit
+ * @returns {number} Rotation for the gauge in degrees
+ */
+function mbpsToRotation(speed, oscillate) {
+  speed = Number(speed);
+  if (speed <= 0) return 0;
+
+  const minSpeed = 0;
+  const maxSpeed = 10000; // 10 Gbps maxes out the gauge
+  const minRotation = 0;
+  const maxRotation = 180;
+
+  // Can't do log10 of values less than one, +1 all to keep it fair
+  const logMinSpeed = Math.log10(minSpeed + 1);
+  const logMaxSpeed = Math.log10(maxSpeed + 1);
+  const logSpeed = Math.log10(speed + 1);
+
+  const power = (logSpeed - logMinSpeed) / (logMaxSpeed - logMinSpeed);
+  const oscillation = oscillate ? 1 + 0.01 * Math.sin(Date.now() / 100) : 1;
+  const rotation = power * oscillation * maxRotation;
+
+  // Make sure we stay within bounds at all times
+  return Math.max(Math.min(rotation, maxRotation), minRotation);
+}
+
+/**
+ * Convert a number to a user friendly version
+ * @param {string} value Speed, ping or jitter
+ * @returns {string} A text version with proper decimals
+ */
+function numberToText(value) {
+  if (!value) return "00";
+  value = Number(value);
+  if (value < 10) return value.toFixed(2);
+  if (value < 100) return value.toFixed(1);
+  return value.toFixed(0);
+}

BIN
frontend/screenshots/desktop.png


BIN
frontend/screenshots/mobile.png


+ 409 - 0
frontend/server-list.json

@@ -0,0 +1,409 @@
+[
+  {
+    "name": "Amsterdam, Netherlands",
+    "server": "//ams.speedtest.clouvider.net/backend",
+    "id": 51,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Clouvider",
+    "sponsorURL": "https://www.clouvider.co.uk/"
+  },
+  {
+    "name": "Amsterdam, Netherlands (Rust backend)",
+    "server": "https://librespeed-rs.ir/",
+    "id": 95,
+    "dlURL": "backend/garbage",
+    "ulURL": "backend/empty",
+    "pingURL": "backend/empty",
+    "getIpURL": "backend/getIP",
+    "sponsorName": "Sudo Dios",
+    "sponsorURL": "https://github.com/SudoDios"
+  },
+  {
+    "name": "Amsterdam, Netherlands",
+    "server": "https://amsspeed.sharktech.net",
+    "id": 94,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Sharktech",
+    "sponsorURL": "https://sharktech.net"
+  },
+  {
+    "name": "Atlanta, United States",
+    "server": "//atl.speedtest.clouvider.net/backend",
+    "id": 53,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Clouvider",
+    "sponsorURL": "https://www.clouvider.co.uk/"
+  },
+  {
+    "name": "Bangalore, India",
+    "server": "//in1.backend.librespeed.org/",
+    "id": 75,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "DigitalOcean",
+    "sponsorURL": "https://www.digitalocean.com"
+  },
+  {
+    "name": "Bari, Italy",
+    "server": "https://st-be-ba1.infra.garr.it",
+    "id": 33,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Consortium GARR",
+    "sponsorURL": "//garr.it"
+  },
+  {
+    "name": "Bologna, Italy",
+    "server": "https://st-be-bo1.infra.garr.it",
+    "id": 34,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Consortium GARR",
+    "sponsorURL": "//garr.it"
+  },
+  {
+    "name": "Chicago, USA",
+    "server": "https://chispeed.sharktech.net",
+    "id": 93,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Sharktech",
+    "sponsorURL": "https://sharktech.net"
+  },
+  {
+    "name": "Denver, USA",
+    "server": "https://denspeed.sharktech.net",
+    "id": 92,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Sharktech",
+    "sponsorURL": "https://sharktech.net"
+  },
+  {
+    "name": "Frankfurt, Germany",
+    "server": "//fra.speedtest.clouvider.net/backend",
+    "id": 50,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Clouvider",
+    "sponsorURL": "https://www.clouvider.co.uk/"
+  },
+  {
+    "name": "Frankfurt, Germany (FRA01)",
+    "server": "https://speedtest.lumischvps.cloud/",
+    "id": 86,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "LumischVPS",
+    "sponsorURL": "https://discord.gg/GxYzPwJmA2"
+  },
+  {
+    "name": "Ghom, Iran (Amin IDC)",
+    "server": "https://fastme.ir/",
+    "id": 77,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Bardia Moshiri",
+    "sponsorURL": "https://bardia.tech/"
+  },
+  {
+    "name": "Helsinki, Finland (3) (Hetzner)",
+    "server": "//finew.openspeed.org/",
+    "id": 22,
+    "dlURL": "backend437/garbage.php",
+    "ulURL": "backend437/empty.php",
+    "pingURL": "backend437/empty.php",
+    "getIpURL": "backend437/getIP.php",
+    "sponsorName": "Daily Health Insurance Group",
+    "sponsorURL": "//dhig.net/"
+  },
+  {
+    "name": "Helsinki, Finland (5) (Hetzner)",
+    "server": "//fast.kabi.tk/",
+    "id": 24,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "KABI.tk",
+    "sponsorURL": "//kabi.tk"
+  },
+  {
+    "name": "Johannesburg, South Africa",
+    "server": "//za1.backend.librespeed.org/",
+    "id": 70,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "HOSTAFRICA",
+    "sponsorURL": "https://www.hostafrica.co.za"
+  },
+  {
+    "name": "Las Vegas, USA",
+    "server": "https://lasspeed.sharktech.net",
+    "id": 90,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Sharktech",
+    "sponsorURL": "https://sharktech.net"
+  },
+  {
+    "name": "London, England",
+    "server": "//lon.speedtest.clouvider.net/backend",
+    "id": 49,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Clouvider",
+    "sponsorURL": "https://www.clouvider.co.uk/"
+  },
+  {
+    "name": "Los Angeles, United States (1)",
+    "server": "//la.speedtest.clouvider.net/backend",
+    "id": 54,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Clouvider",
+    "sponsorURL": "https://www.clouvider.co.uk/"
+  },
+  {
+    "name": "Los Angeles, USA",
+    "server": "https://laxspeed.sharktech.net",
+    "id": 91,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Sharktech",
+    "sponsorURL": "https://sharktech.net"
+  },
+  {
+    "name": "New York, United States (2)",
+    "server": "//nyc.speedtest.clouvider.net/backend",
+    "id": 52,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Clouvider",
+    "sponsorURL": "https://www.clouvider.co.uk/"
+  },
+  {
+    "name": "Nottingham, England (LayerIP)",
+    "server": "https://uk1.backend.librespeed.org",
+    "id": 43,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "fosshost.org",
+    "sponsorURL": "https://fosshost.org"
+  },
+  {
+    "name": "Nuremberg, Germany (1) (Hetzner)",
+    "server": "//de1.backend.librespeed.org",
+    "id": 28,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Snopyta",
+    "sponsorURL": "https://snopyta.org"
+  },
+  {
+    "name": "Nuremberg, Germany (2) (Hetzner)",
+    "server": "//de4.backend.librespeed.org",
+    "id": 27,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "LibreSpeed",
+    "sponsorURL": "https://librespeed.org"
+  },
+  {
+    "name": "Nuremberg, Germany (3) (Hetzner)",
+    "server": "//de3.backend.librespeed.org",
+    "id": 30,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "LibreSpeed",
+    "sponsorURL": "https://librespeed.org"
+  },
+  {
+    "name": "Nuremberg, Germany (4) (Hetzner)",
+    "server": "//de5.backend.librespeed.org",
+    "id": 31,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "LibreSpeed",
+    "sponsorURL": "https://librespeed.org"
+  },
+  {
+    "name": "Nuremberg, Germany (6) (Hetzner)",
+    "server": "//librespeed.lukas-heinrich.com/",
+    "id": 46,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "luki9100",
+    "sponsorURL": "https://lukas-heinrich.com/"
+  },
+  {
+    "name": "Poznan, Poland (INEA)",
+    "server": "https://speedtest.kamilszczepanski.com",
+    "id": 74,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Kamil Szczepa\u0144ski",
+    "sponsorURL": "https://kamilszczepanski.com"
+  },
+  {
+    "name": "Prague, Czech Republic",
+    "server": "//speedtest.cesnet.cz",
+    "id": 79,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "CESNET",
+    "sponsorURL": "https://www.cesnet.cz"
+  },
+  {
+    "name": "Prague, Czech Republic",
+    "server": "//librespeed.turris.cz",
+    "id": 85,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Turris",
+    "sponsorURL": "https://www.turris.com"
+  },
+  {
+    "name": "Roma, Italy",
+    "server": "https://st-be-rm2.infra.garr.it",
+    "id": 35,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Consortium GARR",
+    "sponsorURL": "//garr.it"
+  },
+  {
+    "name": "Serbia",
+    "server": "https://speedtest2.sox.rs",
+    "id": 87,
+    "dlURL": "libre/backend/garbage.php",
+    "ulURL": "libre/backend/empty.php",
+    "pingURL": "libre/backend/empty.php",
+    "getIpURL": "libre/backend/getIP.php",
+    "sponsorName": "Serbian Open eXchange",
+    "sponsorURL": "https://sox.rs"
+  },
+  {
+    "name": "Singapore",
+    "server": "https://speedtest.dsgroupmedia.com",
+    "id": 68,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Salvatore Cahyo",
+    "sponsorURL": "https://salvatorecahyo.my.id"
+  },
+  {
+    "name": "Tehran, Iran (Fanava)",
+    "server": "https://speedme.ir/",
+    "id": 76,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Bardia Moshiri",
+    "sponsorURL": "https://bardia.tech"
+  },
+  {
+    "name": "Tehran, Iran (Faraso)",
+    "server": "https://st.bardia.tech",
+    "id": 80,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "Bardia Moshiri",
+    "sponsorURL": "https://bardia.tech/"
+  },
+  {
+    "name": "Tokyo, Japan",
+    "server": "https://librespeed.a573.net/",
+    "id": 82,
+    "dlURL": "backend/garbage.php",
+    "ulURL": "backend/empty.php",
+    "pingURL": "backend/empty.php",
+    "getIpURL": "backend/getIP.php",
+    "sponsorName": "A573",
+    "sponsorURL": "https://mirror.a573.net/"
+  },
+  {
+    "name": "Vilnius, Lithuania (RackRay)",
+    "server": "//lt1.backend.librespeed.org/",
+    "id": 69,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Time4VPS",
+    "sponsorURL": "https://www.time4vps.com"
+  },
+  {
+    "name": "Virginia, United States, OVH",
+    "server": "https://speed.riverside.rocks/",
+    "id": 78,
+    "dlURL": "garbage.php",
+    "ulURL": "empty.php",
+    "pingURL": "empty.php",
+    "getIpURL": "getIP.php",
+    "sponsorName": "Riverside Rocks",
+    "sponsorURL": "https://riverside.rocks"
+  }
+]

+ 9 - 0
frontend/settings.json

@@ -0,0 +1,9 @@
+{
+  "telemetry_level": "off",
+  "test_order": "ID_U_P",
+  "time_dl_max": 12,
+  "time_ul_max": 12,
+  "time_dlGraceTime": 0,
+  "time_ulGraceTime": 0,
+  "time_auto": false
+}

+ 83 - 0
frontend/styling/button.css

@@ -0,0 +1,83 @@
+/* The main "start the test" button and the share button */
+
+button {
+  height: 6.8rem;
+  min-width: 26.4rem;
+  padding: 0 5rem;
+  margin: 2.5rem;
+  border-radius: 3.4rem;
+  border: 0;
+
+  font-family: "Inter", sans-serif;
+  font-size: 2rem;
+  font-weight: 700;
+  letter-spacing: -0.1rem;
+  color: var(--button-text-color);
+  text-transform: uppercase;
+  cursor: pointer;
+  box-shadow: 0 0.4rem 1.6rem 0 var(--button-shadow-color);
+
+  will-change: transform;
+  backface-visibility: hidden;
+  transform: scale(1) translate3d(0, 0, 0) perspective(1px);
+
+  background: var(--button-gradient-1-color-1);
+  transition: background-position 0.2s, transform 0.2s;
+  background-position: 0% 0%;
+  background: linear-gradient(
+    92.97deg,
+    var(--button-gradient-1-color-1) 0%,
+    var(--button-gradient-1-color-1) 33%,
+    var(--button-gradient-1-color-2) 40%,
+    var(--button-gradient-1-color-3) 66.71%,
+    var(--button-gradient-1-color-3) 100%
+  );
+  background-size: 300% 100%;
+
+  &.disabled {
+    cursor: default;
+    transform: scale(1) translate3d(0, 0, 0) perspective(1px);
+    background: var(--button-disabled-background-color);
+  }
+  &.small {
+    height: 4.7rem;
+    min-width: 20.2rem;
+    text-transform: lowercase;
+  }
+  &.inverted {
+    border: 1px solid var(--button-gradient-1-color-1);
+    color: transparent;
+    background-clip: text;
+  }
+  &.hidden {
+    opacity: 0;
+    pointer-events: none;
+  }
+  &:hover {
+    background-position: 60% 0%;
+    transform: scale(1.03) translate3d(0, 0, 0) perspective(1px);
+  }
+  &.active,
+  &:active {
+    background-position: 100% 0%;
+    animation: pulse 0.7s;
+  }
+}
+
+@keyframes pulse {
+  0% {
+    transform: scale(1.03) translate3d(0, 0, 0) perspective(1px);
+  }
+  20% {
+    transform: scale(1.2) translate3d(0, 0, 0) perspective(1px);
+  }
+  40% {
+    transform: scale(1) translate3d(0, 0, 0) perspective(1px);
+  }
+  60% {
+    transform: scale(1.1) translate3d(0, 0, 0) perspective(1px);
+  }
+  100% {
+    transform: scale(1) translate3d(0, 0, 0) perspective(1px);
+  }
+}

+ 36 - 0
frontend/styling/colors.css

@@ -0,0 +1,36 @@
+:root {
+  --theme-green: #5cf9fd;
+  --theme-pink: #d63bc6;
+
+  --background-backup-color: #0e0720;
+  --background-overlay-color: rgb(41 26 70 / 71%);
+
+  --primary-text-color: #ffffff;
+  --tagline-text-color: var(--theme-green);
+  --secondary-text-color: #898591;
+  --primary-text-disabled-color: #888888;
+  --secondary-text-disabled-color: #2e7d7f;
+
+  --button-text-color: #3e2f50;
+  --button-gradient-1-color-1: #f5f5f5;
+  --button-gradient-1-color-2: var(--theme-green);
+  --button-gradient-1-color-3: var(--theme-pink);
+  --button-shadow-color: #5cf9fd47;
+  --button-disabled-background-color: #a2a2a2;
+
+  --server-selector-border-color: #625b6b;
+  --server-selector-hover-border-color: var(--theme-green);
+  --server-selector-background-color: #251b32;
+  --server-selector-hover-background-color: var(--server-selector-border-color);
+
+  --gauge-background-color: #3e2f50;
+  --gauge-progress-color: #726c7a;
+  --gauge-pointer-green: #e2fbfc;
+  --gauge-pointer-pink: #d091ca;
+
+  --ping-and-jitter-primary-text-color: #f5f5f5;
+  --ping-and-jitter-secondary-text-color: #7b7b7b;
+
+  --popup-background-color: #251b32;
+  --popup-shadow-color: #000000;
+}

+ 132 - 0
frontend/styling/dialog.css

@@ -0,0 +1,132 @@
+/* Styling for the popups */
+
+dialog {
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 70vw;
+  height: 70vh;
+  margin: auto;
+  margin-top: 23rem;
+
+  background: var(--popup-background-color);
+  border: none;
+  border-radius: 0.8rem;
+
+  @media screen and (max-width: 800px) {
+    position: fixed;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    max-width: 100vw; /* We need these overrides of browser defaults*/
+    max-height: 100vh;
+    width: auto;
+    height: auto;
+    margin: 0;
+  }
+
+  animation: fade-out 0.3s ease-out;
+  &[open] {
+    display: flex;
+    animation: fade-in 0.3s ease-out;
+  }
+
+  & > .close-dialog {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    width: 4rem;
+    height: 4rem;
+    position: absolute;
+    top: 3rem;
+    right: 3rem;
+
+    cursor: pointer;
+  }
+
+  & > section {
+    max-width: 800px;
+    overflow-y: auto;
+    margin: 4rem 2rem 2rem 4rem;
+    padding: 0 2rem 0 0;
+
+    & h1,
+    & h2 {
+      margin: 3rem 0 2rem 0;
+      font-size: 3.6rem;
+      font-weight: 400;
+      letter-spacing: -0.2rem;
+      color: var(--primary-text-color);
+    }
+    & h2 {
+      margin: 2rem 0 1rem 0;
+      font-size: 2.5rem;
+    }
+
+    & p,
+    & li {
+      margin: 1rem 0 1rem 0;
+      font-size: 1.6rem;
+      line-height: 2.5rem;
+      font-weight: 400;
+      letter-spacing: -0.1rem;
+      color: var(--secondary-text-color);
+    }
+
+    & ul {
+      list-style-position: inside;
+      margin: 1rem;
+
+      & li {
+        margin: 0.1rem 0;
+      }
+    }
+
+    & a {
+      font-size: 1.6rem;
+      font-weight: 700;
+      letter-spacing: -0.1rem;
+      color: var(--secondary-text-color);
+      text-underline-offset: 0.3rem;
+      transition: text-underline-offset 0.2s;
+
+      &:hover {
+        color: var(--theme-green);
+        text-underline-offset: 0.5rem;
+      }
+    }
+  }
+}
+
+@keyframes fade-in {
+  0% {
+    opacity: 0;
+    transform: scale(0.6);
+    display: none;
+  }
+  0.1% {
+    display: flex;
+  }
+  100% {
+    opacity: 1;
+    transform: scale(1);
+    display: flex;
+  }
+}
+
+@keyframes fade-out {
+  0% {
+    opacity: 1;
+    transform: scale(1);
+    display: flex;
+  }
+  99.9% {
+    display: flex;
+  }
+  100% {
+    opacity: 0;
+    transform: scale(0.6);
+    display: none;
+  }
+}

+ 22 - 0
frontend/styling/fonts.css

@@ -0,0 +1,22 @@
+/* latin-ext */
+@font-face {
+  font-family: "Inter";
+  font-style: normal;
+  font-weight: 100 900;
+  font-display: swap;
+  src: url(../fonts/Inter-latin-ext.woff2) format("woff2");
+  unicode-range: U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF,
+    U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
+}
+
+/* latin */
+@font-face {
+  font-family: "Inter";
+  font-style: normal;
+  font-weight: 100 900;
+  font-display: swap;
+  src: url(../fonts/Inter-latin.woff2) format("woff2");
+  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
+    U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
+    U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}

+ 85 - 0
frontend/styling/index.css

@@ -0,0 +1,85 @@
+/**
+ * Design by fromScratch Studio - 2022, 2023 (fromscratch.io)
+ * Implementation in HTML/CSS/JS by Timendus - 2024 (https://github.com/Timendus)
+ *
+ * See https://github.com/librespeed/speedtest/issues/585
+ */
+
+@import url("colors.css");
+@import url("fonts.css");
+@import url("main.css");
+@import url("server-selector.css");
+@import url("button.css");
+@import url("results.css");
+@import url("dialog.css");
+
+/* Setting up the basic structure */
+
+* {
+  box-sizing: border-box;
+  padding: 0;
+  margin: 0;
+}
+
+html,
+body {
+  min-height: 100vh;
+  width: 100vw;
+}
+
+html {
+  background-color: var(--background-backup-color);
+  background-image: url("../images/background.jpeg");
+  background-repeat: no-repeat;
+  background-position: center;
+  background-size: cover;
+  font-size: 10px;
+
+  @media screen and (max-width: 800px) {
+    font-size: 8px;
+  }
+}
+
+body {
+  font-family: "Inter", sans-serif;
+  background-color: var(--background-overlay-color);
+  color: var(--primary-text-color);
+  display: flex;
+  flex-direction: column;
+}
+
+/* Position the logo */
+
+header {
+  padding: 4rem 7rem;
+
+  @media screen and (max-width: 800px) {
+    padding: 7rem 2rem;
+    text-align: center;
+  }
+}
+
+/* Position the source code link */
+
+footer {
+  margin: auto auto 0 auto;
+  padding: 5rem;
+
+  & > p.source a {
+    font-size: 1.6rem;
+    font-weight: 700;
+    letter-spacing: -0.1rem;
+    color: var(--theme-green);
+    text-underline-offset: 0.3rem;
+    transition: text-underline-offset 0.2s;
+
+    &:hover {
+      color: var(--theme-pink);
+      text-underline-offset: 0.5rem;
+    }
+  }
+
+  @media screen and (max-width: 800px) {
+    padding: 4rem;
+  }
+}

+ 58 - 0
frontend/styling/main.css

@@ -0,0 +1,58 @@
+/* Texts on the front page */
+
+main {
+  text-align: center;
+  padding: 0 2rem;
+  flex: 1;
+
+  & > h1 {
+    margin: 0.2rem;
+    font-size: 3.6rem;
+    font-weight: 400;
+    letter-spacing: -0.2rem;
+    color: var(--primary-text-color);
+  }
+
+  & p {
+    margin-top: 8rem;
+    font-size: 1.6rem;
+    line-height: 2.5rem;
+    font-weight: 400;
+    letter-spacing: -0.1rem;
+    color: var(--secondary-text-color);
+
+    &#privacy-warning {
+      min-height: 5.3rem;
+
+      & > span {
+        font-weight: 700;
+        color: var(--theme-green);
+      }
+      &.hidden {
+        opacity: 0;
+        pointer-events: none;
+      }
+    }
+  }
+
+  & > p.tagline {
+    margin-top: 0;
+    margin-bottom: 6rem;
+    font-size: 2rem;
+    color: var(--tagline-text-color);
+  }
+
+  & a {
+    font-size: 1.6rem;
+    font-weight: 700;
+    letter-spacing: -0.1rem;
+    color: var(--secondary-text-color);
+    text-underline-offset: 0.3rem;
+    transition: text-underline-offset 0.2s;
+
+    &:hover {
+      color: var(--theme-green);
+      text-underline-offset: 0.5rem;
+    }
+  }
+}

+ 260 - 0
frontend/styling/results.css

@@ -0,0 +1,260 @@
+/* Variables */
+
+:root {
+  --gauge-width: 32rem;
+  --gauge-height: 22rem;
+  --progress-width: 0.6rem;
+  --speed-width: 3rem;
+}
+
+/* Layout for the gauges */
+
+.gauge-layout {
+  display: flex;
+  flex-direction: row;
+  align-items: start;
+  justify-content: center;
+  gap: 5rem;
+  margin: 5rem auto 3rem auto;
+
+  @media screen and (max-width: 1100px) {
+    display: grid;
+    grid-template-areas:
+      "download upload"
+      "ping jitter";
+    justify-items: center;
+    justify-content: center;
+
+    --gauge-width: min(40vw, 32rem);
+    --gauge-height: min(28vw, 22rem);
+    --progress-width: min(1.2vw, 0.6rem);
+    --speed-width: min(4vw, 3rem);
+  }
+  @media screen and (max-width: 500px) {
+    gap: 5rem 2rem;
+  }
+}
+
+/* The download/upload speed gauges */
+
+/**
+ * One thing I should really document here is the weird `transform: scale(1);`
+ * and `position: fixed` in this code. This is a nasty little trick to allow the
+ * gauge pointer to break out of the `overflow: hidden` of the .speed element.
+ * We need the `overflow: hidden` to hide the arc that's rotating into view when
+ * the value goes up. But we do want to see the full pointer, even when it's at
+ * zero. This degrades fairly gracefully into showing half of the pointer when
+ * browsers don't understand this.
+ *
+ * Trick taken from this article:
+ * https://medium.com/@thomas.ryu/css-overriding-the-parents-overflow-hidden-90c75a0e7296
+ */
+
+div.gauge {
+  position: relative;
+  transform: scale(1);
+  width: var(--gauge-width);
+  height: var(--gauge-height);
+
+  &.download {
+    grid-area: download;
+  }
+  &.upload {
+    grid-area: upload;
+  }
+
+  & > .progress,
+  & > .speed {
+    position: absolute;
+    top: 0;
+    left: 0;
+    width: var(--gauge-width);
+    height: calc(var(--gauge-width) / 2);
+    overflow: hidden;
+
+    &:after,
+    &:before {
+      content: "";
+      position: absolute;
+      box-sizing: border-box;
+    }
+  }
+
+  & > .progress {
+    &:before,
+    &:after {
+      top: 0;
+      left: 0;
+      width: var(--gauge-width);
+      height: calc(var(--gauge-width) / 2);
+
+      border-radius: 50% 50% 0 0 / 100% 100% 0 0;
+      border: var(--progress-width) solid var(--gauge-background-color);
+      border-bottom: 0;
+
+      transform-origin: bottom center;
+      transform: rotate(var(--progress-rotation));
+      transition: transform 0.2s linear;
+    }
+    &:after {
+      top: calc(var(--gauge-width) / 2);
+
+      border-radius: 0 0 50% 50% / 0 0 100% 100%;
+      border: var(--progress-width) solid var(--gauge-background-color);
+      border-top: 0;
+
+      transform-origin: top center;
+    }
+  }
+
+  & > .speed {
+    &:before,
+    &:after {
+      transform: rotate(var(--speed-rotation));
+      transition: transform 0.2s ease;
+      transition-timing-function: cubic-bezier(0.56, 0.04, 0.59, 0.91);
+    }
+    &:before {
+      position: fixed;
+      top: calc(var(--gauge-width) / 2 - var(--speed-width) / 3);
+      left: var(--progress-width);
+      width: 0;
+      height: 0;
+
+      border-top: calc(var(--speed-width) / 3) solid transparent;
+      border-bottom: calc(var(--speed-width) / 3) solid transparent;
+      border-right: calc(var(--speed-width) * 0.97) solid
+        var(--gauge-background-color);
+      z-index: 1;
+
+      transform-origin: calc(var(--gauge-width) / 2 - var(--progress-width))
+        calc(var(--speed-width) / 3);
+    }
+    &:after {
+      top: calc(var(--gauge-width) / 2);
+      left: calc(var(--progress-width) - 0.1rem);
+      width: calc(var(--gauge-width) - var(--progress-width) * 2 + 0.2rem);
+      height: calc(var(--gauge-width) / 2 - var(--progress-width) + 0.1rem);
+
+      border-radius: 0 0 50% 50% / 0 0 100% 100%;
+      border: var(--speed-width) solid var(--gauge-background-color);
+      border-top: 0;
+
+      transform-origin: top center;
+    }
+  }
+
+  &.enabled {
+    &.download {
+      & > .progress:after {
+        border-color: var(--theme-pink);
+      }
+      & > .speed {
+        &:before {
+          border-right-color: var(--gauge-pointer-pink);
+        }
+        &:after {
+          border-color: var(--theme-pink);
+        }
+      }
+    }
+    &.upload {
+      & > .progress:after {
+        border-color: var(--theme-green);
+      }
+      & > .speed {
+        &:before {
+          border-right-color: var(--gauge-pointer-green);
+        }
+        &:after {
+          border-color: var(--theme-green);
+        }
+      }
+    }
+    & > h1 > span {
+      color: var(--primary-text-color);
+    }
+  }
+
+  & > h1,
+  & > h2 {
+    display: block;
+    position: absolute;
+    width: 100%;
+    font-family: "Inter", sans-serif;
+    font-size: 2.1rem;
+    letter-spacing: -0.1rem;
+    color: var(--secondary-text-color);
+  }
+  & > h1 {
+    bottom: calc(var(--gauge-height) - var(--gauge-width) / 2);
+    font-weight: 300;
+
+    & > span {
+      font-size: 5.5rem;
+      font-weight: 200;
+      display: block;
+      color: var(--secondary-text-color);
+      letter-spacing: -0.3rem;
+    }
+  }
+  & > h2 {
+    bottom: 0;
+    font-weight: 700;
+    text-transform: uppercase;
+  }
+
+  @media screen and (max-width: 500px) {
+    & > h1 {
+      font-size: 3vw;
+
+      & > span {
+        font-size: 8vw;
+      }
+    }
+
+    & > h2 {
+      font-size: 3vw;
+    }
+  }
+}
+
+/* Styling for Ping and Jitter */
+
+.ping,
+.jitter {
+  grid-area: jitter;
+  display: flex;
+  align-items: end;
+  height: calc(var(--gauge-width) / 2);
+  width: 13rem;
+
+  font-size: 2.1rem;
+  letter-spacing: -0.1rem;
+  font-weight: 300;
+  color: var(--ping-and-jitter-secondary-text-color);
+
+  & > .label {
+    font-weight: 700;
+  }
+  & > .value {
+    color: var(--ping-and-jitter-primary-text-color);
+  }
+
+  &.hidden {
+    display: none;
+  }
+
+  @media screen and (max-width: 1100px) {
+    width: 100%;
+    height: auto;
+    justify-content: center !important;
+  }
+  @media screen and (max-width: 500px) {
+    font-size: 1.8rem;
+  }
+}
+.ping {
+  grid-area: ping;
+  justify-content: end;
+}

+ 171 - 0
frontend/styling/server-selector.css

@@ -0,0 +1,171 @@
+/* The server selector fake dropdown */
+
+.server-selector {
+  position: relative;
+  width: 50rem;
+  margin: 0rem auto;
+  display: none;
+
+  &.active {
+    display: block;
+  }
+
+  @media screen and (max-width: 500px) {
+    width: 100%;
+  }
+
+  & > .chosen {
+    position: relative;
+    height: 8.8rem;
+
+    border: 1px solid var(--server-selector-border-color);
+    border-radius: 0.8rem;
+    background-color: var(--server-selector-background-color);
+    cursor: pointer;
+    transition: border-color 0.2s;
+
+    &:hover {
+      border-color: var(--server-selector-hover-border-color);
+    }
+
+    & > div.chevron {
+      content: "";
+      position: absolute;
+      display: block;
+      width: 32px;
+      height: 32px;
+      right: 1.8rem;
+      top: 1rem;
+    }
+
+    & > p {
+      margin: 0;
+      position: absolute;
+      left: 2.4rem;
+      top: 1.5rem;
+      font-size: 1.6rem;
+      font-weight: 400;
+      letter-spacing: -0.1rem;
+      color: var(--theme-green);
+    }
+
+    & > h2 {
+      position: absolute;
+      left: 2.4rem;
+      right: 2.4rem;
+      bottom: 1rem;
+
+      font-size: 2.4rem;
+      font-weight: 700;
+      letter-spacing: -0.2rem;
+      color: var(--primary-text-color);
+      text-align: left;
+      text-transform: uppercase;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+      overflow: hidden;
+
+      & span {
+        font-weight: 400;
+      }
+    }
+  }
+
+  /* Special case for when we have only one server */
+  &.single-server {
+    & > .chosen {
+      cursor: default;
+      &:hover {
+        border-color: var(--server-selector-border-color);
+      }
+      & > div.chevron {
+        display: none;
+      }
+    }
+  }
+
+  /* Overrides for when the test is running and the selector is disabled */
+  &.disabled {
+    pointer-events: none;
+
+    & > .chosen {
+      cursor: default;
+      &:hover {
+        border-color: var(--server-selector-border-color);
+      }
+      & > p {
+        color: var(--secondary-text-disabled-color);
+      }
+      & > h2 {
+        color: var(--primary-text-disabled-color);
+      }
+    }
+  }
+
+  /* Styling for the list of servers that pops out */
+  & > ul.servers {
+    position: absolute;
+    width: 50rem;
+    max-height: 70vh;
+    overflow-y: auto;
+    z-index: 1;
+
+    border: 1px solid var(--server-selector-border-color);
+    border-radius: 0.8rem;
+    background-color: var(--server-selector-background-color);
+    list-style: none;
+
+    transform: scaleY(0);
+    transform-origin: top;
+    transition: transform 0.1s;
+
+    &.active {
+      transform: scaleY(1);
+    }
+
+    @media screen and (max-width: 800px) {
+      width: 100%;
+    }
+
+    & > li {
+      &:first-child a {
+        padding-top: 1.5rem;
+      }
+      &:last-child a {
+        padding-bottom: 1.5rem;
+      }
+
+      & a {
+        display: block;
+        padding: 0.7rem 2.4rem;
+
+        font-size: 2.4rem;
+        font-weight: 700;
+        letter-spacing: -0.2rem;
+        color: var(--sprint-text-color);
+        text-transform: uppercase;
+        text-decoration: none;
+        text-align: left;
+        cursor: pointer;
+
+        transition: background-color 0.2s;
+
+        & span {
+          font-weight: 400;
+        }
+        &:hover {
+          background-color: var(--server-selector-hover-background-color);
+        }
+      }
+    }
+  }
+
+  /* Styling for the sponsor text under the dropdown */
+  & > p.sponsor {
+    margin: 1rem 0 5rem 0;
+
+    & a {
+      font-weight: 400;
+    }
+  }
+}

+ 591 - 0
index-classic.html

@@ -0,0 +1,591 @@
+<!DOCTYPE html>
+<html>
+
+<head>
+	<link rel="shortcut icon" href="favicon.ico">
+	<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no" />
+	<meta charset="UTF-8" />
+	<script type="text/javascript" src="speedtest.js"></script>
+	<script type="text/javascript">
+		function I(i) { return document.getElementById(i); }
+
+		//LIST OF TEST SERVERS. Leave empty if you're doing a standalone installation. See documentation for details
+		var SPEEDTEST_SERVERS = [
+			/*{	//this server doesn't actually exist, remove it
+				name:"Example Server 1", //user friendly name for the server
+				server:"//test1.mydomain.com/", //URL to the server. // at the beginning will be replaced with http:// or https:// automatically
+				dlURL:"backend/garbage.php",  //path to download test on this server (garbage.php or replacement)
+				ulURL:"backend/empty.php",  //path to upload test on this server (empty.php or replacement)
+				pingURL:"backend/empty.php",  //path to ping/jitter test on this server (empty.php or replacement)
+				getIpURL:"backend/getIP.php"  //path to getIP on this server (getIP.php or replacement)
+			},
+			{	//this server doesn't actually exist, remove it
+				name:"Example Server 2", //user friendly name for the server
+				server:"//test2.example.com/", //URL to the server. // at the beginning will be replaced with http:// or https:// automatically
+				dlURL:"garbage.php",  //path to download test on this server (garbage.php or replacement)
+				ulURL:"empty.php",  //path to upload test on this server (empty.php or replacement)
+				pingURL:"empty.php",  //path to ping/jitter test on this server (empty.php or replacement)
+				getIpURL:"getIP.php"  //path to getIP on this server (getIP.php or replacement)
+			}*/
+			//add other servers here, comma separated
+		];
+
+		//INITIALIZE SPEEDTEST
+		var s = new Speedtest(); //create speed test object
+		s.setParameter("telemetry_level", "basic"); //enable basic telemetry (for results sharing)
+
+		//SERVER AUTO SELECTION
+		function initServers() {
+			var showStandaloneUI = function () {
+				I("loading").className = "hidden";
+				I("serverArea").style.display = "none";
+				I("testWrapper").className = "visible";
+				initUI();
+			}
+			var noServersAvailable = function () {
+				I("message").innerHTML = "No servers available";
+			}
+			var runServerSelect = function () {
+				s.selectServer(function (server) {
+					if (server != null) { //at least 1 server is available
+						I("loading").className = "hidden"; //hide loading message
+						//populate server list for manual selection
+						for (var i = 0; i < SPEEDTEST_SERVERS.length; i++) {
+							if (SPEEDTEST_SERVERS[i].pingT == -1) continue;
+							var option = document.createElement("option");
+							option.value = i;
+							option.textContent = SPEEDTEST_SERVERS[i].name;
+							if (SPEEDTEST_SERVERS[i] === server) option.selected = true;
+							I("server").appendChild(option);
+						}
+						//show test UI
+						I("testWrapper").className = "visible";
+						initUI();
+					} else { //no servers are available, the test cannot proceed
+						noServersAvailable();
+					}
+				});
+			}
+			var loadServerList = function (serverListUrl, onFailure) {
+				s.loadServerList(serverListUrl, function (servers) {
+					if (servers == null || servers.length == 0) {
+						onFailure();
+					} else {
+						SPEEDTEST_SERVERS = servers;
+						s.addTestPoints(SPEEDTEST_SERVERS);
+						runServerSelect();
+					}
+				});
+			}
+
+			if (typeof SPEEDTEST_SERVERS === "string") {
+				//need to fetch list of servers from specified URL
+				loadServerList(SPEEDTEST_SERVERS, noServersAvailable);
+			} else if (SPEEDTEST_SERVERS.length == 0) {
+				//standalone installation by default, but use server-list.json if present
+				loadServerList("server-list.json", showStandaloneUI);
+			} else { //multiple servers
+				//hardcoded server list
+				s.addTestPoints(SPEEDTEST_SERVERS);
+				runServerSelect();
+			}
+		}
+
+		var meterBk = /Trident.*rv:(\d+\.\d+)/i.test(navigator.userAgent) ? "#EAEAEA" : "#80808040";
+		var dlColor = "#6060AA",
+			ulColor = "#616161";
+		var progColor = meterBk;
+
+		//CODE FOR GAUGES
+		function drawMeter(c, amount, bk, fg, progress, prog) {
+			var ctx = c.getContext("2d");
+			var dp = window.devicePixelRatio || 1;
+			var cw = c.clientWidth * dp, ch = c.clientHeight * dp;
+			var sizScale = ch * 0.0055;
+			if (c.width == cw && c.height == ch) {
+				ctx.clearRect(0, 0, cw, ch);
+			} else {
+				c.width = cw;
+				c.height = ch;
+			}
+			ctx.beginPath();
+			ctx.strokeStyle = bk;
+			ctx.lineWidth = 12 * sizScale;
+			ctx.arc(c.width / 2, c.height - 58 * sizScale, c.height / 1.8 - ctx.lineWidth, -Math.PI * 1.1, Math.PI * 0.1);
+			ctx.stroke();
+			ctx.beginPath();
+			ctx.strokeStyle = fg;
+			ctx.lineWidth = 12 * sizScale;
+			ctx.arc(c.width / 2, c.height - 58 * sizScale, c.height / 1.8 - ctx.lineWidth, -Math.PI * 1.1, amount * Math.PI * 1.2 - Math.PI * 1.1);
+			ctx.stroke();
+			if (typeof progress !== "undefined") {
+				ctx.fillStyle = prog;
+				ctx.fillRect(c.width * 0.3, c.height - 16 * sizScale, c.width * 0.4 * progress, 4 * sizScale);
+			}
+		}
+		function mbpsToAmount(s) {
+			return 1 - (1 / (Math.pow(1.3, Math.sqrt(s))));
+		}
+		function format(d) {
+			d = Number(d);
+			if (d < 10) return d.toFixed(2);
+			if (d < 100) return d.toFixed(1);
+			return d.toFixed(0);
+		}
+
+		//UI CODE
+		var uiData = null;
+		function startStop() {
+			if (s.getState() == 3) {
+				//speed test is running, abort
+				s.abort();
+				data = null;
+				I("startStopBtn").className = "";
+				I("server").disabled = false;
+				initUI();
+			} else {
+				//test is not running, begin
+				I("startStopBtn").className = "running";
+				I("shareArea").style.display = "none";
+				I("server").disabled = true;
+				s.onupdate = function (data) {
+					uiData = data;
+				};
+				s.onend = function (aborted) {
+					I("startStopBtn").className = "";
+					I("server").disabled = false;
+					updateUI(true);
+					if (!aborted) {
+						//if testId is present, show sharing panel, otherwise do nothing
+						try {
+							var testId = uiData.testId;
+							if (testId != null) {
+								var shareURL = window.location.href.substring(0, window.location.href.lastIndexOf("/")) + "/results/?id=" + testId;
+								I("resultsImg").src = shareURL;
+								I("resultsURL").value = shareURL;
+								I("testId").innerHTML = testId;
+								I("shareArea").style.display = "";
+							}
+						} catch (e) { }
+					}
+				};
+				s.start();
+			}
+		}
+		//this function reads the data sent back by the test and updates the UI
+		function updateUI(forced) {
+			if (!forced && s.getState() != 3) return;
+			if (uiData == null) return;
+			var status = uiData.testState;
+			I("ip").textContent = uiData.clientIp;
+			I("dlText").textContent = (status == 1 && uiData.dlStatus == 0) ? "..." : format(uiData.dlStatus);
+			drawMeter(I("dlMeter"), mbpsToAmount(Number(uiData.dlStatus * (status == 1 ? oscillate() : 1))), meterBk, dlColor, Number(uiData.dlProgress), progColor);
+			I("ulText").textContent = (status == 3 && uiData.ulStatus == 0) ? "..." : format(uiData.ulStatus);
+			drawMeter(I("ulMeter"), mbpsToAmount(Number(uiData.ulStatus * (status == 3 ? oscillate() : 1))), meterBk, ulColor, Number(uiData.ulProgress), progColor);
+			I("pingText").textContent = format(uiData.pingStatus);
+			I("jitText").textContent = format(uiData.jitterStatus);
+		}
+		function oscillate() {
+			return 1 + 0.02 * Math.sin(Date.now() / 100);
+		}
+		//update the UI every frame
+		window.requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.msRequestAnimationFrame || (function (callback, element) { setTimeout(callback, 1000 / 60); });
+		function frame() {
+			requestAnimationFrame(frame);
+			updateUI();
+		}
+		frame(); //start frame loop
+		//function to (re)initialize UI
+		function initUI() {
+			drawMeter(I("dlMeter"), 0, meterBk, dlColor, 0);
+			drawMeter(I("ulMeter"), 0, meterBk, ulColor, 0);
+			I("dlText").textContent = "";
+			I("ulText").textContent = "";
+			I("pingText").textContent = "";
+			I("jitText").textContent = "";
+			I("ip").textContent = "";
+		}
+	</script>
+	<style type="text/css">
+		html,
+		body {
+			border: none;
+			padding: 0;
+			margin: 0;
+			background: #FFFFFF;
+			color: #202020;
+		}
+
+		body {
+			text-align: center;
+			font-family: "Roboto", sans-serif;
+		}
+
+		h1 {
+			color: #404040;
+		}
+
+		#loading {
+			background-color: #FFFFFF;
+			color: #404040;
+			text-align: center;
+		}
+
+		span.loadCircle {
+			display: inline-block;
+			width: 2em;
+			height: 2em;
+			vertical-align: middle;
+			background: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAP1BMVEUAAAB2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZyFzwnAAAAFHRSTlMAEvRFvX406baecwbf0casimhSHyiwmqgAAADpSURBVHja7dbJbQMxAENRahnN5lkc//5rDRAkDeRgHszXgACJoKiIiIiIiIiIiIiIiIiIiIj4HHspsrpAVhdVVguzrA4OWc10WcEqpwKbnBo0OU1Q5NSpsoJFTgOecrrdEag85DRgktNqfoEdTjnd7hrEHMEJvmRUYJbTYk5Agy6nau6Abp5Cm7mDBtRdPi9gyKdU7w4p1fsLvyqs8hl4z9/w3n/Hmr9WoQ65lAU4d7lMYOz//QboRR5jBZibLMZdAR6O/Vfa1PlxNr3XdS3HzK/HVPRu/KnLs8iAOh993VpRRERERMT/fAN60wwWaVyWwAAAAABJRU5ErkJggg==');
+			background-size: 2em 2em;
+			margin-right: 0.5em;
+			animation: spin 0.6s linear infinite;
+		}
+
+		@keyframes spin {
+			0% {
+				transform: rotate(0deg);
+			}
+
+			100% {
+				transform: rotate(359deg);
+			}
+		}
+
+		#startStopBtn {
+			display: inline-block;
+			margin: 0 auto;
+			color: #6060AA;
+			background-color: rgba(0, 0, 0, 0);
+			border: 0.15em solid #6060FF;
+			border-radius: 0.3em;
+			transition: all 0.3s;
+			box-sizing: border-box;
+			width: 8em;
+			height: 3em;
+			line-height: 2.7em;
+			cursor: pointer;
+			box-shadow: 0 0 0 rgba(0, 0, 0, 0.1), inset 0 0 0 rgba(0, 0, 0, 0.1);
+		}
+
+		#startStopBtn:hover {
+			box-shadow: 0 0 2em rgba(0, 0, 0, 0.1), inset 0 0 1em rgba(0, 0, 0, 0.1);
+		}
+
+		#startStopBtn.running {
+			background-color: #FF3030;
+			border-color: #FF6060;
+			color: #FFFFFF;
+		}
+
+		#startStopBtn:before {
+			content: "Start";
+		}
+
+		#startStopBtn.running:before {
+			content: "Abort";
+		}
+
+		#serverArea {
+			margin-top: 1em;
+		}
+
+		#server {
+			font-size: 1em;
+			padding: 0.2em;
+		}
+
+		#test {
+			margin-top: 2em;
+			margin-bottom: 12em;
+		}
+
+		div.testArea {
+			display: inline-block;
+			width: 16em;
+			height: 12.5em;
+			position: relative;
+			box-sizing: border-box;
+		}
+
+		div.testArea2 {
+			display: inline-block;
+			width: 14em;
+			height: 7em;
+			position: relative;
+			box-sizing: border-box;
+			text-align: center;
+		}
+
+		div.testArea div.testName {
+			position: absolute;
+			top: 0.1em;
+			left: 0;
+			width: 100%;
+			font-size: 1.4em;
+			z-index: 9;
+		}
+
+		div.testArea2 div.testName {
+			display: block;
+			text-align: center;
+			font-size: 1.4em;
+		}
+
+		div.testArea div.meterText {
+			position: absolute;
+			bottom: 1.55em;
+			left: 0;
+			width: 100%;
+			font-size: 2.5em;
+			z-index: 9;
+		}
+
+		div.testArea2 div.meterText {
+			display: inline-block;
+			font-size: 2.5em;
+		}
+
+		div.meterText:empty:before {
+			content: "0.00";
+		}
+
+		div.testArea div.unit {
+			position: absolute;
+			bottom: 2em;
+			left: 0;
+			width: 100%;
+			z-index: 9;
+		}
+
+		div.testArea2 div.unit {
+			display: inline-block;
+		}
+
+		div.testArea canvas {
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			z-index: 1;
+		}
+
+		div.testGroup {
+			display: block;
+			margin: 0 auto;
+		}
+
+		#shareArea {
+			width: 95%;
+			max-width: 40em;
+			margin: 0 auto;
+			margin-top: 2em;
+		}
+
+		#shareArea>* {
+			display: block;
+			width: 100%;
+			height: auto;
+			margin: 0.25em 0;
+		}
+
+		#privacyPolicy {
+			position: fixed;
+			top: 2em;
+			bottom: 2em;
+			left: 2em;
+			right: 2em;
+			overflow-y: auto;
+			width: auto;
+			height: auto;
+			box-shadow: 0 0 3em 1em #000000;
+			z-index: 999999;
+			text-align: left;
+			background-color: #FFFFFF;
+			padding: 1em;
+		}
+
+		a.privacy {
+			text-align: center;
+			font-size: 0.8em;
+			color: #808080;
+			padding: 0 3em;
+		}
+
+		div.closePrivacyPolicy {
+			width: 100%;
+			text-align: center;
+		}
+
+		div.closePrivacyPolicy a.privacy {
+			padding: 1em 3em;
+		}
+
+		@media all and (max-width:40em) {
+			body {
+				font-size: 0.8em;
+			}
+		}
+
+		div.visible {
+			animation: fadeIn 0.4s;
+			display: block;
+		}
+
+		div.hidden {
+			animation: fadeOut 0.4s;
+			display: none;
+		}
+
+		@keyframes fadeIn {
+			0% {
+				opacity: 0;
+			}
+
+			100% {
+				opacity: 1;
+			}
+		}
+
+		@keyframes fadeOut {
+			0% {
+				display: block;
+				opacity: 1;
+			}
+
+			100% {
+				display: block;
+				opacity: 0;
+			}
+		}
+
+		@media all and (prefers-color-scheme: dark) {
+
+			html,
+			body,
+			#loading {
+				background: #202020;
+				color: #F4F4F4;
+				color-scheme: dark;
+			}
+
+			h1 {
+				color: #E0E0E0;
+			}
+
+			a {
+				color: #9090FF;
+			}
+
+			#privacyPolicy {
+				background: #000000;
+			}
+
+			#resultsImg {
+				filter: invert(1);
+			}
+		}
+	</style>
+	<title>LibreSpeed</title>
+</head>
+
+<body onload="initServers()">
+	<h1>LibreSpeed</h1>
+	<div id="loading" class="visible">
+		<p id="message"><span class="loadCircle"></span>Selecting a server...</p>
+	</div>
+	<div id="testWrapper" class="hidden">
+		<div id="startStopBtn" onclick="startStop()"></div><br />
+		<a class="privacy" href="#" onclick="I('privacyPolicy').style.display=''">Privacy</a>
+		<div id="serverArea">
+			Server: <select id="server" onchange="s.setSelectedServer(SPEEDTEST_SERVERS[this.value])"></select>
+		</div>
+		<div id="test">
+			<div class="testGroup">
+				<div class="testArea2">
+					<div class="testName">Ping</div>
+					<div id="pingText" class="meterText" style="color:#AA6060"></div>
+					<div class="unit">ms</div>
+				</div>
+				<div class="testArea2">
+					<div class="testName">Jitter</div>
+					<div id="jitText" class="meterText" style="color:#AA6060"></div>
+					<div class="unit">ms</div>
+				</div>
+			</div>
+			<div class="testGroup">
+				<div class="testArea">
+					<div class="testName">Download</div>
+					<canvas id="dlMeter" class="meter"></canvas>
+					<div id="dlText" class="meterText"></div>
+					<div class="unit">Mbit/s</div>
+				</div>
+				<div class="testArea">
+					<div class="testName">Upload</div>
+					<canvas id="ulMeter" class="meter"></canvas>
+					<div id="ulText" class="meterText"></div>
+					<div class="unit">Mbit/s</div>
+				</div>
+			</div>
+			<div id="ipArea">
+				<span id="ip"></span>
+			</div>
+			<div id="shareArea" style="display:none">
+				<h3>Share results</h3>
+				<p>Test ID: <span id="testId"></span></p>
+				<input type="text" value="" id="resultsURL" readonly="readonly"
+					onclick="this.select();this.focus();this.select();document.execCommand('copy');alert('Link copied')" />
+				<img src="" id="resultsImg" />
+			</div>
+		</div>
+		<a href="index.html?design=new">Try the modern design</a><br>
+		<a href="https://github.com/librespeed/speedtest">Source code</a>
+	</div>
+	<div id="privacyPolicy" style="display:none">
+		<h2>Privacy Policy</h2>
+		<p>This HTML5 speed test server is configured with telemetry enabled.</p>
+		<h4>What data we collect</h4>
+		<p>
+			At the end of the test, the following data is collected and stored:
+		<ul>
+			<li>Test ID</li>
+			<li>Time of testing</li>
+			<li>Test results (download and upload speed, ping and jitter)</li>
+			<li>IP address</li>
+			<li>ISP information</li>
+			<li>Approximate location (inferred from IP address, not GPS)</li>
+			<li>User agent and browser locale</li>
+			<li>Test log (contains no personal information)</li>
+		</ul>
+		</p>
+		<h4>How we use the data</h4>
+		<p>
+			Data collected through this service is used to:
+		<ul>
+			<li>Allow sharing of test results (sharable image for forums, etc.)</li>
+			<li>To improve the service offered to you (for instance, to detect problems on our side)</li>
+		</ul>
+		No personal information is disclosed to third parties.
+		</p>
+		<h4>Your consent</h4>
+		<p>
+			By starting the test, you consent to the terms of this privacy policy.
+		</p>
+		<h4>Data removal</h4>
+		<p>
+			If you want to have your information deleted, you need to provide either the ID of the test or your IP
+			address. This is the only way to identify your data, without this information we won't be able to comply
+			with your request.<br /><br />
+			Contact this email address for all deletion requests: 
+			<a href="mailto:PUT@YOUR_EMAIL.HERE">TO BE FILLED BY DEVELOPER</a>.
+		</p>
+		<br /><br />
+		<div class="closePrivacyPolicy">
+			<a class="privacy" href="#" onclick="I('privacyPolicy').style.display='none'">Close</a>
+		</div>
+		<br />
+	</div>
+</body>
+
+</html>

+ 152 - 0
index-modern.html

@@ -0,0 +1,152 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta charset="UTF-8" />
+  <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no" />
+  <meta name="description"
+    content="Free and Open Source Speedtest. Run it right now in your browser, or self-host on a PHP, Golang, Rust or Node server. License: LGPL." />
+  <link rel="shortcut icon" href="images/favicon.svg" />
+  <script type="text/javascript" src="speedtest.js"></script>
+  <script type="text/javascript">
+    // Set this to a different URL to load the server list from another location.
+    var SPEEDTEST_SERVERS = "server-list.json";
+  </script>
+  <script type="text/javascript" src="javascript/index.js"></script>
+  <link rel="stylesheet" type="text/css" href="styling/index.css" />
+  <title>LibreSpeed - Free and Open Source Speedtest</title>
+</head>
+
+<body>
+  <header>
+    <img src="images/logo.svg" alt="LibreSpeed" />
+  </header>
+  <main>
+    <h1>Free and Open Source Speedtest.</h1>
+    <p class="tagline">No Flash, No Java, No Websockets, No Bullsh*t</p>
+
+    <div class="server-selector">
+      <div class="chosen">
+        <div class="chevron">
+          <img src="images/chevron.svg" alt="select..." />
+        </div>
+        <p>current server</p>
+        <h2 id="selected-server">searching nearest server...</h2>
+      </div>
+      <ul class="servers"></ul>
+      <p class="sponsor" id="sponsor">&nbsp;</p>
+    </div>
+
+    <p id="privacy-warning" class="hidden">
+      by clicking the start button you agree to our privacy policy<br />
+      <a href="#" id="choose-privacy">or choose your privacy options</a>
+    </p>
+    <button class="disabled" id="start-button"></button>
+
+    <div class="gauge-layout">
+      <div class="ping hidden">
+        <span class="label">Ping</span>:&nbsp;
+        <span class="value" id="ping">00</span>ms
+      </div>
+
+      <div class="gauge download" id="download-gauge">
+        <div class="progress"></div>
+        <div class="speed"></div>
+        <h1><span id="download-speed">00</span> Mbps</h1>
+        <h2>Download</h2>
+      </div>
+
+      <div class="gauge upload" id="upload-gauge">
+        <div class="progress"></div>
+        <div class="speed"></div>
+        <h1><span id="upload-speed">00</span> Mbps</h1>
+        <h2>Upload</h2>
+      </div>
+
+      <div class="jitter hidden">
+        <span class="label">Jitter</span>:&nbsp;
+        <span class="value" id="jitter">00</span>ms
+      </div>
+    </div>
+
+    <button class="small inverted hidden" id="share-results">
+      Share results
+    </button>
+  </main>
+  <footer>
+    <p class="source">
+      <a href="https://github.com/librespeed/speedtest">source code</a>
+    </p>
+  </footer>
+
+  <dialog id="share">
+    <div class="close-dialog">
+      <img src="images/close-button.svg" alt="Close" />
+    </div>
+    <img id="results" src="" alt="Test results in graphical form" />
+    <button id="copy-link">Copy link</button>
+  </dialog>
+
+  <dialog id="privacy">
+    <div class="close-dialog">
+      <img src="images/close-button.svg" alt="Close" />
+    </div>
+    <section>
+      <h1>Privacy Policy</h1>
+      <p>
+        This HTML5 speed test server is configured with telemetry enabled.
+      </p>
+
+      <h2>What data we collect</h2>
+      <p>
+        At the end of the test, the following data is collected and stored:
+      </p>
+
+      <ul>
+        <li>Test ID</li>
+        <li>Time of testing</li>
+        <li>Test results (download and upload speed, ping and jitter)</li>
+        <li>IP address</li>
+        <li>ISP information</li>
+        <li>Approximate location (inferred from IP address, not GPS)</li>
+        <li>User agent and browser locale</li>
+        <li>Test log (contains no personal information)</li>
+      </ul>
+
+      <h2>How we use the data</h2>
+      <p>Data collected through this service is used to:</p>
+
+      <ul>
+        <li>
+          Allow sharing of test results (sharable image for forums, etc.)
+        </li>
+        <li>
+          To improve the service offered to you (for instance, to detect
+          problems on our side)
+        </li>
+      </ul>
+
+      <p>No personal information is disclosed to third parties.</p>
+
+      <h2>Your consent</h2>
+      <p>
+        By starting the test, you consent to the terms of this privacy policy.
+      </p>
+
+      <h2>Data removal</h2>
+      <p>
+        If you want to have your information deleted, you need to provide
+        either the ID of the test or your IP address. This is the only way to
+        identify your data, without this information we won't be able to
+        comply with your request.
+      </p>
+      <p>
+        Contact this email address for all deletion requests:
+        <a href="mailto:PUT@YOUR_EMAIL.HERE">TO BE FILLED BY DEVELOPER</a>.
+      </p>
+    </section>
+    <button id="close-privacy">Close</button>
+  </dialog>
+</body>
+
+</html>

+ 11 - 513
index.html

@@ -1,518 +1,16 @@
 <!DOCTYPE html>
 <html>
-<head>
-<link rel="shortcut icon" href="favicon.ico">
-<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no" />
-<meta charset="UTF-8" />
-<script type="text/javascript" src="speedtest.js"></script>
-<script type="text/javascript">
-function I(i){return document.getElementById(i);}
-
-//LIST OF TEST SERVERS. Leave empty if you're doing a standalone installation. See documentation for details
-var SPEEDTEST_SERVERS=[
-	/*{	//this server doesn't actually exist, remove it
-		name:"Example Server 1", //user friendly name for the server
-		server:"//test1.mydomain.com/", //URL to the server. // at the beginning will be replaced with http:// or https:// automatically
-		dlURL:"backend/garbage.php",  //path to download test on this server (garbage.php or replacement)
-		ulURL:"backend/empty.php",  //path to upload test on this server (empty.php or replacement)
-		pingURL:"backend/empty.php",  //path to ping/jitter test on this server (empty.php or replacement)
-		getIpURL:"backend/getIP.php"  //path to getIP on this server (getIP.php or replacement)
-	},
-	{	//this server doesn't actually exist, remove it
-		name:"Example Server 2", //user friendly name for the server
-		server:"//test2.example.com/", //URL to the server. // at the beginning will be replaced with http:// or https:// automatically
-		dlURL:"garbage.php",  //path to download test on this server (garbage.php or replacement)
-		ulURL:"empty.php",  //path to upload test on this server (empty.php or replacement)
-		pingURL:"empty.php",  //path to ping/jitter test on this server (empty.php or replacement)
-		getIpURL:"getIP.php"  //path to getIP on this server (getIP.php or replacement)
-	}*/
-	//add other servers here, comma separated
-];
-
-//INITIALIZE SPEEDTEST
-var s=new Speedtest(); //create speed test object
-s.setParameter("telemetry_level","basic"); //enable basic telemetry (for results sharing)
-
-//SERVER AUTO SELECTION
-function initServers(){
-	if(SPEEDTEST_SERVERS.length==0){ //standalone installation
-		//just make the UI visible
-		I("loading").className="hidden";
-		I("serverArea").style.display="none";
-		I("testWrapper").className="visible";
-		initUI();
-	}else{ //multiple servers
-		var noServersAvailable=function(){
-			I("message").innerHTML="No servers available";
-		}
-		var runServerSelect=function(){
-			s.selectServer(function(server){
-				if(server!=null){ //at least 1 server is available
-					I("loading").className="hidden"; //hide loading message
-					//populate server list for manual selection
-					for(var i=0;i<SPEEDTEST_SERVERS.length;i++){
-						if(SPEEDTEST_SERVERS[i].pingT==-1) continue;
-						var option=document.createElement("option");
-						option.value=i;
-						option.textContent=SPEEDTEST_SERVERS[i].name;
-						if(SPEEDTEST_SERVERS[i]===server) option.selected=true;
-						I("server").appendChild(option);
-					}
-					//show test UI
-					I("testWrapper").className="visible";
-					initUI();
-				}else{ //no servers are available, the test cannot proceed
-					noServersAvailable();
-				}
-			});
-		}
-		if(typeof SPEEDTEST_SERVERS === "string"){
-			//need to fetch list of servers from specified URL
-			s.loadServerList(SPEEDTEST_SERVERS,function(servers){
-				if(servers==null){ //failed to load server list
-					noServersAvailable();
-				}else{ //server list loaded
-					SPEEDTEST_SERVERS=servers;
-					runServerSelect();
-				}
-			});
-		}else{
-			//hardcoded server list
-			s.addTestPoints(SPEEDTEST_SERVERS);
-			runServerSelect();
-		}
-	}
-}
-
-var meterBk=/Trident.*rv:(\d+\.\d+)/i.test(navigator.userAgent)?"#EAEAEA":"#80808040";
-var dlColor="#6060AA",
-	ulColor="#616161";
-var progColor=meterBk;
 
-//CODE FOR GAUGES
-function drawMeter(c,amount,bk,fg,progress,prog){
-	var ctx=c.getContext("2d");
-	var dp=window.devicePixelRatio||1;
-	var cw=c.clientWidth*dp, ch=c.clientHeight*dp;
-	var sizScale=ch*0.0055;
-	if(c.width==cw&&c.height==ch){
-		ctx.clearRect(0,0,cw,ch);
-	}else{
-		c.width=cw;
-		c.height=ch;
-	}
-	ctx.beginPath();
-	ctx.strokeStyle=bk;
-	ctx.lineWidth=12*sizScale;
-	ctx.arc(c.width/2,c.height-58*sizScale,c.height/1.8-ctx.lineWidth,-Math.PI*1.1,Math.PI*0.1);
-	ctx.stroke();
-	ctx.beginPath();
-	ctx.strokeStyle=fg;
-	ctx.lineWidth=12*sizScale;
-	ctx.arc(c.width/2,c.height-58*sizScale,c.height/1.8-ctx.lineWidth,-Math.PI*1.1,amount*Math.PI*1.2-Math.PI*1.1);
-	ctx.stroke();
-	if(typeof progress !== "undefined"){
-		ctx.fillStyle=prog;
-		ctx.fillRect(c.width*0.3,c.height-16*sizScale,c.width*0.4*progress,4*sizScale);
-	}
-}
-function mbpsToAmount(s){
-	return 1-(1/(Math.pow(1.3,Math.sqrt(s))));
-}
-function format(d){
-    d=Number(d);
-    if(d<10) return d.toFixed(2);
-    if(d<100) return d.toFixed(1);
-    return d.toFixed(0);
-}
-
-//UI CODE
-var uiData=null;
-function startStop(){
-    if(s.getState()==3){
-		//speed test is running, abort
-		s.abort();
-		data=null;
-		I("startStopBtn").className="";
-		I("server").disabled=false;
-		initUI();
-	}else{
-		//test is not running, begin
-		I("startStopBtn").className="running";
-		I("shareArea").style.display="none";
-		I("server").disabled=true;
-		s.onupdate=function(data){
-            uiData=data;
-		};
-		s.onend=function(aborted){
-            I("startStopBtn").className="";
-            I("server").disabled=false;
-            updateUI(true);
-            if(!aborted){
-                //if testId is present, show sharing panel, otherwise do nothing
-                try{
-                    var testId=uiData.testId;
-                    if(testId!=null){
-                        var shareURL=window.location.href.substring(0,window.location.href.lastIndexOf("/"))+"/results/?id="+testId;
-                        I("resultsImg").src=shareURL;
-                        I("resultsImg").alt="Download: "+uiData.dlStatus+" Mbps, Upload: "+uiData.ulStatus+" Mbps, Ping: "+uiData.pingStatus+" ms, Jitter: "+uiData.jitterStatus+" ms";
-                        I("resultsURL").value=shareURL;
-                        I("testId").innerHTML=testId;
-                        I("shareArea").style.display="";
-                    }
-                }catch(e){}
-            }
-		};
-		s.start();
-	}
-}
-//this function reads the data sent back by the test and updates the UI
-function updateUI(forced){
-	if(!forced&&s.getState()!=3) return;
-	if(uiData==null) return;
-	var status=uiData.testState;
-	I("ip").textContent=uiData.clientIp;
-	I("dlText").textContent=(status==1&&uiData.dlStatus==0)?"...":format(uiData.dlStatus);
-	drawMeter(I("dlMeter"),mbpsToAmount(Number(uiData.dlStatus*(status==1?oscillate():1))),meterBk,dlColor,Number(uiData.dlProgress),progColor);
-	I("ulText").textContent=(status==3&&uiData.ulStatus==0)?"...":format(uiData.ulStatus);
-	drawMeter(I("ulMeter"),mbpsToAmount(Number(uiData.ulStatus*(status==3?oscillate():1))),meterBk,ulColor,Number(uiData.ulProgress),progColor);
-	I("pingText").textContent=format(uiData.pingStatus);
-	I("jitText").textContent=format(uiData.jitterStatus);
-}
-function oscillate(){
-	return 1+0.02*Math.sin(Date.now()/100);
-}
-//update the UI every frame
-window.requestAnimationFrame=window.requestAnimationFrame||window.webkitRequestAnimationFrame||window.mozRequestAnimationFrame||window.msRequestAnimationFrame||(function(callback,element){setTimeout(callback,1000/60);});
-function frame(){
-	requestAnimationFrame(frame);
-	updateUI();
-}
-frame(); //start frame loop
-//function to (re)initialize UI
-function initUI(){
-	drawMeter(I("dlMeter"),0,meterBk,dlColor,0);
-	drawMeter(I("ulMeter"),0,meterBk,ulColor,0);
-	I("dlText").textContent="";
-	I("ulText").textContent="";
-	I("pingText").textContent="";
-	I("jitText").textContent="";
-	I("ip").textContent="";
-}
-</script>
-<style type="text/css">
-	html,body{
-		border:none; padding:0; margin:0;
-		background:#FFFFFF;
-		color:#202020;
-	}
-	body{
-		text-align:center;
-		font-family:"Roboto",sans-serif;
-	}
-	h1{
-		color:#404040;
-	}
-	#loading{
-		background-color:#FFFFFF;
-		color:#404040;
-		text-align:center;
-	}
-	span.loadCircle{
-		display:inline-block;
-		width:2em;
-		height:2em;
-		vertical-align:middle;
-		background:url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAAAP1BMVEUAAAB2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZ2dnZyFzwnAAAAFHRSTlMAEvRFvX406baecwbf0casimhSHyiwmqgAAADpSURBVHja7dbJbQMxAENRahnN5lkc//5rDRAkDeRgHszXgACJoKiIiIiIiIiIiIiIiIiIiIj4HHspsrpAVhdVVguzrA4OWc10WcEqpwKbnBo0OU1Q5NSpsoJFTgOecrrdEag85DRgktNqfoEdTjnd7hrEHMEJvmRUYJbTYk5Agy6nau6Abp5Cm7mDBtRdPi9gyKdU7w4p1fsLvyqs8hl4z9/w3n/Hmr9WoQ65lAU4d7lMYOz//QboRR5jBZibLMZdAR6O/Vfa1PlxNr3XdS3HzK/HVPRu/KnLs8iAOh993VpRRERERMT/fAN60wwWaVyWwAAAAABJRU5ErkJggg==');
-		background-size:2em 2em;
-		margin-right:0.5em;
-		animation: spin 0.6s linear infinite;
-	}
-	@keyframes spin{
-		0%{transform:rotate(0deg);}
-		100%{transform:rotate(359deg);}
-	}
-	#startStopBtn{
-		display:inline-block;
-		margin:0 auto;
-		color:#6060AA;
-		background-color:rgba(0,0,0,0);
-		border:0.15em solid #6060FF;
-		border-radius:0.3em;
-		transition:all 0.3s;
-		box-sizing:border-box;
-		width:8em; height:3em;
-		line-height:2.7em;
-		cursor:pointer;
-		box-shadow: 0 0 0 rgba(0,0,0,0.1), inset 0 0 0 rgba(0,0,0,0.1);
-	}
-	#startStopBtn:hover{
-		box-shadow: 0 0 2em rgba(0,0,0,0.1), inset 0 0 1em rgba(0,0,0,0.1);
-	}
-	#startStopBtn.running{
-		background-color:#FF3030;
-		border-color:#FF6060;
-		color:#FFFFFF;
-	}
-	#startStopBtn:before{
-		content:"Start";
-	}
-	#startStopBtn.running:before{
-		content:"Abort";
-	}
-	#serverArea{
-		margin-top:1em;
-	}
-	#server{
-		font-size:1em;
-		padding:0.2em;
-	}
-	#test{
-		margin-top:2em;
-		margin-bottom:12em;
-	}
-	div.testArea{
-		display:inline-block;
-		width:16em;
-		height:12.5em;
-		position:relative;
-		box-sizing:border-box;
-	}
-	div.testArea2{
-		display:inline-block;
-		width:14em;
-		height:7em;
-		position:relative;
-		box-sizing:border-box;
-		text-align:center;
-	}
-	div.testArea div.testName{
-		position:absolute;
-		top:0.1em; left:0;
-		width:100%;
-		font-size:1.4em;
-		z-index:9;
-	}
-	div.testArea2 div.testName{
-        display:block;
-        text-align:center;
-        font-size:1.4em;
-	}
-	div.testArea div.meterText{
-		position:absolute;
-		bottom:1.55em; left:0;
-		width:100%;
-		font-size:2.5em;
-		z-index:9;
-	}
-	div.testArea2 div.meterText{
-        display:inline-block;
-        font-size:2.5em;
-	}
-	div.meterText:empty:before{
-		content:"0.00";
-	}
-	div.testArea div.unit{
-		position:absolute;
-		bottom:2em; left:0;
-		width:100%;
-		z-index:9;
-	}
-	div.testArea2 div.unit{
-		display:inline-block;
-	}
-	div.testArea canvas{
-		position:absolute;
-		top:0; left:0; width:100%; height:100%;
-		z-index:1;
-	}
-	div.testGroup{
-		display:block;
-        margin: 0 auto;
-	}
-	#shareArea{
-		width:95%;
-		max-width:40em;
-		margin:0 auto;
-		margin-top:2em;
-	}
-	#shareArea > *{
-		display:block;
-		width:100%;
-		height:auto;
-		margin: 0.25em 0;
-	}
-	#privacyPolicy{
-        position:fixed;
-        top:2em;
-        bottom:2em;
-        left:2em;
-        right:2em;
-        overflow-y:auto;
-        width:auto;
-        height:auto;
-        box-shadow:0 0 3em 1em #000000;
-        z-index:999999;
-        text-align:left;
-        background-color:#FFFFFF;
-        padding:1em;
-	}
-	a.privacy{
-        text-align:center;
-        font-size:0.8em;
-        color:#808080;
-        padding: 0 3em;
-	}
-    div.closePrivacyPolicy {
-        width: 100%;
-        text-align: center;
-    }
-    div.closePrivacyPolicy a.privacy {
-        padding: 1em 3em;
-    }
-	@media all and (max-width:40em){
-		body{
-			font-size:0.8em;
-		}
-	}
-	div.visible{
-		animation: fadeIn 0.4s;
-		display:block;
-	}
-	div.hidden{
-		animation: fadeOut 0.4s;
-		display:none;
-	}
-	@keyframes fadeIn{
-		0%{
-			opacity:0;
-		}
-		100%{
-			opacity:1;
-		}
-	}
-	@keyframes fadeOut{
-		0%{
-			display:block;
-			opacity:1;
-		}
-		100%{
-			display:block;
-			opacity:0;
-		}
-	}
-	@media all and (prefers-color-scheme: dark){
-		html,body,#loading{
-			background:#202020;
-			color:#F4F4F4;
-			color-scheme:dark;
-		}
-		h1{
-			color:#E0E0E0;
-		}
-		a{
-			color:#9090FF;
-		}
-		#privacyPolicy{
-			background:#000000;
-		}
-		#resultsImg{
-			filter: invert(1);
-		}
-	}
-</style>
-<title>LibreSpeed</title>
+<head>
+    <link rel="shortcut icon" href="favicon.ico">
+    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no, user-scalable=no" />
+    <meta charset="UTF-8" />
+    <script type="text/javascript" src="design-switch.js"></script>
+    <title>LibreSpeed</title>
 </head>
-<body onload="initServers()">
-<h1>LibreSpeed</h1>
-<div id="loading" class="visible">
-	<p id="message"><span class="loadCircle"></span>Selecting a server...</p>
-</div>
-<div id="testWrapper" class="hidden">
-	<div id="startStopBtn" onclick="startStop()"></div><br/>
-	<a class="privacy" href="#" onclick="I('privacyPolicy').style.display=''">Privacy</a>
-	<div id="serverArea">
-		Server: <select id="server" onchange="s.setSelectedServer(SPEEDTEST_SERVERS[this.value])"></select>
-	</div>
-	<div id="test">
-		<div class="testGroup">
-            <div class="testArea2">
-				<div class="testName">Ping</div>
-				<div id="pingText" class="meterText" style="color:#AA6060"></div>
-				<div class="unit">ms</div>
-			</div>
-			<div class="testArea2">
-				<div class="testName">Jitter</div>
-				<div id="jitText" class="meterText" style="color:#AA6060"></div>
-				<div class="unit">ms</div>
-			</div>
-		</div>
-		<div class="testGroup">
-			<div class="testArea">
-				<div class="testName">Download</div>
-				<canvas id="dlMeter" class="meter"></canvas>
-				<div id="dlText" class="meterText"></div>
-				<div class="unit">Mbit/s</div>
-			</div>
-			<div class="testArea">
-				<div class="testName">Upload</div>
-				<canvas id="ulMeter" class="meter"></canvas>
-				<div id="ulText" class="meterText"></div>
-				<div class="unit">Mbit/s</div>
-			</div>
-		</div>
-		<div id="ipArea">
-			<span id="ip"></span>
-		</div>
-		<div id="shareArea" style="display:none">
-			<h3>Share results</h3>
-			<p>Test ID: <span id="testId"></span></p>
-			<input type="text" value="" id="resultsURL" readonly="readonly" onclick="this.select();this.focus();this.select();document.execCommand('copy');alert('Link copied')"/>
-			<img src="" id="resultsImg" />
-		</div>
-	</div>
-	<a href="https://github.com/librespeed/speedtest">Source code</a>
-</div>
-<div id="privacyPolicy" style="display:none">
-    <h2>Privacy Policy</h2>
-    <p>This HTML5 speed test server is configured with telemetry enabled.</p>
-    <h4>What data we collect</h4>
-    <p>
-        At the end of the test, the following data is collected and stored:
-        <ul>
-            <li>Test ID</li>
-            <li>Time of testing</li>
-            <li>Test results (download and upload speed, ping and jitter)</li>
-            <li>IP address</li>
-            <li>ISP information</li>
-            <li>Approximate location (inferred from IP address, not GPS)</li>
-            <li>User agent and browser locale</li>
-            <li>Test log (contains no personal information)</li>
-        </ul>
-    </p>
-    <h4>How we use the data</h4>
-    <p>
-        Data collected through this service is used to:
-        <ul>
-            <li>Allow sharing of test results (sharable image for forums, etc.)</li>
-            <li>To improve the service offered to you (for instance, to detect problems on our side)</li>
-        </ul>
-        No personal information is disclosed to third parties.
-    </p>
-    <h4>Your consent</h4>
-    <p>
-        By starting the test, you consent to the terms of this privacy policy.
-    </p>
-    <h4>Data removal</h4>
-    <p>
-        If you want to have your information deleted, you need to provide either the ID of the test or your IP address. This is the only way to identify your data, without this information we won't be able to comply with your request.<br/><br/>
-        Contact this email address for all deletion requests: <a href="mailto:PUT@YOUR_EMAIL.HERE">TO BE FILLED BY DEVELOPER</a>.
-    </p>
-    <br/><br/>
-    <div class="closePrivacyPolicy">
-        <a class="privacy" href="#" onclick="I('privacyPolicy').style.display='none'">Close</a>
-    </div>
-    <br/>
-</div>
+
+<body>
+    <p>Loading...</p>
 </body>
-</html>
+
+</html>

+ 1 - 1
package.json

@@ -1,6 +1,6 @@
 {
   "name": "librespeed-speedtest",
-  "version": "5.5.1",
+  "version": "6.0.0",
   "description": "LibreSpeed - A Free and Open Source speed test that you can host on your server(s)",
   "main": "speedtest.js",
   "scripts": {

+ 6 - 0
results/telemetry_mssql.sql

@@ -1,3 +1,9 @@
+CREATE DATABASE librespeed;
+GO
+
+USE librespeed;
+GO
+
 SET ANSI_NULLS ON
 GO
 

+ 1 - 1
speedtest.js

@@ -49,7 +49,7 @@ function Speedtest() {
   this._settings = {}; //settings for the speed test worker
   this._state = 0; //0=adding settings, 1=adding servers, 2=server selection done, 3=test running, 4=done
   console.log(
-    "LibreSpeed by Federico Dossena v5.5.1 - https://github.com/librespeed/speedtest"
+    "LibreSpeed by Federico Dossena v6.0.0pre1 - https://github.com/librespeed/speedtest"
   );
 }