Browse Source

Merge remote-tracking branch 'origin/master' into fix/worker-lifecycle-cleanup

# Conflicts:
#	package-lock.json

Co-authored-by: sstidl <12804296+sstidl@users.noreply.github.com>
copilot-swe-agent[bot] 3 hours ago
parent
commit
36e266cdfa

+ 6 - 0
.github/workflows/docker-publish.yml

@@ -8,6 +8,9 @@ name: Docker
 on:
   schedule:
     - cron: '30 20 * * Sun'
+  # Lets a maintainer rebuild and publish the images right after a security release of the
+  # php or alpine base image (Actions > Docker > Run workflow), instead of waiting for Sunday.
+  workflow_dispatch:
   push:
     branches: ["*"]
     # Publish semver tags as releases.
@@ -129,6 +132,9 @@ jobs:
           file: ${{ matrix.dockerfile }}
           platforms: linux/amd64,linux/arm64,linux/arm/v7
           push: ${{ github.event_name != 'pull_request' }}
+          # Always fetch the base image again, so a rebuild after a security release of php or alpine
+          # does not reuse a cached older base
+          pull: true
           tags: ${{ steps.meta.outputs.tags }}
           labels: ${{ steps.meta.outputs.labels }}
           cache-from: type=gha

+ 2 - 1
design-switch.js

@@ -37,7 +37,8 @@
     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);
+        // Bypass stale browser caches so Docker env changes are reflected immediately
+        xhr.open('GET', 'config.json?_=' + Date.now(), false);
         xhr.send(null);
 
         // Check for a successful response, but not 304 Not Modified, which can have an empty response body

+ 1 - 1
doc.md

@@ -1,7 +1,7 @@
 # LibreSpeed
 
 > by Federico Dossena
-> Version 6.2.1
+> Version 6.3.1
 > [https://github.com/librespeed/speedtest/](https://github.com/librespeed/speedtest/)
 
 ## Introduction

+ 3 - 1
docker/entrypoint.sh

@@ -91,6 +91,8 @@ if [[ "$MODE" == "frontend" || "$MODE" == "dual" ||  "$MODE" == "standalone" ]];
   if [ -f /servers.json ]; then
     echo "using mounted /servers.json for server-list.json"
     cp /servers.json /var/www/html/server-list.json
+    # default the frontend list to the mounted file so the classic design uses it too
+    SERVER_LIST_URL="${SERVER_LIST_URL:-server-list.json}"
   elif [ -n "$SERVER_LIST_URL" ]; then
     echo "no /servers.json found, relying on SERVER_LIST_URL"
   elif [ "$MODE" == "frontend" ]; then
@@ -99,7 +101,7 @@ if [[ "$MODE" == "frontend" || "$MODE" == "dual" ||  "$MODE" == "standalone" ]];
   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
+    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"

+ 6 - 0
docker/librespeed-php.ini

@@ -3,3 +3,9 @@
 ; empty.php can send its headers. 32M leaves modest headroom.
 
 post_max_size = 32M
+
+; LibreSpeed does not use the session upload progress feature, and PHP's advisory
+; for CVE-2026-109619 (GHSA-6wmf-4rv5-p3rp) names it as a way to reach the
+; vulnerable code path, recommending it be off if it is not needed. Updating PHP is
+; the real fix; this only removes a feature nobody here uses.
+session.upload_progress.enabled = 0

+ 42 - 0
frontend/javascript/index.js

@@ -16,6 +16,8 @@ const testState = {
   state: INITIALIZING,
   speedtest: null,
   servers: [],
+  initialGaugeScrollPending: false,
+  initialGaugeScrollScheduled: false,
   selectedServerDirty: false,
   testData: null,
   testDataDirty: false,
@@ -81,6 +83,7 @@ function startButtonClickHandler() {
     case READY:
     case FINISHED:
       testState.speedtest.start();
+      testState.initialGaugeScrollPending = true;
       testState.state = RUNNING;
       return;
     case RUNNING:
@@ -92,6 +95,30 @@ function startButtonClickHandler() {
   }
 }
 
+/**
+ * Scroll the initial download gauge into view on narrow viewports when starting a test
+ */
+function scrollInitialDownloadGaugeIntoView() {
+  if (!window.matchMedia("(max-width: 800px)").matches) {
+    return;
+  }
+
+  const downloadGauge = document.querySelector("#download-gauge");
+  if (!downloadGauge) {
+    return;
+  }
+
+  const { top, bottom } = downloadGauge.getBoundingClientRect();
+  if (top >= 0 && bottom <= window.innerHeight) {
+    return;
+  }
+
+  downloadGauge.scrollIntoView({
+    block: "center",
+    inline: "nearest",
+  });
+}
+
 /**
  * Event listener for clicks on the "Copy link" button in the modal
  */
@@ -349,6 +376,21 @@ function startRenderingLoop() {
       )
     );
 
+    if (
+      testState.state === RUNNING &&
+      testState.initialGaugeScrollPending &&
+      !testState.initialGaugeScrollScheduled
+    ) {
+      testState.initialGaugeScrollScheduled = true;
+      requestAnimationFrame(() => {
+        if (testState.state === RUNNING) {
+          scrollInitialDownloadGaugeIntoView();
+        }
+        testState.initialGaugeScrollPending = false;
+        testState.initialGaugeScrollScheduled = false;
+      });
+    }
+
     // Show ping and jitter if data is available
     pingAndJitter.forEach((e) =>
       e.classList.toggle(

+ 5 - 5
package-lock.json

@@ -1,12 +1,12 @@
 {
   "name": "librespeed-speedtest",
-  "version": "6.2.1",
+  "version": "6.3.1",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "librespeed-speedtest",
-      "version": "6.2.1",
+      "version": "6.3.1",
       "license": "LGPL-3.0-or-later",
       "devDependencies": {
         "@playwright/test": "^1.55.0",
@@ -260,9 +260,9 @@
       "license": "MIT"
     },
     "node_modules/brace-expansion": {
-      "version": "1.1.18",
-      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
-      "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
+      "version": "1.1.21",
+      "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz",
+      "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==",
       "dev": true,
       "license": "MIT",
       "dependencies": {

+ 1 - 1
package.json

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

+ 6 - 2
speedtest.js

@@ -52,7 +52,7 @@ function Speedtest() {
   this.updater = null;
   this._activeRun = null;
   console.log(
-    "LibreSpeed by Federico Dossena v6.2.1 - https://github.com/librespeed/speedtest"
+    "LibreSpeed by Federico Dossena v6.3.1 - https://github.com/librespeed/speedtest"
   );
 }
 
@@ -242,7 +242,11 @@ Speedtest.prototype = {
       const checkServer = function(server, done) {
         let i = 0;
         server.pingT = -1;
-        if (server.server.indexOf(location.protocol) == -1) done();
+        if (
+          location.protocol === "https:" &&
+          server.server.substring(0, 7).toLowerCase() === "http://"
+        )
+          done();
         else {
           const nextPing = function() {
             if (i++ == PINGS) {

+ 4 - 0
stability_worker.js

@@ -185,6 +185,10 @@ function doPing() {
         if (d > 0 && d < instspd) instspd = d;
       } catch (e) {
         // Performance API not available, use estimate
+      } finally {
+        try {
+          performance.clearResourceTimings();
+        } catch (e) {}
       }
     }
 

+ 29 - 0
tests/e2e/classic-server-list.spec.js

@@ -0,0 +1,29 @@
+const { test, expect } = require("@playwright/test");
+const { baseUrls } = require("./helpers/env");
+
+test.describe("Classic design with mounted server list", () => {
+  test("frontend classic page loads the mounted server-list.json", async ({ page, request }) => {
+    const html = await (await request.get(`${baseUrls.frontend}/index-classic.html`)).text();
+    expect(html).toMatch(/var SPEEDTEST_SERVERS = "server-list\.json";/);
+
+    const pageErrors = [];
+    page.on("pageerror", error => pageErrors.push(error.message));
+
+    // This fixture hostname only resolves inside Docker. Keep server selection
+    // deterministic without mocking the mounted server-list.json response.
+    await page.route("http://backend-testpoint:8080/empty.php*", route =>
+      route.fulfill({ status: 200, headers: { "Access-Control-Allow-Origin": "*" }, body: "" })
+    );
+
+    const listResponse = page.waitForResponse(response => response.url().endsWith("/server-list.json"));
+    await page.goto(`${baseUrls.frontend}/index-classic.html`);
+
+    const response = await listResponse;
+    expect(response.ok()).toBeTruthy();
+
+    // Verify the fetched config was applied to the classic server selector.
+    await expect(page.locator("#server option")).toHaveText(["Backend testpoint"], { timeout: 10_000 });
+    await expect(page.locator("#startStopBtn")).toBeVisible();
+    expect(pageErrors).toEqual([]);
+  });
+});

+ 17 - 0
tests/e2e/design-switch.spec.js

@@ -26,4 +26,21 @@ test.describe('Design switch behavior', () => {
     await expect(page).toHaveURL(/index-classic\.html\?design=old/);
     await expect(classicStartButton(page)).toBeVisible();
   });
+
+  test('config fetch bypasses browser cache', async ({ page }) => {
+    let configRequestUrl = '';
+
+    await page.route('**/config.json*', async (route) => {
+      configRequestUrl = route.request().url();
+      await route.fulfill({
+        status: 200,
+        contentType: 'application/json',
+        body: JSON.stringify({ useNewDesign: true }),
+      });
+    });
+
+    await page.goto(`${baseUrls.standalone}/index.html`);
+    await expect(page).toHaveURL(/index-modern\.html/);
+    expect(configRequestUrl).toMatch(/config\.json\?_=\d+/);
+  });
 });

+ 99 - 0
tests/e2e/mobile-gauge-visibility.spec.js

@@ -0,0 +1,99 @@
+const { test, expect } = require("@playwright/test");
+const { modernStartButton } = require("./helpers/ui");
+
+test.use({ viewport: { width: 390, height: 560 } });
+
+test.describe("Mobile gauge visibility", () => {
+  test("keeps the active gauge visible after pressing Start", async ({ page }) => {
+    await page.route("**/speedtest.js", async (route) => {
+      await route.fulfill({
+        contentType: "text/javascript; charset=utf-8",
+        body: `
+          class Speedtest {
+            constructor() {
+              this.selectedServer = null;
+              this.onupdate = null;
+              this.onend = null;
+            }
+
+            start() {
+              this.onupdate?.({
+                dlProgress: 0.1,
+                ulProgress: 0,
+                dlStatus: "123.45",
+                ulStatus: "0",
+                pingStatus: "10.5",
+                jitterStatus: "0.9",
+                testState: 1
+              });
+            }
+
+            abort() {
+              this.onend?.(true);
+            }
+
+            setParameter() {}
+            addTestPoints() {}
+            selectServer(callback) {
+              callback(this.selectedServer);
+            }
+            setSelectedServer(server) {
+              this.selectedServer = server;
+            }
+            getSelectedServer() {
+              return this.selectedServer;
+            }
+          }
+
+          window.Speedtest = Speedtest;
+        `,
+      });
+    });
+
+    await page.route("**/settings.json", async (route) => {
+      await route.fulfill({
+        contentType: "application/json; charset=utf-8",
+        body: JSON.stringify({ telemetry_level: "off" }),
+      });
+    });
+
+    await page.route("**/server-list.json", async (route) => {
+      await route.fulfill({
+        contentType: "application/json; charset=utf-8",
+        body: JSON.stringify([
+          {
+            name: "Test Server",
+            server: "http://127.0.0.1:18184/backend/",
+          },
+        ]),
+      });
+    });
+
+    await page.goto("http://127.0.0.1:18184/index-modern.html");
+
+    const startButton = modernStartButton(page);
+    const downloadGauge = page.locator("#download-gauge");
+
+    await expect(startButton).toHaveText("Let's start", { timeout: 10_000 });
+
+    await page.evaluate(() => window.scrollTo(0, 0));
+
+    const gaugeStartsBelowFold = await downloadGauge.evaluate((element) => {
+      const { top, bottom } = element.getBoundingClientRect();
+      return bottom > window.innerHeight || top < 0;
+    });
+    expect(gaugeStartsBelowFold).toBe(true);
+
+    await startButton.click();
+    await expect(startButton).toHaveText("Abort");
+
+    await expect
+      .poll(() =>
+        downloadGauge.evaluate((element) => {
+          const { top, bottom } = element.getBoundingClientRect();
+          return top >= 0 && bottom <= window.innerHeight;
+        })
+      )
+      .toBe(true);
+  });
+});

+ 113 - 0
tests/e2e/server-selection.spec.js

@@ -0,0 +1,113 @@
+const { test, expect } = require('@playwright/test');
+const fs = require('node:fs');
+const path = require('node:path');
+const vm = require('node:vm');
+
+const speedtestSource = fs.readFileSync(
+  path.resolve(__dirname, '../../speedtest.js'),
+  'utf8'
+);
+
+function createHarness(protocol, responders = {}) {
+  const requests = [];
+
+  function FakeXMLHttpRequest() {
+    this.responseText = '';
+    this.timeout = 0;
+  }
+
+  FakeXMLHttpRequest.prototype.open = function open(method, url) {
+    this._method = method;
+    this._url = url;
+  };
+
+  FakeXMLHttpRequest.prototype.send = function send() {
+    requests.push(this._url);
+    const baseUrl = this._url.replace(/[?&]cors=true$/, '');
+    const outcome = responders[baseUrl] || 'error';
+
+    if (outcome === 'success') {
+      if (typeof this.onload === 'function') {
+        this.onload();
+      }
+      return;
+    }
+
+    if (typeof this.onerror === 'function') {
+      this.onerror(new Error(`Request failed for ${baseUrl}`));
+    }
+  };
+
+  const context = {
+    console: { log() {} },
+    Date,
+    location: { protocol },
+    navigator: { userAgent: 'Playwright' },
+    performance: { getEntriesByName: () => [] },
+    XMLHttpRequest: FakeXMLHttpRequest
+  };
+
+  vm.createContext(context);
+  vm.runInContext(speedtestSource, context, { filename: 'speedtest.js' });
+
+  return { Speedtest: context.Speedtest, requests };
+}
+
+function createServer(server) {
+  return {
+    name: server,
+    server,
+    dlURL: 'garbage.php',
+    ulURL: 'empty.php',
+    pingURL: 'empty.php',
+    getIpURL: 'getIP.php'
+  };
+}
+
+function selectServer(speedtest) {
+  return new Promise((resolve) => {
+    speedtest.selectServer(resolve);
+  });
+}
+
+test.describe('Speedtest server selection protocol handling', () => {
+  test('allows HTTPS backends to be pinged from an HTTP frontend', async () => {
+    const secureServer = 'https://secure.example/';
+    const { Speedtest, requests } = createHarness('http:', {
+      [`${secureServer}empty.php`]: 'success'
+    });
+
+    const speedtest = new Speedtest();
+    speedtest.addTestPoint(createServer(secureServer));
+
+    const selectedServer = await selectServer(speedtest);
+
+    expect(selectedServer.server).toBe(secureServer);
+    expect(
+      requests.some((url) => url.startsWith(`${secureServer}empty.php`))
+    ).toBeTruthy();
+  });
+
+  test('still skips insecure HTTP backends from an HTTPS frontend', async () => {
+    const insecureServer = 'http://insecure.example/';
+    const secureServer = 'https://secure.example/';
+    const { Speedtest, requests } = createHarness('https:', {
+      [`${insecureServer}empty.php`]: 'success',
+      [`${secureServer}empty.php`]: 'success'
+    });
+
+    const speedtest = new Speedtest();
+    speedtest.addTestPoint(createServer(insecureServer));
+    speedtest.addTestPoint(createServer(secureServer));
+
+    const selectedServer = await selectServer(speedtest);
+
+    expect(selectedServer.server).toBe(secureServer);
+    expect(
+      requests.some((url) => url.startsWith(`${insecureServer}empty.php`))
+    ).toBeFalsy();
+    expect(
+      requests.some((url) => url.startsWith(`${secureServer}empty.php`))
+    ).toBeTruthy();
+  });
+});

+ 39 - 0
tests/e2e/stability.spec.js

@@ -1,8 +1,11 @@
 const fs = require("node:fs");
+const path = require("node:path");
 const { test, expect } = require("@playwright/test");
 const { baseUrls } = require("./helpers/env");
 const { stabilityStartButton } = require("./helpers/ui");
 
+const workerSource = fs.readFileSync(path.join(__dirname, "..", "..", "stability_worker.js"), "utf8");
+
 async function setShortDuration(page) {
   await page.evaluate(() => {
     const select = document.querySelector("#durationSelect");
@@ -122,4 +125,40 @@ test.describe("Stability test", () => {
     await expect(page.locator("#serverArea")).toBeVisible({ timeout: 10_000 });
     await expect(page.locator("#server option")).toContainText("Local dual backend", { timeout: 10_000 });
   });
+
+  test("clears resource timings after measuring a ping", async ({ page }) => {
+    await page.goto(`${baseUrls.standalone}/stability.html`);
+    await page.route(`${baseUrls.backend}/empty.php?cors=true&r=*`, route => route.fulfill({ status: 200, headers: { "Access-Control-Allow-Origin": "*" }, body: "" }));
+
+    await expect(
+      page.evaluate(
+        async ({ source, url }) => {
+          const instrumentedSource = `
+          const clearResourceTimings = performance.clearResourceTimings.bind(performance);
+          performance.clearResourceTimings = () => {
+            postMessage("resource timings cleared");
+            clearResourceTimings();
+          };
+          ${source}
+        `;
+          const worker = new Worker(URL.createObjectURL(new Blob([instrumentedSource], { type: "text/javascript" })));
+          try {
+            await new Promise((resolve, reject) => {
+              const timeout = setTimeout(() => reject(new Error("Resource timings were not cleared")), 10_000);
+              worker.onmessage = event => {
+                if (event.data === "resource timings cleared") {
+                  clearTimeout(timeout);
+                  resolve();
+                }
+              };
+              worker.postMessage(`start ${JSON.stringify({ url_ping: url, duration: 1, mpot: true })}`);
+            });
+          } finally {
+            worker.terminate();
+          }
+        },
+        { source: workerSource, url: `${baseUrls.backend}/empty.php` }
+      )
+    ).resolves.toBeUndefined();
+  });
 });