diff --git a/README.md b/README.md index fe9466d..d00ab97 100644 --- a/README.md +++ b/README.md @@ -272,8 +272,9 @@ Each registered driver can have an optional personal mobile link: 1. Open `Training` and select the driver. 2. Open `Goals and mobile profile`, then click `Enable mobile profile`. -3. Copy or open the generated link. -4. Use `Create new link` to revoke the previous URL, or `Disable profile` to turn sharing off. +3. For internet sharing, set the public base address, for example `https://racecore.example.org`, and click `Save address`. +4. Scan `Local QR code` on the same network as RaceCore, or `Public QR code` over the internet. The local QR is generated from the RaceCore server LAN address, so it also works when the admin UI was opened as `localhost`. +5. Use `Create new link` to revoke the previous URL, or `Disable profile` to turn sharing off. `Detect public IP` is an explicit suggestion only; use the FQDN when the TLS certificate is issued for the domain. The URL uses a 192-bit random bearer token rather than a driver ID. The same link provides a class selector with fully separate lap times and practice results for each linked class. The public API does not return transponder IDs, other driver names, club ranking, race setup or admin state. @@ -287,7 +288,7 @@ For internet publishing, proxy only these paths: /src/public_training.css ``` -Do not expose `/api/state` or the full admin application. Static serving is restricted to `/src` and `/docs`; `data/`, server scripts and bundled binaries are not public static files. +Do not expose `/api/state` or the full admin application. Ready-to-copy Teltonika files are in `scripts/teltonika/`; the init script recreates Nginx RAM-backed temporary directories at every boot and does not use `nginx-util`. Static serving is restricted to `/src` and `/docs`; `data/`, server scripts and bundled binaries are not public static files. Regression tests use Node's built-in test runner and require no extra test-framework dependency. The suite covers training profiles, race generation and timing, judging corrections, frontend/backend state synchronization, and an isolated SQLite API round-trip using a temporary data directory: diff --git a/README.sv.md b/README.sv.md index 4dffde7..6380de0 100644 --- a/README.sv.md +++ b/README.sv.md @@ -261,8 +261,9 @@ Varje registrerad förare kan få en valfri personlig mobillänk: 1. Öppna `Träning` och välj föraren. 2. Öppna `Mål och mobilprofil` och klicka sedan `Aktivera mobilprofil`. -3. Kopiera eller öppna länken som skapas. -4. Använd `Skapa ny länk` för att återkalla den gamla eller `Stäng av profil` för att stoppa delningen. +3. För internetdelning: ange publik basadress, exempelvis `https://racecore.example.org`, och klicka `Spara adress`. +4. Skanna `Lokal QR-kod` på samma nätverk som RaceCore, eller `Publik QR-kod` via internet. Den lokala QR-koden skapas från RaceCore-serverns LAN-IP och fungerar därför även om adminvyn öppnades som `localhost`. +5. Använd `Skapa ny länk` för att återkalla den gamla eller `Stäng av profil` för att stoppa delningen. Knappen `Hämta publik IP` är endast ett manuellt förslag; FQDN är rätt val när TLS-certifikatet använder domännamnet. Länken använder en slumpad 192-bitars bearer-nyckel i stället för förar-ID. Samma länk visar profilens klasser i ett klassval, med helt separata varvtider och träningsresultat. Det publika API:t lämnar inte ut transponder-ID, andra förares namn, klubbrankning, race setup eller admin-state. @@ -276,7 +277,7 @@ Vid extern publicering ska bara dessa paths proxas: /src/public_training.css ``` -Exponera inte `/api/state` eller hela admin-appen. Statisk servering är begränsad till `/src` och `/docs`; `data/`, serverscripts och bundlade binärer publiceras inte som statiska filer. +Exponera inte `/api/state` eller hela admin-appen. Färdiga Teltonika-filer finns i `scripts/teltonika/`; init-scriptet skapar Nginx temporära RAM-kataloger vid varje boot och använder inte `nginx-util`. Statisk servering är begränsad till `/src` och `/docs`; `data/`, serverscripts och bundlade binärer publiceras inte som statiska filer. Regressionstesterna använder Node:s inbyggda testverktyg och kräver inget extra testramverk. Sviten täcker träningsprofiler, racegenerering och tidtagning, domarkorrigeringar, frontend/backend-synk samt ett isolerat SQLite API-test med en temporär datamapp: diff --git a/docs/javascript-architecture.md b/docs/javascript-architecture.md index 2046e3a..cd6ea32 100644 --- a/docs/javascript-architecture.md +++ b/docs/javascript-architecture.md @@ -135,7 +135,7 @@ Owns: - file watching for app version - AMMC config + process state - public overlay route serving via same frontend shell -- tokenized public driver training profile API and standalone mobile shell +- tokenized public driver training profile API, locally generated QR SVG, configurable public base URL, and standalone mobile shell - PDF export endpoint Delegates public training token validation and response shaping to `server/public_training_profile.js`; `server.js` does not duplicate those rules. @@ -143,6 +143,7 @@ Delegates public training token validation and response shaping to `server/publi Main API groups: - `/api/health` - `/api/app-version` +- `/api/network/public-address` (explicit UI action only; never polled) - `/api/backups` (`GET` list, `POST` create only) - `/api/state` - `/api/passings` @@ -613,7 +614,7 @@ Loads: - `/src/public_training.css` - `/src/public_training.js` -Served only after `server.js` validates the enabled share token in `/public-training/:shareToken`. +Served only after `server.js` validates the enabled share token in `/public-training/:shareToken`. QR images use `/public-training/qr/:shareToken` and validate the same token. `baseUrl=local` prefers a private 192.168.x.x or 10.x.x.x RaceCore server address and otherwise uses the first LAN address; an explicit HTTP(S) `baseUrl` is normalized to its origin for the public QR. The saved public value is `settings.publicBaseUrl`. ### `src/public_training.js` Type: diff --git a/package-lock.json b/package-lock.json index 832e7f0..d89d51f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "hasInstallScript": true, "dependencies": { "better-sqlite3": "^12.11.1", - "express": "^4.21.2" + "express": "5.2.1", + "qrcode": "^1.5.4" }, "engines": { "node": ">=22 <27", @@ -18,23 +19,41 @@ } }, "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { "node": ">= 0.6" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } }, "node_modules/base64-js": { "version": "1.5.1", @@ -91,27 +110,40 @@ } }, "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/buffer": { @@ -176,22 +208,61 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", "license": "ISC" }, - "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "license": "MIT", "dependencies": { - "safe-buffer": "5.2.1" + "color-name": "~1.1.4" }, "engines": { - "node": ">= 0.6" + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -204,27 +275,47 @@ } }, "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "ms": "2.0.0" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" } }, "node_modules/decompress-response": { @@ -260,16 +351,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -279,6 +360,12 @@ "node": ">=8" } }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -299,6 +386,12 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", @@ -372,45 +465,42 @@ } }, "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { - "node": ">= 0.10.0" + "node": ">= 18" }, "funding": { "type": "opencollective", @@ -424,21 +514,37 @@ "license": "MIT" }, "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/forwarded": { @@ -451,12 +557,12 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/fs-constants": { @@ -474,6 +580,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -574,15 +689,19 @@ } }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ieee754": { @@ -626,6 +745,33 @@ "node": ">= 0.10" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -636,63 +782,53 @@ } }, "node_modules/media-typer": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", - "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/methods": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", - "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/mimic-response": { @@ -723,9 +859,9 @@ "license": "MIT" }, "node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, "node_modules/napi-build-utils": { @@ -735,12 +871,32 @@ "license": "MIT" }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/node-abi": { @@ -788,6 +944,42 @@ "wrappy": "1" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -797,11 +989,33 @@ "node": ">= 0.8" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } }, "node_modules/prebuild-install": { "version": "7.1.3", @@ -853,10 +1067,27 @@ "once": "^1.3.1" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/qs": { - "version": "6.15.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", - "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", "license": "BSD-3-Clause", "dependencies": { "es-define-property": "^1.0.1", @@ -870,27 +1101,31 @@ } }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", + "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/rc": { @@ -922,6 +1157,37 @@ "node": ">= 6" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -961,50 +1227,56 @@ } }, "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "node_modules/send/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -1146,6 +1418,32 @@ "safe-buffer": "~5.2.0" } }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", @@ -1205,16 +1503,34 @@ } }, "node_modules/type-is": { - "version": "1.6.18", - "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", - "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", "license": "MIT", "dependencies": { - "media-typer": "0.3.0", - "mime-types": "~2.1.24" + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.6" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/unpipe": { @@ -1232,15 +1548,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -1250,11 +1557,72 @@ "node": ">= 0.8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" + }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/package.json b/package.json index de7af82..8b024ce 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "better-sqlite3": "^12.11.1", - "express": "^4.21.2" + "express": "5.2.1", + "qrcode": "^1.5.4" } } diff --git a/scripts/teltonika/README.md b/scripts/teltonika/README.md new file mode 100644 index 0000000..96b9f36 --- /dev/null +++ b/scripts/teltonika/README.md @@ -0,0 +1,61 @@ +# Teltonika/OpenWrt public training proxy + +These files are an example, not site-specific configuration. The proxy exposes only +RaceCore public training profiles and their two frontend assets. + +## Configure + +Edit `nginx.conf` before copying it: + +- replace `racecore.example.org` with the public FQDN +- replace `192.0.2.10:8081` with the RaceCore host and port on the LAN +- replace the certificate paths if the router does not use `/etc/uhttpd.crt` and `/etc/uhttpd.key` +- keep the three restricted locations and `proxy_set_header Origin ""` + +The default Teltonika certificate encrypts traffic but normally does not match a +public FQDN. Install a certificate issued for the configured FQDN to remove browser +warnings. + +## Install + +```sh +scp scripts/teltonika/nginx.conf root@ROUTER_IP:/etc/nginx/nginx.conf +scp scripts/teltonika/racecore-nginx.init root@ROUTER_IP:/etc/init.d/racecore-nginx +ssh root@ROUTER_IP +chmod +x /etc/init.d/racecore-nginx +/etc/init.d/nginx disable 2>/dev/null +/etc/init.d/racecore-nginx enable +/etc/init.d/racecore-nginx start +``` + +The custom init script is useful when Teltonika's bundled `nginx-util` is missing. +It recreates all RAM-backed Nginx temporary directories under `/tmp` on every boot, +validates the config, and starts Nginx under `procd`. + +If Nginx was started manually, stop that process before starting the service: + +```sh +nginx -s quit -c /etc/nginx/nginx.conf +/etc/init.d/racecore-nginx start +``` + +## Verify + +```sh +nginx -t -c /etc/nginx/nginx.conf +/etc/init.d/racecore-nginx running +netstat -lntp | grep ':443 ' +logread -e racecore-nginx +``` + +A standard Nginx, Caddy, Traefik, Apache, or cloud reverse proxy can provide the +same result. It only needs to expose: + +```text +/public-training/* +/src/public_training.js +/src/public_training.css +``` + +Never expose `/api/state` or the RaceCore administration UI unless separate +authentication and network controls have been added. diff --git a/scripts/teltonika/nginx.conf b/scripts/teltonika/nginx.conf new file mode 100644 index 0000000..199094d --- /dev/null +++ b/scripts/teltonika/nginx.conf @@ -0,0 +1,89 @@ +# RaceCore public-training reverse proxy template for Teltonika/OpenWrt. +# Replace racecore.example.org, 192.0.2.10:8081 and certificate paths. +user root; +worker_processes 1; +pid /var/run/racecore-nginx.pid; +error_log /tmp/racecore-nginx-error.log warn; + +events { worker_connections 256; } + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + client_body_temp_path /tmp/nginx/client_body; + proxy_temp_path /tmp/nginx/proxy; + fastcgi_temp_path /tmp/nginx/fastcgi; + uwsgi_temp_path /tmp/nginx/uwsgi; + scgi_temp_path /tmp/nginx/scgi; + access_log off; + sendfile on; + keepalive_timeout 30; + server_tokens off; + + upstream racecore_backend { + server 192.0.2.10:8081; + keepalive 4; + } + + server { + listen 443 ssl; + server_name racecore.example.org; + + ssl_certificate /etc/uhttpd.crt; + ssl_certificate_key /etc/uhttpd.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers on; + ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA256'; + + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "no-referrer" always; + add_header X-Frame-Options "SAMEORIGIN" always; + + location ^~ /public-training/ { + limit_except GET HEAD { deny all; } + proxy_pass http://racecore_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + # RaceCore normally sees an http backend while the browser uses https. + # Removing browser Origin avoids a false same-origin CORS rejection. + proxy_set_header Origin ""; + proxy_set_header Referer ""; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 3s; + proxy_read_timeout 30s; + proxy_send_timeout 30s; + proxy_buffering off; + add_header Cache-Control "no-store" always; + } + + location = /src/public_training.js { + limit_except GET HEAD { deny all; } + proxy_pass http://racecore_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header Origin ""; + proxy_set_header Referer ""; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "no-store" always; + } + + location = /src/public_training.css { + limit_except GET HEAD { deny all; } + proxy_pass http://racecore_backend; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_set_header Host $host; + proxy_set_header Origin ""; + proxy_set_header Referer ""; + proxy_set_header X-Forwarded-Proto $scheme; + add_header Cache-Control "no-store" always; + } + + location / { return 404; } + } +} diff --git a/scripts/teltonika/racecore-nginx.init b/scripts/teltonika/racecore-nginx.init new file mode 100755 index 0000000..52f3ff7 --- /dev/null +++ b/scripts/teltonika/racecore-nginx.init @@ -0,0 +1,30 @@ +#!/bin/sh /etc/rc.common + +START=95 +STOP=10 +USE_PROCD=1 + +NGINX_BIN="/usr/sbin/nginx" +NGINX_CONF="/etc/nginx/nginx.conf" + +prepare_runtime() { + mkdir -p /tmp/nginx/client_body /tmp/nginx/proxy /tmp/nginx/fastcgi /tmp/nginx/uwsgi /tmp/nginx/scgi +} + +start_service() { + prepare_runtime + "$NGINX_BIN" -t -c "$NGINX_CONF" || return 1 + procd_open_instance + procd_set_param command "$NGINX_BIN" -c "$NGINX_CONF" -g "daemon off;" + procd_set_param respawn 3600 5 5 + procd_set_param file "$NGINX_CONF" + procd_set_param stdout 1 + procd_set_param stderr 1 + procd_close_instance +} + +reload_service() { + prepare_runtime + "$NGINX_BIN" -t -c "$NGINX_CONF" || return 1 + "$NGINX_BIN" -s reload -c "$NGINX_CONF" +} diff --git a/server.js b/server.js index 2a6e5b5..1f83781 100644 --- a/server.js +++ b/server.js @@ -1,8 +1,10 @@ const fs = require("fs"); const path = require("path"); const os = require("os"); +const net = require("net"); const { spawn } = require("child_process"); const express = require("express"); +const QRCode = require("qrcode"); const Database = require("better-sqlite3"); const publicTrainingProfile = require("./server/public_training_profile"); const { createBackupService } = require("./server/backup_service"); @@ -101,6 +103,19 @@ app.get("/api/app-version", (_req, res) => { res.json(appVersion); }); +app.get("/api/network/public-address", async (_req, res) => { + try { + const response = await fetch("https://api.ipify.org?format=json", { signal: AbortSignal.timeout(5000) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const address = String((await response.json())?.ip || "").trim(); + if (!net.isIP(address)) throw new Error("Invalid public address response"); + res.setHeader("Cache-Control", "no-store"); + res.json({ address, suggestedBaseUrl: `https://${address}` }); + } catch (error) { + res.status(502).json({ error: error instanceof Error ? error.message : String(error) }); + } +}); + app.get("/api/backups", (_req, res) => { try { res.setHeader("Cache-Control", "no-store"); @@ -356,6 +371,38 @@ app.get("/public-training/api/:shareToken", (req, res) => { } }); +app.get("/public-training/qr/:shareToken", async (req, res) => { + try { + const stored = readStoredAppState(); + const token = String(req.params.shareToken || ""); + if (!stored || !publicTrainingProfile.hasPublicTrainingShare(stored.state, token)) { + res.status(404).type("text/plain").send("Profile not found"); + return; + } + const requestedBase = String(req.query.baseUrl || "").trim(); + const configuredBase = String(stored.state?.settings?.publicBaseUrl || "").trim(); + const requestBase = `${req.protocol}://${req.get("host")}`; + const lanUrls = getLanUrls(PORT); + const localBase = lanUrls.find((url) => /^http:\/\/(?:192\.168\.|10\.)/.test(url)) || lanUrls[0] || requestBase; + let baseUrl; + try { + const parsed = new URL(requestedBase === "local" ? localBase : requestedBase || configuredBase || requestBase); + if (!["http:", "https:"].includes(parsed.protocol)) throw new Error("Unsupported protocol"); + baseUrl = parsed.origin; + } catch { + baseUrl = `${req.protocol}://${req.get("host")}`; + } + const language = req.query.lang === "en" ? "en" : "sv"; + const profileUrl = new URL(`/public-training/${encodeURIComponent(token)}`, baseUrl); + profileUrl.searchParams.set("lang", language); + const svg = await QRCode.toString(profileUrl.toString(), { type: "svg", errorCorrectionLevel: "M", margin: 2, width: 320 }); + res.setHeader("Cache-Control", "no-store"); + res.type("image/svg+xml").send(svg); + } catch (error) { + res.status(500).type("text/plain").send(error instanceof Error ? error.message : String(error)); + } +}); + app.get("/public-training/:shareToken", (req, res) => { try { const stored = readStoredAppState(); @@ -389,7 +436,7 @@ app.use( app.use("/docs", express.static(path.join(__dirname, "docs"))); -app.get("*", (_req, res) => { +app.use((_req, res) => { res.sendFile(path.join(__dirname, "index.html")); }); diff --git a/src/app.js b/src/app.js index bd462d5..d9368a9 100644 --- a/src/app.js +++ b/src/app.js @@ -570,6 +570,18 @@ const TRANSLATIONS = { "training.public_profile_help": "Skapa en skrivskyddad mobilvy med den här förarens träningsstatistik, mål, belöningar och valbara träningstimer.", "training.public_profile_private": "Länken innehåller en unik hemlig nyckel. Den visar inte transponder-ID, övriga förare eller adminfunktioner.", "training.public_profile_link": "Personlig delningslänk", + "training.public_base_url": "Publik adress eller FQDN", + "training.public_base_url_hint": "Exempel: https://jmkrb.myddns.rocks. Lämna tomt för adressen där RaceCore öppnades.", + "training.save_public_url": "Spara adress", + "training.detect_public_ip": "Hämta publik IP", + "training.detecting_public_ip": "Kontrollerar publik IP...", + "training.detected_public_ip": "Publik IP hittades: {address}. Använd FQDN om certifikatet har ett domännamn och spara.", + "training.detect_public_ip_failed": "Kunde inte hämta publik IP. Fyll i FQDN manuellt.", + "training.public_url_invalid": "Ange en komplett http- eller https-adress.", + "training.local_profile_qr": "Lokal QR-kod", + "training.local_profile_qr_hint": "RaceCore-serverns LAN-adress", + "training.public_profile_qr": "Publik QR-kod", + "training.public_profile_qr_hint": "Lokal QR fungerar på samma nätverk. Publik QR använder sparad FQDN eller publik IP.", "training.copy_link": "Kopiera länk", "training.open_profile": "Öppna profil", "training.replace_link": "Skapa ny länk", @@ -1904,6 +1916,18 @@ const TRANSLATIONS = { "training.public_profile_help": "Create a read-only mobile view with this driver's practice statistics, goals, rewards and configurable practice timer.", "training.public_profile_private": "The link contains a unique secret key. It does not show transponder IDs, other drivers or admin controls.", "training.public_profile_link": "Personal share link", + "training.public_base_url": "Public address or FQDN", + "training.public_base_url_hint": "Example: https://training.example.org. Leave empty to use the address where RaceCore was opened.", + "training.save_public_url": "Save address", + "training.detect_public_ip": "Detect public IP", + "training.detecting_public_ip": "Detecting public IP...", + "training.detected_public_ip": "Public IP found: {address}. Use the FQDN when the certificate has a domain name, then save.", + "training.detect_public_ip_failed": "Could not detect the public IP. Enter the FQDN manually.", + "training.public_url_invalid": "Enter a complete http or https address.", + "training.local_profile_qr": "Local QR code", + "training.local_profile_qr_hint": "RaceCore server LAN address", + "training.public_profile_qr": "Public QR code", + "training.public_profile_qr_hint": "The local QR works on the same network. The public QR uses the saved FQDN or public IP.", "training.copy_link": "Copy link", "training.open_profile": "Open profile", "training.replace_link": "Create new link", @@ -3357,6 +3381,8 @@ function seedDefaultData() { state.settings.backendUrl = getDefaultBackendUrl(); } + state.settings.publicBaseUrl = String(state.settings.publicBaseUrl || "").trim().replace(/\/$/, ""); + if (!state.settings.language) { state.settings.language = DEFAULT_LANGUAGE; } @@ -3474,6 +3500,7 @@ function loadState() { pitEntryWsUrl: String(parsed.settings?.pitEntryWsUrl || "ws://127.0.0.1:9002"), pitExitWsUrl: String(parsed.settings?.pitExitWsUrl || "ws://127.0.0.1:9003"), backendUrl: parsed.settings?.backendUrl || getDefaultBackendUrl(), + publicBaseUrl: String(parsed.settings?.publicBaseUrl || ""), language: parsed.settings?.language || DEFAULT_LANGUAGE, theme: AVAILABLE_THEMES.includes(String(parsed.settings?.theme || "").toLowerCase()) ? String(parsed.settings.theme).toLowerCase() : DEFAULT_THEME, autoReconnect: parsed.settings?.autoReconnect !== false, @@ -3540,6 +3567,7 @@ function loadState() { pitEntryWsUrl: "ws://127.0.0.1:9002", pitExitWsUrl: "ws://127.0.0.1:9003", backendUrl: getDefaultBackendUrl(), + publicBaseUrl: "", language: DEFAULT_LANGUAGE, theme: DEFAULT_THEME, autoReconnect: true, diff --git a/src/runtime_services.js b/src/runtime_services.js index 8f88939..6c17ede 100644 --- a/src/runtime_services.js +++ b/src/runtime_services.js @@ -119,6 +119,7 @@ export function applyPersistedStateHelper(persisted, deps) { pitEntryWsUrl: String(persisted.settings?.pitEntryWsUrl || state.settings.pitEntryWsUrl || "ws://127.0.0.1:9002"), pitExitWsUrl: String(persisted.settings?.pitExitWsUrl || state.settings.pitExitWsUrl || "ws://127.0.0.1:9003"), backendUrl: persisted.settings?.backendUrl || state.settings.backendUrl || getDefaultBackendUrl(), + publicBaseUrl: String(persisted.settings?.publicBaseUrl || state.settings.publicBaseUrl || ""), language: persisted.settings?.language || state.settings.language || DEFAULT_LANGUAGE, theme: AVAILABLE_THEMES.includes(String(persisted.settings?.theme || state.settings.theme || DEFAULT_THEME).toLowerCase()) ? String(persisted.settings?.theme || state.settings.theme || DEFAULT_THEME).toLowerCase() : DEFAULT_THEME, autoReconnect: persisted.settings?.autoReconnect !== false, diff --git a/src/styles.css b/src/styles.css index fabbd20..6665ce5 100644 --- a/src/styles.css +++ b/src/styles.css @@ -3937,3 +3937,9 @@ select:focus { .backup-row { grid-template-columns: 1fr auto; } .backup-row > span:first-of-type { display: none; } } + +.training-profile-qr-list { display: flex; flex-wrap: wrap; gap: 1rem; align-items: start; margin: 0.8rem 0; } +.training-profile-qr { display: grid; justify-items: start; gap: 0.45rem; max-width: 220px; } +.training-profile-qr img { width: min(220px, 70vw); aspect-ratio: 1; padding: 0.5rem; border-radius: 12px; background: #fff; } +.training-profile-qr small { color: var(--muted); overflow-wrap: anywhere; } +.training-profile-qr-hint { display: block; color: var(--muted); margin-bottom: 0.8rem; } diff --git a/src/training_views.js b/src/training_views.js index ab62bc3..6f66738 100644 --- a/src/training_views.js +++ b/src/training_views.js @@ -114,7 +114,14 @@ export function renderTrainingView(deps) { && (!goal.classId || String(goal.classId) === selectedClassId) )); const profileShare = (state.training.shares || []).find((share) => profileReferences.has(String(share.driverId || "")) && share.enabled); - const publicProfileUrl = profileShare ? buildPublicProfileUrl(profileShare.token, state.settings?.language) : ""; + const publicBaseUrl = normalizePublicBaseUrl(state.settings?.publicBaseUrl); + const localBaseUrl = window.location.origin; + const publicProfileUrl = profileShare ? buildPublicProfileUrl(profileShare.token, state.settings?.language, publicBaseUrl || localBaseUrl) : ""; + const qrLanguage = state.settings?.language === "en" ? "en" : "sv"; + const localQrUrl = profileShare ? buildPublicProfileQrUrl(profileShare.token, qrLanguage, "local") : ""; + const publicQrUrl = profileShare && publicBaseUrl && publicBaseUrl !== localBaseUrl + ? buildPublicProfileQrUrl(profileShare.token, qrLanguage, publicBaseUrl) + : ""; const driverOptions = `` + profiles .map((profile) => { const classNames = profile.classIds @@ -439,11 +446,36 @@ export function renderTrainingView(deps) {

${escapeHtml(t("training.public_profile_help"))}

+ +
+ + +
+

${profileShare ? ` +
+
+ ${escapeHtml(t("training.local_profile_qr"))} + ${escapeHtml(t( + ${escapeHtml(t("training.local_profile_qr_hint"))} +
+ ${publicQrUrl ? ` +
+ ${escapeHtml(t("training.public_profile_qr"))} + ${escapeHtml(t( + ${escapeHtml(new URL(publicProfileUrl).host)} +
+ ` : ""} +
+ ${escapeHtml(t("training.public_profile_qr_hint"))}
@@ -668,6 +700,31 @@ export function renderTrainingView(deps) { }); }); + document.getElementById("trainingPublicBaseSave")?.addEventListener("click", () => { + const input = document.getElementById("trainingPublicBaseUrl"); + const normalized = normalizePublicBaseUrl(input?.value); + if (input?.value && !normalized) { + document.getElementById("trainingPublicAddressStatus").textContent = t("training.public_url_invalid"); + return; + } + state.settings.publicBaseUrl = normalized; + saveState(); + renderView(); + }); + document.getElementById("trainingPublicAddressDetect")?.addEventListener("click", async () => { + const status = document.getElementById("trainingPublicAddressStatus"); + if (status) status.textContent = t("training.detecting_public_ip"); + try { + const response = await fetch("/api/network/public-address", { cache: "no-store" }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const payload = await response.json(); + const input = document.getElementById("trainingPublicBaseUrl"); + if (input) input.value = payload.suggestedBaseUrl || ""; + if (status) status.textContent = t("training.detected_public_ip", { address: payload.address || "-" }); + } catch { + if (status) status.textContent = t("training.detect_public_ip_failed"); + } + }); document.getElementById("trainingPublicProfileEnable")?.addEventListener("click", () => { replacePublicProfileShare(state, selectedProfileId, profileReferences); saveState(); @@ -928,12 +985,29 @@ function formatTrainingHighlight(highlight, { t, formatLap }) { return t("training.highlight_today_laps", { laps: highlight.value }); } -function buildPublicProfileUrl(token, language) { - const url = new URL(`/public-training/${encodeURIComponent(token)}`, window.location.origin); +function normalizePublicBaseUrl(value) { + try { + const url = new URL(String(value || "").trim()); + return ["http:", "https:"].includes(url.protocol) ? url.origin : ""; + } catch { + return ""; + } +} + +function buildPublicProfileUrl(token, language, publicBaseUrl = "") { + const url = new URL(`/public-training/${encodeURIComponent(token)}`, publicBaseUrl || window.location.origin); url.searchParams.set("lang", language === "en" ? "en" : "sv"); return url.toString(); } +function buildPublicProfileQrUrl(token, language, baseUrl) { + const params = new URLSearchParams({ + lang: language === "en" ? "en" : "sv", + baseUrl, + }); + return `/public-training/qr/${encodeURIComponent(token)}?${params.toString()}`; +} + function generateShareToken() { const bytes = new Uint8Array(24); crypto.getRandomValues(bytes); diff --git a/tests/runtime_persistence.test.js b/tests/runtime_persistence.test.js index c24ffb9..734ec38 100644 --- a/tests/runtime_persistence.test.js +++ b/tests/runtime_persistence.test.js @@ -69,6 +69,7 @@ test("backend hydration normalizes persisted entities and settings", () => { pitEntryWsUrl: "ws://decoder:9002", pitExitWsUrl: "ws://decoder:9003", backendUrl: "http://race:8081", + publicBaseUrl: "https://training.example.test", language: "en", theme: "light", autoReconnect: false, @@ -107,6 +108,7 @@ test("backend hydration normalizes persisted entities and settings", () => { assert.equal(state.activeSessionId, "session_1"); assert.deepEqual(state.audioEventLog, [{ id: "audio_1", event: "race_start" }]); assert.equal(state.settings.theme, "light"); + assert.equal(state.settings.publicBaseUrl, "https://training.example.test"); assert.equal(state.settings.splitDecoderEnabled, true); assert.equal(state.settings.splitWsUrl, "ws://decoder:9001"); assert.equal(state.settings.pitDecoderEnabled, true); diff --git a/tests/server_persistence.integration.test.js b/tests/server_persistence.integration.test.js index e3a015e..7654100 100644 --- a/tests/server_persistence.integration.test.js +++ b/tests/server_persistence.integration.test.js @@ -6,6 +6,7 @@ import { createServer } from "node:net"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import QRCode from "qrcode"; const projectRoot = dirname(dirname(fileURLToPath(import.meta.url))); @@ -147,8 +148,14 @@ test("SQLite API persists state, passings, and overlay session results", { timeo const initialState = { events: [{ id: "event_1", name: "Club race" }], + classes: [{ id: "class_1", name: "Buggy" }], + drivers: [{ id: "driver_1", name: "Driver One", classId: "class_1" }], sessions: [{ id: "session_1", eventId: "event_1", name: "A final" }], resultsBySession: {}, + settings: { publicBaseUrl: "https://racecore.example.org" }, + training: { + shares: [{ driverId: "driver_1", token: "test-share-token-1234567890", enabled: true }], + }, }; const saveResponse = await fetch(`${baseUrl}/api/state`, { method: "POST", @@ -160,6 +167,28 @@ test("SQLite API persists state, passings, and overlay session results", { timeo assert.equal(savePayload.ok, true); assert.equal(typeof savePayload.updatedAt, "string"); + const shareToken = initialState.training.shares[0].token; + const publicQrResponse = await fetch( + `${baseUrl}/public-training/qr/${shareToken}?lang=en&baseUrl=${encodeURIComponent("https://public.example.test")}`, + ); + const publicQrSvg = await publicQrResponse.text(); + const expectedPublicQrSvg = await QRCode.toString( + `https://public.example.test/public-training/${shareToken}?lang=en`, + { type: "svg", errorCorrectionLevel: "M", margin: 2, width: 320 }, + ); + assert.equal(publicQrResponse.status, 200); + assert.match(publicQrResponse.headers.get("content-type"), /^image\/svg\+xml/); + assert.equal(publicQrSvg, expectedPublicQrSvg); + + const localQrResponse = await fetch(`${baseUrl}/public-training/qr/${shareToken}?lang=sv&baseUrl=local`); + const localQrSvg = await localQrResponse.text(); + assert.equal(localQrResponse.status, 200); + assert.match(localQrResponse.headers.get("content-type"), /^image\/svg\+xml/); + assert.notEqual(localQrSvg, publicQrSvg); + + const missingQrResponse = await fetch(`${baseUrl}/public-training/qr/unknown-share-token-123456?baseUrl=local`); + assert.equal(missingQrResponse.status, 404); + const backupResponse = await fetch(`${baseUrl}/api/backups`, { method: "POST", headers: { "Content-Type": "application/json" },