From 2f2e9f7ae77af09a8bf24d9966444050f4ff5a03 Mon Sep 17 00:00:00 2001 From: Cristian Date: Fri, 23 Oct 2026 21:49:37 -0500 Subject: [PATCH 01/32] build(deps): zod 4.6.7 (GHSA-mrhx-c7xc-m3w5), @fastify/cors 11 and minor updates - zod 4.6.7 fixes a high-severity memory exhaustion advisory (< 4.6.6) - @fastify/cors 11 only changes the default methods, which the server sets explicitly - Dependabot ignores @types/node majors until the runtime moves to that major --- .github/dependabot.yml | 4 + package-lock.json | 169 +++++++++++++++-------------------- package.json | 2 +- packages/client/package.json | 6 +- packages/server/package.json | 8 +- packages/shared/package.json | 2 +- 6 files changed, 84 insertions(+), 107 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index eb02e87..302fa20 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,6 +7,10 @@ updates: directory: / schedule: interval: weekly + ignore: + # @types/node follows the Node.js runtime (engines.node and the Docker image), not the latest major + - dependency-name: '@types/node' + update-types: ['version-update:semver-major'] groups: # @vitest/coverage-v8 pins vitest exactly (peer dependency), so they must move together vitest: diff --git a/package-lock.json b/package-lock.json index cc5978c..d317454 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,7 @@ "prettier": "^3.9.10", "typescript": "~6.0.3", "typescript-eslint": "^8.72.0", - "vite": "^8.3.4", + "vite": "^8.3.5", "vitest": "^5.0.5" }, "engines": { @@ -833,14 +833,24 @@ } }, "node_modules/@eslint/js": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.0.tgz", - "integrity": "sha512-J2VKrn6YUBegZFzRQVOVA8jk7VViV/MXhQUvXuCUK1I2RGGT2E+bmNGOsgtdO9Wo25Kd3hfZFuCm8LcfsUFV7g==", - "deprecated": "This version should not be used.", + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", "dev": true, "license": "MIT", "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } } }, "node_modules/@eslint/object-schema": { @@ -905,9 +915,9 @@ } }, "node_modules/@fastify/cors": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz", - "integrity": "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==", + "version": "11.3.1", + "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.1.tgz", + "integrity": "sha512-gZXNzd6FLHzOIfvoK4+Az3Wko2V6Y0MIsLxpyo4AEYl7a9q1JBPwPaxLea4nkr++8FCiJiPpYYo6E02uZW0dqw==", "funding": [ { "type": "github", @@ -920,8 +930,8 @@ ], "license": "MIT", "dependencies": { - "fastify-plugin": "^5.0.0", - "mnemonist": "0.40.0" + "fastify-plugin": "^6.0.0", + "toad-cache": "^3.7.0" } }, "node_modules/@fastify/deepmerge": { @@ -1031,9 +1041,9 @@ } }, "node_modules/@fastify/rate-limit": { - "version": "11.0.0", - "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.0.0.tgz", - "integrity": "sha512-kCs+G59SitZw9TL/ekFe+MrzXk20dEp6zPAM8WEZjFl5Ubvv5ksTbEXYr4jGlBwWAKn78q+NFsj5CN75zXLjaw==", + "version": "11.2.1", + "resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.2.1.tgz", + "integrity": "sha512-uTQjii3tJUnb6ZyEMcunuvKE3FDKNZEaPpxdyWADDgthHnUV1swdvwuiZfi3DDDgxm1iBrhOkQ/w0yDBQ8qJtA==", "funding": [ { "type": "github", @@ -1047,7 +1057,8 @@ "license": "MIT", "dependencies": { "@lukeed/ms": "^2.0.2", - "fastify-plugin": "^5.0.0", + "fastify-plugin": "^6.0.0", + "ip-address": "^10.2.0", "toad-cache": "^3.7.0" } }, @@ -1075,9 +1086,9 @@ } }, "node_modules/@fastify/static": { - "version": "10.1.6", - "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.6.tgz", - "integrity": "sha512-f3x9XisI+y6AIhIfA/+Q3SFsuXeqNNk5HQh5htgHsPxmMLb4pLFskV6eFaLSW3IbI4azrS5np+/tkzXNH99LyQ==", + "version": "10.1.7", + "resolved": "https://registry.npmjs.org/@fastify/static/-/static-10.1.7.tgz", + "integrity": "sha512-7+lUiPRraENCiULgjhcqZyWSW6qnwIT87Is0u3c2Z3iGEEEGB45wJhYxzrSdzu12gHM4qcviEYcPEiCe4J6MCQ==", "funding": [ { "type": "github", @@ -1099,22 +1110,6 @@ "glob": "^13.0.0" } }, - "node_modules/@fastify/static/node_modules/fastify-plugin": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.1.tgz", - "integrity": "sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@fastify/websocket": { "version": "11.3.4", "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.4.tgz", @@ -1136,22 +1131,6 @@ "ws": "^8.16.0" } }, - "node_modules/@fastify/websocket/node_modules/fastify-plugin": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.1.tgz", - "integrity": "sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -2253,6 +2232,16 @@ "csstype": "^3.2.2" } }, + "node_modules/@types/react-dom": { + "version": "19.3.0", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz", + "integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.3.0" + } + }, "node_modules/@types/ws": { "version": "8.18.2", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", @@ -3462,9 +3451,9 @@ } }, "node_modules/fastify-plugin": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", - "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.1.tgz", + "integrity": "sha512-4wW4RxSmvPXLrh5mgQEiu43qU3bw44N2ss4FTkTkQIbprUhE+/s43QX0j/ttcztdWK7PEkHqVthdc5GdEGDvjA==", "funding": [ { "type": "github", @@ -3734,6 +3723,15 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ip-address": { + "version": "10.7.4", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.4.tgz", + "integrity": "sha512-DWBMKyeM25D70nrIiTkgQth59/G+naKOTep6huF9j+GmeK3DMD60Zvxg5d5qQnz+38AHgou6ean2LECX+lrKDg==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/ipaddr.js": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", @@ -4203,9 +4201,9 @@ } }, "node_modules/lucide-react": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.60.0.tgz", - "integrity": "sha512-UqxOW0FgAfDyb9VmjKb6/BkOzW6yfhf/QNboKtS2NDsscnNRW2CDzlyhbMKBQ1ontmcxuIDqHJ2AVgFR3NI+GA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.61.0.tgz", + "integrity": "sha512-v2SQPPPNjnr+BlhL9Spuzmo5yR+hLkftfuE4E3jaxdNPgSH0hJKPcLJBP7yAta87jQNPsSERS59PJjCJ/CorBA==", "license": "ISC", "peerDependencies": { "@types/react": "*", @@ -4275,15 +4273,6 @@ "node": ">=16 || 14 >=14.17" } }, - "node_modules/mnemonist": { - "version": "0.40.0", - "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz", - "integrity": "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==", - "license": "MIT", - "dependencies": { - "obliterator": "^2.0.4" - } - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -4326,12 +4315,6 @@ "node": ">=18" } }, - "node_modules/obliterator": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", - "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", - "license": "MIT" - }, "node_modules/obs-websocket-js": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/obs-websocket-js/-/obs-websocket-js-5.0.8.tgz", @@ -5228,16 +5211,16 @@ "license": "MIT" }, "node_modules/vite": { - "version": "8.3.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", - "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz", + "integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.33.0", "picomatch": "^4.0.7", - "postcss": "^8.5.29", - "rolldown": "~1.2.12", + "postcss": "^8.5.30", + "rolldown": "~1.2.14", "tinyglobby": "^0.2.17" }, "bin": { @@ -5254,7 +5237,7 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.7.1", + "@vitejs/devtools": "^0.7.1 || ^0.8.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", @@ -5475,9 +5458,9 @@ } }, "node_modules/zod": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.0.tgz", - "integrity": "sha512-iIvwyDnebKYpww2ta0DjaNOL8RnVLmPMhgWyOzlW9y0EIIxv5gl+H6Y0ONpt83HqjGqkk78uWp6xWtpxzZdPbw==", + "version": "4.6.7", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.7.tgz", + "integrity": "sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -5501,7 +5484,7 @@ "version": "1.6.1", "dependencies": { "@streamcollab/shared": "*", - "lucide-react": "^1.60.0", + "lucide-react": "^1.61.0", "obs-websocket-js": "^5.0.6", "react": "^19.3.1", "react-dom": "^19.3.1" @@ -5509,10 +5492,10 @@ "devDependencies": { "@tailwindcss/vite": "^4.3.3", "@types/react": "^19.3.0", - "@types/react-dom": "^19.2.0", + "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", "tailwindcss": "^4.3.3", - "vite": "^8.3.4" + "vite": "^8.3.5" } }, "packages/client/node_modules/@rolldown/pluginutils": { @@ -5522,16 +5505,6 @@ "dev": true, "license": "MIT" }, - "packages/client/node_modules/@types/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-brtBs0MnE9SMx7px208g39lRmC5uHZs96caOJfTjFcYSLHNamvaSMfJNagChVNkup2SdtOxKX1FDBkRSJe1ZAg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, "packages/client/node_modules/@vitejs/plugin-react": { "version": "6.1.2", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.1.2.tgz", @@ -5566,13 +5539,13 @@ "name": "@streamcollab/server", "version": "1.6.1", "dependencies": { - "@fastify/cors": "^10.0.2", - "@fastify/rate-limit": "^11.0.0", - "@fastify/static": "^10.1.6", + "@fastify/cors": "^11.3.1", + "@fastify/rate-limit": "^11.2.1", + "@fastify/static": "^10.1.7", "@fastify/websocket": "^11.0.2", "@streamcollab/shared": "*", "fastify": "^5.2.1", - "zod": "^4.6.0" + "zod": "^4.6.7" }, "devDependencies": { "@types/node": "^24.19.2", @@ -5584,7 +5557,7 @@ "name": "@streamcollab/shared", "version": "1.6.1", "dependencies": { - "zod": "^4.6.0" + "zod": "^4.6.7" } } } diff --git a/package.json b/package.json index 2988b76..e382347 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ "prettier": "^3.9.10", "typescript": "~6.0.3", "typescript-eslint": "^8.72.0", - "vite": "^8.3.4", + "vite": "^8.3.5", "vitest": "^5.0.5" } } diff --git a/packages/client/package.json b/packages/client/package.json index e8b6b33..72e57a0 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@streamcollab/shared": "*", - "lucide-react": "^1.60.0", + "lucide-react": "^1.61.0", "obs-websocket-js": "^5.0.6", "react": "^19.3.1", "react-dom": "^19.3.1" @@ -18,9 +18,9 @@ "devDependencies": { "@tailwindcss/vite": "^4.3.3", "@types/react": "^19.3.0", - "@types/react-dom": "^19.2.0", + "@types/react-dom": "^19.3.0", "@vitejs/plugin-react": "^6.1.2", "tailwindcss": "^4.3.3", - "vite": "^8.3.4" + "vite": "^8.3.5" } } diff --git a/packages/server/package.json b/packages/server/package.json index d57fc04..15dc19e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -10,13 +10,13 @@ "backup": "node dist/backup.js" }, "dependencies": { - "@fastify/cors": "^10.0.2", - "@fastify/rate-limit": "^11.0.0", - "@fastify/static": "^10.1.6", + "@fastify/cors": "^11.3.1", + "@fastify/rate-limit": "^11.2.1", + "@fastify/static": "^10.1.7", "@fastify/websocket": "^11.0.2", "@streamcollab/shared": "*", "fastify": "^5.2.1", - "zod": "^4.6.0" + "zod": "^4.6.7" }, "devDependencies": { "@types/node": "^24.19.2", diff --git a/packages/shared/package.json b/packages/shared/package.json index 91a82cb..9a7758e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -18,6 +18,6 @@ "dev": "tsc -b --watch" }, "dependencies": { - "zod": "^4.6.0" + "zod": "^4.6.7" } } From ccaca0e1bc72c3cb7e5b3ea1d854ba4261940077 Mon Sep 17 00:00:00 2001 From: Cristian Date: Fri, 23 Oct 2026 21:58:18 -0500 Subject: [PATCH 02/32] fix(server): transaction safety, session integrity, restart recovery and lookup limits - transaction(): a failed BEGIN no longer leaves every later transaction running without one; the rollback keeps the original error; afterCommit() for broadcasts that must not outlive a rollback - Stopping a session that already stopped neither moves its end nor orphans the running session - Markers never go into a stopped session (a dock that hasn't seen SESSION_STOPPED yet) - Undo only reaches the running session, as documented - Partial status updates are validated against the merged OBS framerate - Migration v5 repairs sessions v4 closed before they started; a newer schema is refused and the database file is closed when it can't be opened - Restart recovery: running sessions resume after a recent clean shutdown and close at the last heartbeat after a crash; members are no longer reported as recording until their dock rejoins - Unknown room codes count per IP across REST and WebSocket; custom codes need 8+ characters - Sockets that leave their room or whose room is deleted are closed after the unjoined timeout; simultaneous sockets per IP are capped - Exports of an unknown session answer 404; repeated query parameters answer 400 - Empty PORT / HOST / LOG_LEVEL / ROOM_CREATE_LIMIT_PER_MINUTE use their defaults - RFC 5987 file names percent-encode ' ( ) *; startup errors are logged before exiting --- packages/server/src/config.ts | 14 +- packages/server/src/db/database.ts | 138 +++++- packages/server/src/index.ts | 65 ++- packages/server/src/rooms/roomManager.ts | 47 +- packages/server/src/routes/api.ts | 84 ++-- packages/server/src/security/lookupGuard.ts | 51 +++ packages/server/src/websocket/handler.ts | 110 +++-- packages/server/test/audit-v162.test.ts | 454 ++++++++++++++++++++ packages/shared/src/schemas/index.ts | 30 +- 9 files changed, 875 insertions(+), 118 deletions(-) create mode 100644 packages/server/src/security/lookupGuard.ts create mode 100644 packages/server/test/audit-v162.test.ts diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 5587bcb..748d681 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -3,8 +3,10 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { z } from 'zod'; -// Empty strings from .env files / compose mean "not set" -const optionalString = z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()); +// Empty strings from .env files / compose (`PORT=`, `${PORT:-}`) mean "not set" +const unsetIfEmpty = (schema: T) => + z.preprocess((v) => (typeof v === 'string' && v.trim() === '' ? undefined : v), schema); +const optionalString = unsetIfEmpty(z.string().optional()); const booleanFlag = z.preprocess( (v) => (v === '' || v === undefined ? undefined : String(v).toLowerCase()), @@ -15,8 +17,8 @@ const booleanFlag = z.preprocess( ); const envSchema = z.object({ - PORT: z.coerce.number().int().min(1).max(65535).default(3000), - HOST: z.string().min(1).default('0.0.0.0'), + PORT: unsetIfEmpty(z.coerce.number().int().min(1).max(65535).default(3000)), + HOST: unsetIfEmpty(z.string().min(1).default('0.0.0.0')), DATABASE_PATH: optionalString, CLIENT_DIST: optionalString, ALLOWED_ORIGIN: optionalString, @@ -25,8 +27,8 @@ const envSchema = z.object({ z.string().min(16, 'ADMIN_TOKEN must be at least 16 characters').optional() ), TRUST_PROXY: booleanFlag, - ROOM_CREATE_LIMIT_PER_MINUTE: z.coerce.number().int().min(1).max(10_000).default(10), - LOG_LEVEL: z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info'), + ROOM_CREATE_LIMIT_PER_MINUTE: unsetIfEmpty(z.coerce.number().int().min(1).max(10_000).default(10)), + LOG_LEVEL: unsetIfEmpty(z.enum(['fatal', 'error', 'warn', 'info', 'debug', 'trace', 'silent']).default('info')), }); export interface ServerConfig { diff --git a/packages/server/src/db/database.ts b/packages/server/src/db/database.ts index 0a1b8f9..62df019 100644 --- a/packages/server/src/db/database.ts +++ b/packages/server/src/db/database.ts @@ -150,8 +150,23 @@ const MIGRATIONS: string[] = [ WHERE stopped_at IS NULL AND id NOT IN (SELECT active_session_id FROM rooms WHERE active_session_id IS NOT NULL); `, + // v5 — (v1.6.2) v4 could close an orphan at a marker taken just before the session started, which + // left stopped_at < started_at. Also records when the server was last alive and whether it shut + // down cleanly, so a restart can tell a deploy from a crash. + ` + UPDATE recording_sessions SET stopped_at = started_at + WHERE stopped_at IS NOT NULL AND stopped_at < started_at; + + CREATE TABLE server_state ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL + ); + `, ]; +// A clean shutdown older than this is treated like a crash: nobody was recording through it +export const RESUME_AFTER_CLEAN_SHUTDOWN_MS = 10 * 60_000; + // Shapes of SQLite rows as returned by node:sqlite (snake_case columns) interface RoomRow { id: string; @@ -232,7 +247,14 @@ export class DatabaseService { } this.db = new DatabaseSync(finalPath); - this.init(); + try { + this.init(); + } catch (err) { + // Don't keep the file open (and locked on Windows) when it can't be used + this.open = false; + this.db.close(); + throw err; + } } private init() { @@ -246,23 +268,51 @@ export class DatabaseService { } private inTx = false; + private afterCommitQueue: (() => void)[] = []; - /** Run a callback inside a BEGIN IMMEDIATE ... COMMIT transaction with rollback on error */ + /** + * Run a callback inside a BEGIN IMMEDIATE ... COMMIT transaction with rollback on error. + * Nested calls join the outer transaction. + */ transaction(fn: () => T): T { if (this.inTx) { return fn(); } - this.inTx = true; + // A BEGIN that fails (SQLITE_BUSY past busy_timeout) leaves nothing to undo: throw before inTx is set this.db.exec('BEGIN IMMEDIATE'); + this.inTx = true; + let committed = false; try { const result = fn(); this.db.exec('COMMIT'); + committed = true; return result; } catch (err) { - this.db.exec('ROLLBACK'); + // SQLite may have rolled back on its own already (SQLITE_FULL, IOERR); keep the original error + if (this.db.isTransaction) { + try { + this.db.exec('ROLLBACK'); + } catch { + // the original error is the useful one + } + } throw err; } finally { this.inTx = false; + const queued = this.afterCommitQueue; + this.afterCommitQueue = []; + if (committed) { + for (const callback of queued) callback(); + } + } + } + + /** Run `callback` once the current transaction commits (now, if there is none); dropped on rollback */ + afterCommit(callback: () => void) { + if (this.inTx) { + this.afterCommitQueue.push(callback); + } else { + callback(); } } @@ -329,10 +379,76 @@ export class DatabaseService { return this.reconcileOrphanSessions({ closeActive: true }); } + private getState(key: string): number | null { + const row = this.stmt('SELECT value FROM server_state WHERE key = ?').get(key) as { value: number } | undefined; + return row ? Number(row.value) : null; + } + + private setState(key: string, value: number) { + this.stmt( + 'INSERT INTO server_state (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value' + ).run(key, value); + } + + /** Written periodically while the server runs: after a crash, sessions ran at least until then */ + markAlive(now: number = Date.now()) { + this.setState('last_alive_at', now); + } + + /** Written by the graceful shutdown, just before the database closes */ + markCleanShutdown(now: number = Date.now()) { + this.setState('last_alive_at', now); + this.setState('clean_shutdown_at', now); + } + + /** + * Startup reconciliation. + * - After a recent clean shutdown (a deploy or restart), running sessions keep running: OBS kept + * recording and the docks reconnect on their own. + * - After a crash, or a clean shutdown older than RESUME_AFTER_CLEAN_SHUTDOWN_MS, running sessions + * are closed at the last moment they are known to have run (latest marker or server heartbeat). + * In both cases no dock is connected yet, so nobody is "recording": that status is reported again + * on rejoin, and a session started before then must not anchor a stale recording file. + */ + recoverAfterRestart(now: number = Date.now()): { resumed: boolean; closedCount: number } { + return this.transaction(() => { + const cleanShutdownAt = this.getState('clean_shutdown_at'); + const lastAliveAt = this.getState('last_alive_at'); + this.stmt('DELETE FROM server_state WHERE key = ?').run('clean_shutdown_at'); + this.db.exec("UPDATE members SET obs_status = 'disconnected' WHERE obs_status <> 'disconnected'"); + + const resumed = cleanShutdownAt !== null && now - cleanShutdownAt <= RESUME_AFTER_CLEAN_SHUTDOWN_MS; + if (resumed) { + return { resumed, ...this.reconcileOrphanSessions() }; + } + + const unstopped = this.stmt('SELECT id FROM recording_sessions WHERE stopped_at IS NULL').all(); + this.stmt( + `UPDATE recording_sessions + SET stopped_at = MAX( + started_at, + COALESCE((SELECT MAX(timestamp) FROM markers WHERE markers.session_id = recording_sessions.id), started_at), + COALESCE(?, started_at) + ) + WHERE stopped_at IS NULL` + ).run(lastAliveAt); + this.db.exec('UPDATE rooms SET active_session_id = NULL WHERE active_session_id IS NOT NULL'); + return { resumed, closedCount: unstopped.length }; + }); + } + private migrate() { const row = this.db.prepare('PRAGMA user_version').get() as { user_version: number }; const current = Number(row.user_version); + // A database written by a newer StreamCollab has tables this build doesn't know how to keep consistent + if (current > MIGRATIONS.length) { + throw new Error( + `Database schema v${current} is newer than this StreamCollab build supports (v${MIGRATIONS.length}). ` + + 'Upgrade StreamCollab or restore a backup made by this version.' + ); + } + for (let version = current; version < MIGRATIONS.length; version++) { this.db.exec('BEGIN IMMEDIATE'); try { @@ -540,13 +656,23 @@ export class DatabaseService { }); } + /** + * Stop a running session. Returns null when it doesn't exist or already stopped (its stop time is + * never moved). The room's active session is only cleared if it is this one. + */ stopSession(sessionId: string, stoppedAt: number = Date.now()): RecordingSession | null { return this.transaction(() => { - this.stmt('UPDATE recording_sessions SET stopped_at = ? WHERE id = ?').run(stoppedAt, sessionId); + const changed = this.stmt( + 'UPDATE recording_sessions SET stopped_at = MAX(?, started_at) WHERE id = ? AND stopped_at IS NULL' + ).run(stoppedAt, sessionId).changes; + if (Number(changed) === 0) return null; const session = this.getSession(sessionId); if (session) { - this.setRoomActiveSession(session.roomId, null); + this.stmt('UPDATE rooms SET active_session_id = NULL WHERE id = ? AND active_session_id = ?').run( + session.roomId, + sessionId + ); } return session; }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 8c119af..386fabb 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -10,6 +10,7 @@ import { RoomManager } from './rooms/roomManager.js'; import { registerApiRoutes } from './routes/api.js'; import { registerWebSocketHandler } from './websocket/handler.js'; import { registerClientApp } from './static/clientApp.js'; +import { RoomLookupGuard, DEFAULT_JOIN_FAILURE_LIMIT } from './security/lookupGuard.js'; import { loadConfig, loadDotEnv, parseOriginList } from './config.js'; export { parseOriginList }; @@ -26,10 +27,15 @@ export interface ServerOptions { roomCreateLimitPerMinute?: number; trustProxy?: boolean; unjoinedTimeoutMs?: number; + maxSocketsPerIp?: number; + // How often the server records that it is alive (bounds a session's end time after a crash) + aliveIntervalMs?: number; // Compiled client (packages/client/dist) to serve at "/". Omit to serve the API only. clientDir?: string; } +export const DEFAULT_ALIVE_INTERVAL_MS = 30_000; + export async function createServer(options: ServerOptions = {}) { const fastify = Fastify({ logger: options.logger ?? true, @@ -61,16 +67,31 @@ export async function createServer(options: ServerOptions = {}) { const db = new DatabaseService(options.dbPath); const roomManager = new RoomManager(db); - roomManager.reconcileCrashRecovery(); + roomManager.recoverAfterRestart(); + + db.markAlive(); + const alive = setInterval(() => { + if (db.isOpen) db.markAlive(); + }, options.aliveIntervalMs ?? DEFAULT_ALIVE_INTERVAL_MS); + alive.unref(); + + // One count of unknown room codes per IP for both the WebSocket joins and the REST lookups + const lookupGuard = new RoomLookupGuard(options.joinFailureLimit ?? DEFAULT_JOIN_FAILURE_LIMIT); + fastify.addHook('onClose', async () => { + clearInterval(alive); + lookupGuard.close(); + }); registerApiRoutes(fastify, roomManager, db, { adminToken: options.adminToken, roomCreateLimitPerMinute: options.roomCreateLimitPerMinute, + lookupGuard, }); registerWebSocketHandler(fastify, roomManager, { heartbeatIntervalMs: options.heartbeatIntervalMs, - joinFailureLimit: options.joinFailureLimit, + lookupGuard, unjoinedTimeoutMs: options.unjoinedTimeoutMs, + maxSocketsPerIp: options.maxSocketsPerIp, }); if (options.clientDir) { @@ -81,6 +102,8 @@ export async function createServer(options: ServerOptions = {}) { } const SHUTDOWN_TIMEOUT_MS = 10_000; +// Docks that vanished without a close frame would hold the shutdown for ws's 30 s close timeout +const SOCKET_CLOSE_GRACE_MS = 3_000; async function start() { const envFile = loadDotEnv(); @@ -93,15 +116,23 @@ async function start() { process.exit(1); } - const { fastify, db } = await createServer({ - dbPath: config.databasePath, - logger: { level: config.logLevel }, - allowedOrigins: config.allowedOrigins, - adminToken: config.adminToken, - trustProxy: config.trustProxy, - roomCreateLimitPerMinute: config.roomCreateLimitPerMinute, - clientDir: config.clientDist, - }); + let server; + try { + server = await createServer({ + dbPath: config.databasePath, + logger: { level: config.logLevel }, + allowedOrigins: config.allowedOrigins, + adminToken: config.adminToken, + trustProxy: config.trustProxy, + roomCreateLimitPerMinute: config.roomCreateLimitPerMinute, + clientDir: config.clientDist, + }); + } catch (err) { + // e.g. a locked database file, a failed migration or a newer schema + console.error('[StreamCollab Server] Could not start:', err instanceof Error ? err.message : err); + process.exit(1); + } + const { fastify, db } = server; if (envFile) { fastify.log.info({ envFile }, 'Loaded environment file'); @@ -120,8 +151,15 @@ async function start() { }, SHUTDOWN_TIMEOUT_MS); forceExit.unref(); + const terminateSockets = setTimeout(() => { + for (const client of fastify.websocketServer?.clients ?? []) client.terminate(); + }, SOCKET_CLOSE_GRACE_MS); + terminateSockets.unref(); + try { await fastify.close(); + // The next start resumes running sessions instead of treating this like a crash + db.markCleanShutdown(); db.checkpoint('TRUNCATE'); db.close(); process.exit(0); @@ -148,7 +186,10 @@ if (process.argv[1]) { const mainFile = fileURLToPath(import.meta.url).toLowerCase(); const argvFile = path.resolve(process.argv[1]).toLowerCase(); if (mainFile === argvFile) { - start(); + start().catch((err) => { + console.error('[StreamCollab Server] Fatal error:', err); + process.exit(1); + }); } } catch { // Ignore URL parse errors diff --git a/packages/server/src/rooms/roomManager.ts b/packages/server/src/rooms/roomManager.ts index 8f25364..7a64a04 100644 --- a/packages/server/src/rooms/roomManager.ts +++ b/packages/server/src/rooms/roomManager.ts @@ -15,6 +15,7 @@ import { DaVinciMarkerColor, SessionSummary, resolveMemberFramerate, + isPlausibleObsFramerate, } from '@streamcollab/shared'; import { DatabaseService } from '../db/database.js'; import { generateRoomCode, generateToken, hashToken, tokenMatches } from '../auth/tokens.js'; @@ -63,6 +64,7 @@ export class RoomManager { private clients: Map = new Map(); // roomId -> sockets subscribed to that room private roomSockets: Map> = new Map(); + private detachListeners: ((socket: WebSocket) => void)[] = []; constructor(db: DatabaseService) { this.db = db; @@ -78,6 +80,16 @@ export class RoomManager { return this.db.reconcileCrashRecovery(); } + /** Startup: resume sessions after a recent clean shutdown, close them after a crash */ + recoverAfterRestart(now?: number): { resumed: boolean; closedCount: number } { + return this.db.recoverAfterRestart(now); + } + + /** Called with each socket the manager takes out of its room without closing it (room deleted) */ + onClientDetached(listener: (socket: WebSocket) => void) { + this.detachListeners.push(listener); + } + // Room lifecycle createRoom(id?: string, name?: string): CreateRoomResponse { let roomId = id; @@ -134,14 +146,21 @@ export class RoomManager { /** Delete the room and all its data; connected clients are told and detached (host-only) */ deleteRoom(roomId: string) { this.broadcastToRoom(roomId, { type: 'ROOM_DELETED', payload: { roomId } }); - for (const socket of [...(this.roomSockets.get(roomId) ?? [])]) { + const detached = [...(this.roomSockets.get(roomId) ?? [])]; + for (const socket of detached) { this.clients.delete(socket); } this.roomSockets.delete(roomId); this.db.deleteRoom(roomId); + for (const socket of detached) { + for (const listener of this.detachListeners) listener(socket); + } } - /** Host only: forget an offline member (its row and member token). Markers keep their author name. */ + /** + * Host only: take an offline member out of this room. Markers keep their author name. The member id + * stays bound to its device (identity and token), so the same Dock can join again. + */ removeMember(actor: { roomId: string; isHost: boolean }, memberId: string) { if (!actor.isHost) { throw new RoomError('FORBIDDEN', 'Only the room host can remove members'); @@ -317,6 +336,11 @@ export class RoomManager { lastHeartbeat: Date.now(), }; + // A partial update (only the numerator, say) is validated against the merged rate + if (!isPlausibleObsFramerate(updated.fpsNumerator, updated.fpsDenominator)) { + throw new RoomError('INVALID_MESSAGE', 'fpsNumerator / fpsDenominator must describe 1-1000 fps'); + } + this.db.upsertMember(roomId, updated); // A new recording start becomes this member's anchor in the running session @@ -396,19 +420,22 @@ export class RoomManager { return session; } + /** + * Stop a running session of this room. Returns null when it isn't in this room; an already + * stopped session is returned unchanged (stopping twice is harmless and never moves its end). + */ stopSession(roomId: string, sessionId: string): RecordingSession | null { // Never let one room stop another room's session const session = this.db.getSession(sessionId); if (!session || session.roomId !== roomId) return null; + if (session.stoppedAt !== null) return session; const stopped = this.db.stopSession(sessionId); if (stopped) { - this.broadcastToRoom(roomId, { - type: 'SESSION_STOPPED', - payload: stopped, - }); + // Inside startSession this runs only once the whole switch has committed + this.db.afterCommit(() => this.broadcastToRoom(roomId, { type: 'SESSION_STOPPED', payload: stopped })); } - return stopped; + return stopped ?? this.db.getSession(sessionId); } getActiveSession(roomId: string): RecordingSession | null { @@ -430,8 +457,9 @@ export class RoomManager { obsRecordMs?: number; }): SquadMarker { const now = resolveMarkerTimestamp(data.clientTimestamp, Date.now()); + // A dock may still name a session the host just stopped: markers only go into running sessions let session = data.sessionId ? this.db.getSession(data.sessionId) : null; - if (!session || session.roomId !== data.roomId) { + if (!session || session.roomId !== data.roomId || session.stoppedAt !== null) { session = this.db.getActiveSession(data.roomId); } @@ -544,7 +572,8 @@ export class RoomManager { /** Delete a member's most recent marker in the active session (the "oops" button) */ undoLastMarker(roomId: string, memberId: string): SquadMarker { - const session = this.db.getActiveSession(roomId) ?? this.db.getLatestSession(roomId); + // Only the running session: never reach back into a recording that already ended + const session = this.db.getActiveSession(roomId); const marker = session ? this.db.getLatestMemberMarker(session.id, memberId) : null; if (!marker) { throw new RoomError('NOT_FOUND', 'No marker of yours to undo in this session'); diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index 004d27c..7bf64b9 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -4,6 +4,7 @@ import { z } from 'zod'; import { RoomManager, RoomError } from '../rooms/roomManager.js'; import { DatabaseService } from '../db/database.js'; import { bearerToken } from '../auth/tokens.js'; +import { RoomLookupGuard } from '../security/lookupGuard.js'; import { exportToEdl, exportToFcpXml, @@ -17,6 +18,8 @@ import { stopSessionBodySchema, quickMarkBodySchema, undoMarkerBodySchema, + markersQuerySchema, + exportQuerySchema, formatValidationError, } from '@streamcollab/shared'; @@ -28,7 +31,12 @@ export function attachmentDisposition(filename: string): string { // Also drop characters Windows forbids in filenames (e.g. the ':' in "Auto Session 23:07:27") const cleaned = filename.replace(/[\r\n"\\/:*?<>|]/g, '').replace(/\s+/g, '_') || 'export'; const ascii = cleaned.normalize('NFKD').replace(/[^\x20-\x7E]/g, '') || 'export'; - return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(cleaned)}`; + // RFC 5987 attr-char excludes ' ( ) * , which encodeURIComponent leaves as they are + const encoded = encodeURIComponent(cleaned).replace( + /['()*]/g, + (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}` + ); + return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`; } export interface ApiRouteOptions { @@ -36,6 +44,8 @@ export interface ApiRouteOptions { adminToken?: string; // Rooms one IP may create per minute (default 10) roomCreateLimitPerMinute?: number; + // Unknown room codes per IP, shared with the WebSocket joins (created here when omitted) + lookupGuard?: RoomLookupGuard; } // Room creation and HTTP markers are the only unauthenticated-ish writes; keep them cheap to abuse @@ -68,8 +78,19 @@ export function registerApiRoutes( } }); - const requireRoom = (id: string, reply: FastifyReply) => { + const lookupGuard = options.lookupGuard ?? new RoomLookupGuard(); + if (!options.lookupGuard) { + fastify.addHook('onClose', async () => lookupGuard.close()); + } + + // Room codes are the invite: unknown codes count against the caller's IP, like WebSocket joins + const requireRoom = (id: string, req: FastifyRequest, reply: FastifyReply) => { + if (lookupGuard.isBlocked(req.ip)) { + reply.status(429).send({ error: 'Too many unknown room codes from your network; try again in a minute' }); + return false; + } if (!roomIdSchema.safeParse(id).success || !db.roomExists(id)) { + lookupGuard.recordFailure(req.ip); reply.status(404).send({ error: `Room ${id} not found` }); return false; } @@ -127,7 +148,7 @@ export function registerApiRoutes( // Get room fastify.get('/api/rooms/:id', async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const { id } = req.params; - if (!requireRoom(id, reply)) return reply; + if (!requireRoom(id, req, reply)) return reply; const room = roomManager.getRoom(id); const activeSession = roomManager.getActiveSession(id); return { room, activeSession, isHost: roomManager.isHostToken(id, bearerToken(req.headers.authorization)) }; @@ -138,7 +159,7 @@ export function registerApiRoutes( '/api/rooms/:id/session/start', async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const { id } = req.params; - if (!requireRoom(id, reply) || !requireHost(id, req, reply)) return reply; + if (!requireRoom(id, req, reply) || !requireHost(id, req, reply)) return reply; const body = sessionSettingsSchema.safeParse(req.body ?? {}); if (!body.success) return badRequest(reply, body.error); @@ -156,7 +177,7 @@ export function registerApiRoutes( '/api/rooms/:id/session/stop', async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const { id } = req.params; - if (!requireRoom(id, reply) || !requireHost(id, req, reply)) return reply; + if (!requireRoom(id, req, reply) || !requireHost(id, req, reply)) return reply; const body = stopSessionBodySchema.safeParse(req.body ?? {}); if (!body.success) return badRequest(reply, body.error); @@ -180,7 +201,7 @@ export function registerApiRoutes( { config: { rateLimit: QUICK_MARK_RATE_LIMIT } }, async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const { id } = req.params; - if (!requireRoom(id, reply)) return reply; + if (!requireRoom(id, req, reply)) return reply; const body = quickMarkBodySchema.safeParse(req.body ?? {}); if (!body.success) return badRequest(reply, body.error); @@ -212,7 +233,7 @@ export function registerApiRoutes( { config: { rateLimit: QUICK_MARK_RATE_LIMIT } }, async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const { id } = req.params; - if (!requireRoom(id, reply)) return reply; + if (!requireRoom(id, req, reply)) return reply; const body = undoMarkerBodySchema.safeParse(req.body ?? {}); if (!body.success) return badRequest(reply, body.error); @@ -240,7 +261,7 @@ export function registerApiRoutes( '/api/rooms/:id/sessions/:sessionId', async (req: FastifyRequest<{ Params: { id: string; sessionId: string } }>, reply: FastifyReply) => { const { id, sessionId } = req.params; - if (!requireRoom(id, reply) || !requireHost(id, req, reply)) return reply; + if (!requireRoom(id, req, reply) || !requireHost(id, req, reply)) return reply; try { roomManager.deleteSession(id, sessionId); return reply.status(204).send(); @@ -256,7 +277,7 @@ export function registerApiRoutes( // Delete the whole room (host only) fastify.delete('/api/rooms/:id', async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const { id } = req.params; - if (!requireRoom(id, reply) || !requireHost(id, req, reply)) return reply; + if (!requireRoom(id, req, reply) || !requireHost(id, req, reply)) return reply; roomManager.deleteRoom(id); return reply.status(204).send(); }); @@ -266,7 +287,7 @@ export function registerApiRoutes( '/api/rooms/:id/sessions', async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const { id } = req.params; - if (!requireRoom(id, reply)) return reply; + if (!requireRoom(id, req, reply)) return reply; return roomManager.listSessions(id); } ); @@ -274,13 +295,12 @@ export function registerApiRoutes( // List markers fastify.get( '/api/rooms/:id/markers', - async ( - req: FastifyRequest<{ Params: { id: string }; Querystring: { sessionId?: string } }>, - reply: FastifyReply - ) => { + async (req: FastifyRequest<{ Params: { id: string } }>, reply: FastifyReply) => { const { id } = req.params; - if (!requireRoom(id, reply)) return reply; - const { sessionId } = req.query; + if (!requireRoom(id, req, reply)) return reply; + const query = markersQuerySchema.safeParse(req.query ?? {}); + if (!query.success) return badRequest(reply, query.error); + const { sessionId } = query.data; if (sessionId) { const session = db.getSession(sessionId); @@ -307,29 +327,21 @@ export function registerApiRoutes( // NLE Timeline Exporters fastify.get( '/api/rooms/:id/export/:format', - async ( - req: FastifyRequest<{ - Params: { id: string; format: string }; - Querystring: { sessionId?: string; memberId?: string }; - }>, - reply: FastifyReply - ) => { + async (req: FastifyRequest<{ Params: { id: string; format: string } }>, reply: FastifyReply) => { const { id, format } = req.params; - const { sessionId, memberId } = req.query; - - const room = roomManager.getRoom(id); - if (!room) { - return reply.status(404).send({ error: `Room ${id} not found` }); - } - - let session = sessionId ? db.getSession(sessionId) : roomManager.getActiveSession(id); - if (session && session.roomId !== id) { + if (!requireRoom(id, req, reply)) return reply; + const query = exportQuerySchema.safeParse(req.query ?? {}); + if (!query.success) return badRequest(reply, query.error); + const { sessionId, memberId } = query.data; + + // A named session must exist in this room (never silently export another one); without a name, + // the running session or else the most recent one + let session = sessionId + ? db.getSession(sessionId) + : (roomManager.getActiveSession(id) ?? db.getLatestSession(id)); + if (sessionId && (!session || session.roomId !== id)) { return reply.status(404).send({ error: `Session ${sessionId} not found in room ${id}` }); } - if (!session) { - // Fallback: get the most recent session in this room - session = db.getLatestSession(id); - } if (!session) { return reply.status(404).send({ error: 'No recording session found for export' }); diff --git a/packages/server/src/security/lookupGuard.ts b/packages/server/src/security/lookupGuard.ts new file mode 100644 index 0000000..307d000 --- /dev/null +++ b/packages/server/src/security/lookupGuard.ts @@ -0,0 +1,51 @@ +/** + * Counts unknown room codes per IP across the WebSocket and the REST API. Room codes are the + * invite, so a client that tries many of them is guessing; opening new sockets or requests is + * free, which is why this is tracked per IP rather than per connection. + */ +export interface LookupLimit { + max: number; + windowMs: number; +} + +// Unknown room codes tolerated per IP and window before lookups from that IP are refused +export const DEFAULT_JOIN_FAILURE_LIMIT: LookupLimit = { max: 20, windowMs: 60_000 }; + +export class RoomLookupGuard { + private failures = new Map(); + private pruneTimer: ReturnType; + + constructor(readonly limit: LookupLimit = DEFAULT_JOIN_FAILURE_LIMIT) { + this.pruneTimer = setInterval(() => this.prune(), limit.windowMs); + this.pruneTimer.unref(); + } + + isBlocked(ip: string, now = Date.now()): boolean { + const entry = this.failures.get(ip); + if (!entry) return false; + if (now - entry.windowStart > this.limit.windowMs) { + this.failures.delete(ip); + return false; + } + return entry.count >= this.limit.max; + } + + recordFailure(ip: string, now = Date.now()) { + const entry = this.failures.get(ip); + if (!entry || now - entry.windowStart > this.limit.windowMs) { + this.failures.set(ip, { count: 1, windowStart: now }); + } else { + entry.count++; + } + } + + prune(now = Date.now()) { + for (const [ip, entry] of this.failures) { + if (now - entry.windowStart > this.limit.windowMs) this.failures.delete(ip); + } + } + + close() { + clearInterval(this.pruneTimer); + } +} diff --git a/packages/server/src/websocket/handler.ts b/packages/server/src/websocket/handler.ts index e4b6cdd..3b16fad 100644 --- a/packages/server/src/websocket/handler.ts +++ b/packages/server/src/websocket/handler.ts @@ -2,6 +2,7 @@ import { FastifyInstance } from 'fastify'; import { WebSocket } from 'ws'; import { RoomManager, RoomError } from '../rooms/roomManager.js'; import { SntpClockEngine } from '../sntp/clock.js'; +import { RoomLookupGuard, LookupLimit, DEFAULT_JOIN_FAILURE_LIMIT } from '../security/lookupGuard.js'; import { clientMessageSchema, formatValidationError, ErrorCode, ServerMessage } from '@streamcollab/shared'; export const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000; @@ -12,15 +13,19 @@ export const WS_RATE_LIMIT = { burst: 40, refillPerSecond: 20 }; export interface WebSocketHandlerOptions { heartbeatIntervalMs?: number; // Unknown room codes tolerated per IP and window before joins from that IP are refused - joinFailureLimit?: { max: number; windowMs: number }; + joinFailureLimit?: LookupLimit; + // Shared with the REST API so both count the same guesses (created here when omitted) + lookupGuard?: RoomLookupGuard; unjoinedTimeoutMs?: number; + maxSocketsPerIp?: number; } +export { DEFAULT_JOIN_FAILURE_LIMIT }; + export const DEFAULT_UNJOINED_SOCKET_TIMEOUT_MS = 15_000; -// Room codes are the invite, so stop clients that try many codes (they can't be throttled -// per socket alone, since opening new sockets is free) -export const DEFAULT_JOIN_FAILURE_LIMIT = { max: 20, windowMs: 60_000 }; +// A squad behind one home router opens a dock, a HUD and maybe a dashboard per streamer +export const DEFAULT_MAX_SOCKETS_PER_IP = 64; export function registerWebSocketHandler( fastify: FastifyInstance, @@ -46,39 +51,36 @@ export function registerWebSocketHandler( clearInterval(heartbeat); }); - const joinFailureLimit = options.joinFailureLimit ?? DEFAULT_JOIN_FAILURE_LIMIT; - const failedJoins = new Map(); - const joinsBlocked = (ip: string) => { - const entry = failedJoins.get(ip); - if (!entry) return false; - if (Date.now() - entry.windowStart > joinFailureLimit.windowMs) { - failedJoins.delete(ip); - return false; - } - return entry.count >= joinFailureLimit.max; - }; - const recordFailedJoin = (ip: string) => { - const now = Date.now(); - const entry = failedJoins.get(ip); - if (!entry || now - entry.windowStart > joinFailureLimit.windowMs) { - failedJoins.set(ip, { count: 1, windowStart: now }); - } else { - entry.count++; - } - }; - const pruneFailedJoins = setInterval(() => { - const now = Date.now(); - for (const [ip, entry] of failedJoins) { - if (now - entry.windowStart > joinFailureLimit.windowMs) failedJoins.delete(ip); - } - }, joinFailureLimit.windowMs); - pruneFailedJoins.unref(); + const ownsGuard = !options.lookupGuard; + const lookupGuard = + options.lookupGuard ?? new RoomLookupGuard(options.joinFailureLimit ?? DEFAULT_JOIN_FAILURE_LIMIT); fastify.addHook('onClose', async () => { - clearInterval(pruneFailedJoins); + if (ownsGuard) lookupGuard.close(); }); + const unjoinedTimeoutMs = options.unjoinedTimeoutMs ?? DEFAULT_UNJOINED_SOCKET_TIMEOUT_MS; + const maxSocketsPerIp = options.maxSocketsPerIp ?? DEFAULT_MAX_SOCKETS_PER_IP; + const socketsPerIp = new Map(); + // Sockets the room manager detached without closing them (their room was deleted) start the + // unjoined countdown again + const rearmUnjoined = new Map void>(); + roomManager.onClientDetached((socket) => rearmUnjoined.get(socket)?.()); + fastify.get('/ws', { websocket: true }, (socket: WebSocket, req) => { const ip = req.ip; + + const open = (socketsPerIp.get(ip) ?? 0) + 1; + if (open > maxSocketsPerIp) { + const refusal: ServerMessage = { + type: 'ERROR', + payload: { code: 'RATE_LIMITED', message: 'Too many connections from your network' }, + }; + socket.send(JSON.stringify(refusal)); + socket.close(1008, 'Too many connections'); + return; + } + socketsPerIp.set(ip, open); + liveSockets.set(socket, true); socket.on('pong', () => { liveSockets.set(socket, true); @@ -91,15 +93,24 @@ export function registerWebSocketHandler( }; const sendError = (code: ErrorCode, message: string) => send({ type: 'ERROR', payload: { code, message } }); - // Unjoined socket timeout (15s default): auto-close connections that do not join a room - const unjoinedTimeoutMs = options.unjoinedTimeoutMs ?? DEFAULT_UNJOINED_SOCKET_TIMEOUT_MS; - const unjoinedTimer = setTimeout(() => { - if (!roomManager.getClient(socket)) { - sendError('TIMEOUT', 'Socket did not join a room within 15 seconds'); - socket.close(1008, 'Join timeout'); - } - }, unjoinedTimeoutMs); - unjoinedTimer.unref(); + // A socket in no room (never joined, left, or its room was deleted) is closed after a while + let unjoinedTimer: ReturnType | null = null; + const clearUnjoinedTimer = () => { + if (unjoinedTimer) clearTimeout(unjoinedTimer); + unjoinedTimer = null; + }; + const armUnjoinedTimer = () => { + clearUnjoinedTimer(); + unjoinedTimer = setTimeout(() => { + if (!roomManager.getClient(socket)) { + sendError('TIMEOUT', `Socket did not join a room within ${Math.round(unjoinedTimeoutMs / 1000)} seconds`); + socket.close(1008, 'Join timeout'); + } + }, unjoinedTimeoutMs); + unjoinedTimer.unref(); + }; + armUnjoinedTimer(); + rearmUnjoined.set(socket, armUnjoinedTimer); let tokens: number = WS_RATE_LIMIT.burst; let lastRefill = Date.now(); @@ -140,7 +151,7 @@ export function registerWebSocketHandler( } if (message.type === 'JOIN_ROOM') { - if (joinsBlocked(ip)) { + if (lookupGuard.isBlocked(ip)) { sendError('RATE_LIMITED', 'Too many unknown room codes from your network; try again in a minute'); socket.close(1008, 'Too many failed joins'); return; @@ -148,9 +159,9 @@ export function registerWebSocketHandler( const { roomId, member, clientKind, memberToken, hostToken } = message.payload; try { roomManager.joinRoom(socket, roomId, clientKind, member, { memberToken, hostToken }); - clearTimeout(unjoinedTimer); + clearUnjoinedTimer(); } catch (err) { - if (err instanceof RoomError && err.code === 'ROOM_NOT_FOUND') recordFailedJoin(ip); + if (err instanceof RoomError && err.code === 'ROOM_NOT_FOUND') lookupGuard.recordFailure(ip); throw err; } return; @@ -158,6 +169,7 @@ export function registerWebSocketHandler( if (message.type === 'LEAVE_ROOM') { roomManager.removeClient(socket); + armUnjoinedTimer(); return; } @@ -252,9 +264,17 @@ export function registerWebSocketHandler( } }); + // 'error' is followed by 'close': release the per-IP slot once + let cleanedUp = false; const cleanup = () => { - clearTimeout(unjoinedTimer); + if (cleanedUp) return; + cleanedUp = true; + clearUnjoinedTimer(); + rearmUnjoined.delete(socket); liveSockets.delete(socket); + const remaining = (socketsPerIp.get(ip) ?? 1) - 1; + if (remaining > 0) socketsPerIp.set(ip, remaining); + else socketsPerIp.delete(ip); roomManager.removeClient(socket); }; diff --git a/packages/server/test/audit-v162.test.ts b/packages/server/test/audit-v162.test.ts new file mode 100644 index 0000000..f21e703 --- /dev/null +++ b/packages/server/test/audit-v162.test.ts @@ -0,0 +1,454 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest'; +import path from 'node:path'; +import fs from 'node:fs'; +import { DatabaseSync } from 'node:sqlite'; +import { WebSocket } from 'ws'; +import { createServer } from '../src/index.js'; +import { DatabaseService, SCHEMA_VERSION, RESUME_AFTER_CLEAN_SHUTDOWN_MS } from '../src/db/database.js'; +import { RoomManager, RoomError } from '../src/rooms/roomManager.js'; +import { attachmentDisposition } from '../src/routes/api.js'; +import { loadConfig } from '../src/config.js'; + +function removeDb(dbPath: string) { + for (const suffix of ['', '-wal', '-shm']) { + if (fs.existsSync(`${dbPath}${suffix}`)) fs.unlinkSync(`${dbPath}${suffix}`); + } +} + +interface TestClient { + ws: WebSocket; + messages: any[]; + closed: Promise; + waitFor: (predicate: (msg: any) => boolean, timeoutMs?: number) => Promise; + send: (msg: unknown) => void; +} + +function connectTo(wsUrl: string, open: WebSocket[]) { + return new Promise((resolve, reject) => { + const ws = new WebSocket(wsUrl); + open.push(ws); + const messages: any[] = []; + ws.on('message', (data) => messages.push(JSON.parse(data.toString()))); + const closed = new Promise((res) => ws.once('close', (code) => res(code))); + const client: TestClient = { + ws, + messages, + closed, + send: (msg) => ws.send(JSON.stringify(msg)), + waitFor: (predicate, timeoutMs = 2000) => + new Promise((res, rej) => { + const started = Date.now(); + const check = () => { + const index = messages.findIndex(predicate); + if (index >= 0) return res(messages.splice(index, 1)[0]); + if (Date.now() - started > timeoutMs) return rej(new Error('Timed out waiting for message')); + setTimeout(check, 5); + }; + check(); + }), + }; + ws.once('open', () => resolve(client)); + ws.once('error', reject); + }); +} + +const dockMember = (id: string) => ({ + id, + name: id, + role: 'player' as const, + obsStatus: 'stopped' as const, + fps: 60, + outputDurationMs: 0, +}); + +describe('v1.6.2 database integrity', () => { + const dbPath = path.resolve(process.cwd(), 'test-audit-v162-db.sqlite'); + let db: DatabaseService; + let rooms: RoomManager; + + beforeEach(() => { + removeDb(dbPath); + db = new DatabaseService(dbPath); + rooms = new RoomManager(db); + }); + + afterEach(() => { + db.close(); + removeDb(dbPath); + }); + + it('keeps transactions working after a BEGIN that failed (e.g. SQLITE_BUSY)', () => { + const raw = (db as any).db as DatabaseSync; + const realExec = raw.exec.bind(raw); + let failNextBegin = true; + raw.exec = (sql: string) => { + if (failNextBegin && sql === 'BEGIN IMMEDIATE') { + failNextBegin = false; + throw new Error('database is locked'); + } + return realExec(sql); + }; + + expect(() => db.transaction(() => db.createRoom('LOCKED-1', 'x'))).toThrow('database is locked'); + expect(db.roomExists('LOCKED-1')).toBe(false); + + // The next transaction is a real one again: a failure inside it rolls everything back + expect(() => + db.transaction(() => { + db.createRoom('ROLLED-BACK', 'x'); + throw new Error('boom'); + }) + ).toThrow('boom'); + expect(db.roomExists('ROLLED-BACK')).toBe(false); + raw.exec = realExec; + }); + + it('keeps the original error when SQLite already ended the transaction', () => { + const raw = (db as any).db as DatabaseSync; + expect(() => + db.transaction(() => { + raw.exec('ROLLBACK'); // what SQLite does by itself on SQLITE_FULL / IOERR + throw new Error('disk full'); + }) + ).toThrow('disk full'); + // and the service is usable afterwards + db.transaction(() => db.createRoom('AFTER-FULL', 'x')); + expect(db.roomExists('AFTER-FULL')).toBe(true); + }); + + it('runs afterCommit callbacks only once the transaction commits', () => { + const ran: string[] = []; + db.transaction(() => { + db.afterCommit(() => ran.push('committed')); + expect(ran).toEqual([]); + }); + expect(ran).toEqual(['committed']); + + expect(() => + db.transaction(() => { + db.afterCommit(() => ran.push('rolled back')); + throw new Error('boom'); + }) + ).toThrow('boom'); + expect(ran).toEqual(['committed']); + + db.afterCommit(() => ran.push('no transaction')); + expect(ran).toEqual(['committed', 'no transaction']); + }); + + it('stopping an old session neither moves its end nor orphans the running one', () => { + rooms.createRoom('STOP-ROOM', 'x'); + const first = rooms.startSession('STOP-ROOM', { name: 'first' }); + const stoppedFirst = rooms.stopSession('STOP-ROOM', first.id)!; + const second = rooms.startSession('STOP-ROOM', { name: 'second' }); + + const again = rooms.stopSession('STOP-ROOM', first.id); + expect(again?.stoppedAt).toBe(stoppedFirst.stoppedAt); + expect(db.getActiveSession('STOP-ROOM')?.id).toBe(second.id); + expect(db.getSession(second.id)?.stoppedAt).toBeNull(); + }); + + it('never stores a marker in a session that already stopped', () => { + rooms.createRoom('LATE-ROOM', 'x'); + const stopped = rooms.startSession('LATE-ROOM', { name: 'ended' }); + rooms.stopSession('LATE-ROOM', stopped.id); + const running = rooms.startSession('LATE-ROOM', { name: 'running' }); + + const marker = rooms.createMarker({ + roomId: 'LATE-ROOM', + sessionId: stopped.id, // a dock that hasn't seen SESSION_STOPPED yet + memberId: 'm1', + memberName: 'm1', + category: 'kill', + label: 'late', + }); + expect(marker.sessionId).toBe(running.id); + expect(db.getSessionMarkers(stopped.id)).toEqual([]); + }); + + it('undo only reaches markers of the running session', () => { + rooms.createRoom('UNDO-ROOM', 'x'); + rooms.startSession('UNDO-ROOM', { name: 'ended' }); + rooms.createMarker({ roomId: 'UNDO-ROOM', memberId: 'm1', memberName: 'm1', category: 'kill', label: 'keep' }); + rooms.stopSession('UNDO-ROOM', db.getActiveSession('UNDO-ROOM')!.id); + + expect(() => rooms.undoLastMarker('UNDO-ROOM', 'm1')).toThrow(RoomError); + expect(db.getRoomMarkers('UNDO-ROOM')).toHaveLength(1); + }); + + it('validates the merged framerate of a partial status update', () => { + rooms.createRoom('FPS-ROOM', 'x'); + db.upsertMember('FPS-ROOM', { + ...dockMember('m-fps'), + clockOffsetMs: 0, + rttMs: 0, + lastHeartbeat: Date.now(), + fpsNumerator: 30000, + fpsDenominator: 1001, + }); + expect(() => rooms.updateMember('FPS-ROOM', 'm-fps', { fpsNumerator: 1 })).toThrow(/1-1000 fps/); + expect(db.getRoomMembers('FPS-ROOM')[0].fpsNumerator).toBe(30000); + rooms.updateMember('FPS-ROOM', 'm-fps', { fpsNumerator: 60000 }); + expect(db.getRoomMembers('FPS-ROOM')[0].fpsNumerator).toBe(60000); + }); + + it('broadcasts SESSION_STOPPED only after the session switch committed', () => { + rooms.createRoom('ORDER-ROOM', 'x'); + const first = rooms.startSession('ORDER-ROOM', { name: 'first' }); + const sent: string[] = []; + rooms.broadcastToRoom = (_roomId, message) => { + sent.push(`${message.type}:${db.getActiveSession('ORDER-ROOM')?.name ?? 'none'}`); + }; + + // The new session fails to be written: the old one must keep running and nobody hears it stopped + const createSession = db.createSession.bind(db); + db.createSession = () => { + throw new Error('insert failed'); + }; + expect(() => rooms.startSession('ORDER-ROOM', { name: 'second' })).toThrow('insert failed'); + expect(sent).toEqual([]); + expect(db.getActiveSession('ORDER-ROOM')?.id).toBe(first.id); + expect(db.getSession(first.id)?.stoppedAt).toBeNull(); + + db.createSession = createSession; + rooms.startSession('ORDER-ROOM', { name: 'second' }); + expect(sent).toEqual(['SESSION_STOPPED:second', 'SESSION_STARTED:second']); + }); +}); + +describe('v1.6.2 restart recovery and migration v5', () => { + const dbPath = path.resolve(process.cwd(), 'test-audit-v162-restart.sqlite'); + + beforeEach(() => removeDb(dbPath)); + afterEach(() => removeDb(dbPath)); + + const seed = () => { + const db = new DatabaseService(dbPath); + const rooms = new RoomManager(db); + rooms.createRoom('RESTART-ROOM', 'x'); + const session = rooms.startSession('RESTART-ROOM', { name: 'live' }); + db.upsertMember('RESTART-ROOM', { + ...dockMember('m-rec'), + obsStatus: 'recording', + clockOffsetMs: 0, + rttMs: 0, + lastHeartbeat: Date.now(), + recordStartedAt: session.startedAt, + }); + return { db, rooms, session }; + }; + + it('resumes running sessions after a recent clean shutdown', () => { + const { db, session } = seed(); + const shutdownAt = Date.now(); + db.markCleanShutdown(shutdownAt); + db.close(); + + const reopened = new DatabaseService(dbPath); + const result = reopened.recoverAfterRestart(shutdownAt + 60_000); + expect(result).toEqual({ resumed: true, closedCount: 0 }); + expect(reopened.getActiveSession('RESTART-ROOM')?.id).toBe(session.id); + // Nobody's dock is connected yet: no stale "recording" member can anchor a new session + expect(reopened.getRoomMembers('RESTART-ROOM')[0].obsStatus).toBe('disconnected'); + + // The flag is consumed: a crash right after this start is a crash + reopened.close(); + const third = new DatabaseService(dbPath); + expect(third.recoverAfterRestart(shutdownAt + 120_000).resumed).toBe(false); + third.close(); + }); + + it('closes sessions at the last heartbeat after a crash, not at the last marker', () => { + const { db, session } = seed(); + const lastAlive = session.startedAt + 2 * 60 * 60_000; // ran for two hours with no marker + db.markAlive(lastAlive); + db.close(); + + const reopened = new DatabaseService(dbPath); + expect(reopened.recoverAfterRestart(lastAlive + 5_000)).toEqual({ resumed: false, closedCount: 1 }); + expect(reopened.getSession(session.id)?.stoppedAt).toBe(lastAlive); + expect(reopened.getActiveSession('RESTART-ROOM')).toBeNull(); + reopened.close(); + }); + + it('treats a clean shutdown long ago like a crash', () => { + const { db, session } = seed(); + const shutdownAt = session.startedAt + 30_000; + db.markCleanShutdown(shutdownAt); + db.close(); + + const reopened = new DatabaseService(dbPath); + const result = reopened.recoverAfterRestart(shutdownAt + RESUME_AFTER_CLEAN_SHUTDOWN_MS + 1); + expect(result.resumed).toBe(false); + expect(reopened.getSession(session.id)?.stoppedAt).toBe(shutdownAt); + reopened.close(); + }); + + it('migration v5 repairs sessions that v4 closed before they started', () => { + const { db, session } = seed(); + db.stopSession(session.id); + db.close(); + + // Rewind the file to schema v4 with the corruption v4's orphan clean-up could produce + const raw = new DatabaseSync(dbPath); + raw.exec(`UPDATE recording_sessions SET stopped_at = started_at - 1500 WHERE id = '${session.id}'`); + raw.exec('DROP TABLE server_state'); + raw.exec('PRAGMA user_version = 4'); + raw.close(); + + const migrated = new DatabaseService(dbPath); + expect(migrated.getSchemaVersion()).toBe(SCHEMA_VERSION); + expect(migrated.getSession(session.id)?.stoppedAt).toBe(session.startedAt); + migrated.close(); + }); + + it('refuses a database written by a newer StreamCollab', () => { + const { db } = seed(); + db.close(); + const raw = new DatabaseSync(dbPath); + raw.exec(`PRAGMA user_version = ${SCHEMA_VERSION + 1}`); + raw.close(); + expect(() => new DatabaseService(dbPath)).toThrow(/newer than this StreamCollab build supports/); + }); +}); + +describe('v1.6.2 HTTP and WebSocket hardening', () => { + const dbPath = path.resolve(process.cwd(), 'test-audit-v162-net.sqlite'); + let app: any; + let db: DatabaseService; + let wsUrl = ''; + const open: WebSocket[] = []; + const connect = () => connectTo(wsUrl, open); + + beforeAll(async () => { + removeDb(dbPath); + const server = await createServer({ + dbPath, + logger: false, + unjoinedTimeoutMs: 300, + joinFailureLimit: { max: 3, windowMs: 60_000 }, + maxSocketsPerIp: 6, + }); + app = server.fastify; + db = server.db; + const address = await app.listen({ port: 0, host: '127.0.0.1' }); + wsUrl = `${address.replace('http', 'ws')}/ws`; + }); + + afterAll(async () => { + open.forEach((ws) => ws.terminate()); + await app.close(); + db.close(); + removeDb(dbPath); + }); + + afterEach(async () => { + // Every test starts with no open sockets (they count towards the per-IP cap) + open.splice(0).forEach((ws) => ws.terminate()); + await new Promise((r) => setTimeout(r, 50)); + }); + + const createRoom = async (payload: Record = {}) => { + const res = await app.inject({ method: 'POST', url: '/api/rooms', payload }); + return { status: res.statusCode, body: JSON.parse(res.payload) }; + }; + + it('rejects an export for a session that does not exist instead of exporting another one', async () => { + const { body: room } = await createRoom(); + await app.inject({ + method: 'POST', + url: `/api/rooms/${room.id}/session/start`, + payload: {}, + headers: { authorization: `Bearer ${room.hostToken}` }, + }); + const missing = await app.inject({ method: 'GET', url: `/api/rooms/${room.id}/export/edl?sessionId=nope` }); + expect(missing.statusCode).toBe(404); + const latest = await app.inject({ method: 'GET', url: `/api/rooms/${room.id}/export/edl` }); + expect(latest.statusCode).toBe(200); + }); + + it('answers 400 (not 500) to a repeated query parameter', async () => { + const { body: room } = await createRoom(); + for (const url of [ + `/api/rooms/${room.id}/markers?sessionId=a&sessionId=b`, + `/api/rooms/${room.id}/export/csv?sessionId=a&sessionId=b`, + ]) { + expect((await app.inject({ method: 'GET', url })).statusCode).toBe(400); + } + }); + + it('requires custom room codes at least as long as generated ones', async () => { + expect((await createRoom({ id: 'ABC' })).status).toBe(400); + expect((await createRoom({ id: 'ABCD-1234' })).status).toBe(201); + }); + + it('closes a socket that left its room once the unjoined timeout passes', async () => { + const { body: room } = await createRoom(); + const hud = await connect(); + hud.send({ type: 'JOIN_ROOM', payload: { roomId: room.id, clientKind: 'hud' } }); + await hud.waitFor((m) => m.type === 'JOINED'); + hud.send({ type: 'LEAVE_ROOM' }); + expect((await hud.waitFor((m) => m.type === 'ERROR', 2000)).payload.code).toBe('TIMEOUT'); + expect(await hud.closed).toBe(1008); + }); + + it('closes sockets of a deleted room once the unjoined timeout passes', async () => { + const { body: room } = await createRoom(); + const hud = await connect(); + hud.send({ type: 'JOIN_ROOM', payload: { roomId: room.id, clientKind: 'hud' } }); + await hud.waitFor((m) => m.type === 'JOINED'); + await app.inject({ + method: 'DELETE', + url: `/api/rooms/${room.id}`, + headers: { authorization: `Bearer ${room.hostToken}` }, + }); + await hud.waitFor((m) => m.type === 'ROOM_DELETED'); + expect((await hud.waitFor((m) => m.type === 'ERROR', 2000)).payload.code).toBe('TIMEOUT'); + expect(await hud.closed).toBe(1008); + }); + + it('caps simultaneous sockets per IP', async () => { + const sockets = await Promise.all(Array.from({ length: 6 }, () => connect())); + const extra = await connect(); + expect((await extra.waitFor((m) => m.type === 'ERROR')).payload.code).toBe('RATE_LIMITED'); + expect(await extra.closed).toBe(1008); + + // A slot frees up when a socket closes + sockets[0].ws.close(); + await sockets[0].closed; + await new Promise((r) => setTimeout(r, 50)); + const replacement = await connect(); + replacement.send({ type: 'SNTP_PING', payload: { t1: Date.now() } }); + expect((await replacement.waitFor((m) => m.type === 'SNTP_PONG')).type).toBe('SNTP_PONG'); + }); + + // Last: it blocks this IP for the rest of the window + it('counts unknown room codes over HTTP and WebSocket together', async () => { + for (let i = 0; i < 3; i++) { + expect((await app.inject({ method: 'GET', url: `/api/rooms/GUESS-000${i}` })).statusCode).toBe(404); + } + expect((await app.inject({ method: 'GET', url: '/api/rooms/GUESS-0003' })).statusCode).toBe(429); + expect((await app.inject({ method: 'GET', url: '/api/rooms/GUESS-0003/export/edl' })).statusCode).toBe(429); + + const ws = await connect(); + ws.send({ type: 'JOIN_ROOM', payload: { roomId: 'GUESS-0004', clientKind: 'hud' } }); + expect((await ws.waitFor((m) => m.type === 'ERROR')).payload.code).toBe('RATE_LIMITED'); + }); +}); + +describe('v1.6.2 small fixes', () => { + it('treats empty PORT / HOST / LOG_LEVEL / ROOM_CREATE_LIMIT_PER_MINUTE as unset', () => { + expect(loadConfig({ PORT: '', HOST: '', LOG_LEVEL: '', ROOM_CREATE_LIMIT_PER_MINUTE: ' ' })).toMatchObject({ + port: 3000, + host: '0.0.0.0', + logLevel: 'info', + roomCreateLimitPerMinute: 10, + }); + }); + + it("percent-encodes ' ( ) * in the RFC 5987 file name", () => { + expect(attachmentDisposition("Rock'n (roll)*.edl")).toBe( + `attachment; filename="Rock'n_(roll).edl"; filename*=UTF-8''Rock%27n_%28roll%29.edl` + ); + }); +}); diff --git a/packages/shared/src/schemas/index.ts b/packages/shared/src/schemas/index.ts index 24d9299..70eed46 100644 --- a/packages/shared/src/schemas/index.ts +++ b/packages/shared/src/schemas/index.ts @@ -65,10 +65,14 @@ export const markerColorSchema = z.enum(DAVINCI_MARKER_COLORS); export const clientKindSchema = z.enum(CLIENT_KINDS); export const obsStatusSchema = z.enum(['recording', 'streaming', 'paused', 'stopped', 'disconnected']); -// The exact OBS canvas rate a dock reports must describe 1..1000 fps (it times that streamer's exports) +/** The exact OBS canvas rate a dock reports must describe 1..1000 fps (it times that streamer's exports) */ +export function isPlausibleObsFramerate(num: number | undefined, den: number | undefined): boolean { + if (num === undefined || den === undefined) return true; + return num / den >= 1 && num / den <= 1000; +} + function refineObsFramerate(value: { fpsNumerator?: number; fpsDenominator?: number }, ctx: z.RefinementCtx) { - const { fpsNumerator: num, fpsDenominator: den } = value; - if (num !== undefined && den !== undefined && !(num / den >= 1 && num / den <= 1000)) { + if (!isPlausibleObsFramerate(value.fpsNumerator, value.fpsDenominator)) { ctx.addIssue({ code: 'custom', path: ['fpsNumerator'], @@ -203,10 +207,18 @@ export const clientMessageSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('REMOVE_MEMBER'), payload: z.object({ memberId: idSchema }) }), ]); +// A custom code chosen at creation is the room's only secret: at least as long as a generated one +// ("KQ7P-M3XD"). Existing shorter codes still work for joining. +export const MIN_CUSTOM_ROOM_ID_LENGTH = 8; + // REST bodies export const createRoomBodySchema = z .object({ - id: roomIdSchema.optional(), + id: roomIdSchema + .refine((id) => id.length >= MIN_CUSTOM_ROOM_ID_LENGTH, { + message: `A custom room code needs at least ${MIN_CUSTOM_ROOM_ID_LENGTH} characters`, + }) + .optional(), name: text(LIMITS.roomName).min(1).optional(), }) .default({}); @@ -219,6 +231,16 @@ export const undoMarkerBodySchema = z.object({ memberId: idSchema }); export const stopSessionBodySchema = z.object({ sessionId: z.string().max(64).optional() }).default({}); +// Query strings: an empty value means "not given"; a repeated parameter (`?sessionId=a&sessionId=b`) +// arrives as an array and is rejected +const optionalQueryParam = (schema: T) => + z.preprocess((v) => (v === '' ? undefined : v), schema.optional()); +export const markersQuerySchema = z.object({ sessionId: optionalQueryParam(z.string().max(64)) }); +export const exportQuerySchema = z.object({ + sessionId: optionalQueryParam(z.string().max(64)), + memberId: optionalQueryParam(idSchema), +}); + export type ParsedClientMessage = z.infer; /** Flatten a ZodError into one readable line for API / WebSocket error payloads */ From 90b44159fd9bdd9071278e1e7b2c7de3bcf29d18 Mon Sep 17 00:00:00 2001 From: Cristian Date: Fri, 23 Oct 2026 22:02:47 -0500 Subject: [PATCH 03/32] fix(shared): DaVinci Resolve marker EDL layout and drop-frame base timecode in FCP XML - The DaVinci Resolve export now writes the layout Resolve itself produces with Timeline Markers to EDL (one-frame event + |C:ResolveColor |M: |D:1) instead of Avid-style * LOC: locators; '|' in names is replaced, White maps to Cream (Resolve has no white marker) - FCP 7 XML writes the base timecode with ';' for drop-frame sessions - Golden-layout and property tests for the marker EDL; import steps updated in both languages --- README.es.md | 11 +- README.md | 11 +- docs/local-testing.es.md | 2 +- docs/local-testing.md | 2 +- e2e/squad-extended.spec.ts | 5 +- packages/server/test/markers.test.ts | 2 +- packages/server/test/server.test.ts | 2 +- packages/server/test/ws.test.ts | 4 +- packages/shared/src/exporters/edl.ts | 61 ++++++-- packages/shared/src/exporters/xml.ts | 2 +- packages/shared/test/edge-cases.test.ts | 10 +- .../shared/test/exporters-conformance.test.ts | 11 +- packages/shared/test/exporters-v162.test.ts | 132 ++++++++++++++++++ packages/shared/test/exporters.test.ts | 14 +- packages/shared/test/perMember.test.ts | 2 +- 15 files changed, 225 insertions(+), 46 deletions(-) create mode 100644 packages/shared/test/exporters-v162.test.ts diff --git a/README.es.md b/README.es.md index 7d3d1ee..7980ee1 100644 --- a/README.es.md +++ b/README.es.md @@ -45,7 +45,7 @@ StreamCollab permite que squads de juego competitivo, grupos de creadores y dire - Timecode base configurable (p. ej. `01:00:00:00` o `00:00:00:00`). - **Exportadores para editores (NLE)**: - - 🎬 **EDL CMX 3600 para DaVinci Resolve**: saltos de línea CRLF estrictos, eventos de edición de 1 fotograma numerados (`001`, `002`...) y localizadores `* LOC:` con los colores nativos de marcador de DaVinci (Red, Fuchsia, Yellow, Cyan, Blue, Green), acotados contra duraciones negativas de fotogramas. + - 🎬 **EDL de marcadores para DaVinci Resolve**: el formato que escribe el propio Resolve con *Timeline Markers to EDL* (un evento CMX 3600 de 1 fotograma por marcador seguido de `|C:ResolveColor |M: |D:1`), saltos de línea CRLF y los colores de marcador de Resolve (Red, Fuchsia, Yellow, Cyan, Blue, Green según la categoría). - 🎞️ **XML FCP 7 (`xmeml` v4) para Adobe Premiere Pro**: XML de Final Cut Pro 7 válido con eliminación de caracteres de control, con bloques `` completos y etiquetas `` en la pista con el identificador de la categoría delante. - 📊 **CSV universal**: CSV conforme a RFC 4180, compatible con Google Sheets, Microsoft Excel y Markerbox. Patrones de inyección de fórmulas (`/^\s*[=+\-@|]|[\t\r]/`) neutralizados de forma segura. - 📺 **Capítulos de YouTube**: formato limpio para la descripción (`00:00 Intro\n01:23 First Blood`) que siempre empieza en `00:00` y respeta la duración mínima de 10 segundos por capítulo de YouTube. @@ -244,10 +244,11 @@ El HUD y el Panel del director nunca hablan con el WebSocket de OBS, así que si ### DaVinci Resolve 1. En la Dock o en el Panel del director, pulsa **Exportar NLE** → elige **DaVinci Resolve**. 2. Pulsa **Descargar archivo** (`.edl`). -3. En DaVinci Resolve: - - Ve a **File** → **Import** → **Timeline...** (o clic derecho en la línea de tiempo → **Timelines** → **Import** → **EDL/XML...**). - - Elige el archivo `.edl` descargado. - - DaVinci Resolve crea marcadores en la línea de tiempo con el color nativo de cada tipo de evento (rojo para kills, fucsia para wipes, amarillo para clutches, cian para momentos graciosos, azul para notas). +3. En DaVinci Resolve, pon tu grabación en una línea de tiempo cuyo timecode inicial coincida con el timecode base de la sesión (01:00:00:00 por defecto; 00:00:00:00 en un export por POV). +4. En el Media Pool, clic derecho en esa línea de tiempo → **Timelines** → **Import** → **Timeline Markers from EDL...** y elige el `.edl` descargado. + - Cada marcador lleva el color de su tipo de evento (rojo para kills, fucsia para wipes, amarillo para clutches, cian para momentos graciosos, azul para notas, verde para los personalizados). Si no aparece ningún marcador, aleja el zoom: pueden estar fuera de la parte visible de la línea de tiempo. + +Desde la v1.6.2 el archivo usa el formato de EDL de marcadores del propio Resolve (las versiones anteriores escribían localizadores `* LOC:` al estilo de Avid). Los títulos del EDL son ASCII puro: las letras acentuadas se simplifican (una sesión llamada "Sesión" pasa a "Sesion"). diff --git a/README.md b/README.md index 84af51d..504c113 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ StreamCollab allows competitive gaming squads, content creator groups, and espor - Configurable base timecode offsets (e.g. `01:00:00:00` or `00:00:00:00`). - **NLE Exporter Suite**: - - 🎬 **DaVinci Resolve CMX 3600 EDL**: Strict CRLF line endings, sequential 1-frame edit events (`001`, `002`...), and `* LOC:` locator syntax mapped to native DaVinci marker colors (Red, Fuchsia, Yellow, Cyan, Blue, Green), clamped against negative frame durations. + - 🎬 **DaVinci Resolve marker EDL**: the layout Resolve itself writes with *Timeline Markers to EDL* (a 1-frame CMX 3600 event per marker followed by `|C:ResolveColor |M: |D:1`), CRLF line endings, and Resolve's marker colors (Red, Fuchsia, Yellow, Cyan, Blue, Green by category). - 🎞️ **Adobe Premiere Pro FCP 7 XML (`xmeml` v4)**: Valid Final Cut Pro 7 XML with stripped control characters, complete `` video format blocks and track-level `` tags prefixed with category identifiers. - 📊 **Universal CSV**: RFC 4180 compliant CSV compatible with Google Sheets, Microsoft Excel, and Markerbox. Formula injection triggers (`/^\s*[=+\-@|]|[\t\r]/`) are safely neutralized. - 📺 **YouTube Video Chapters**: Clean description chapter format (`00:00 Intro\n01:23 First Blood`) that always starts at `00:00` and keeps YouTube's 10-second minimum chapter length. @@ -242,10 +242,11 @@ The HUD and Director Panel never talk to OBS's WebSocket, so they can always be ### DaVinci Resolve 1. In StreamCollab Dock or Director Panel, click **Export NLE** -> select **DaVinci Resolve**. 2. Click **Download File** (`.edl`). -3. In DaVinci Resolve: - - Go to **File** -> **Import** -> **Timeline...** (or right-click timeline -> **Timelines** -> **Import** -> **EDL/XML...**). - - Select the downloaded `.edl` file. - - DaVinci Resolve will create timeline markers with native colors corresponding to each event type (Red for Kills, Fuchsia for Wipes, Yellow for Clutches, Cyan for Funny, Blue for Notes). +3. In DaVinci Resolve, put your recording on a timeline whose start timecode matches the session's base timecode (01:00:00:00 by default; 00:00:00:00 for a per-POV export). +4. In the Media Pool, right-click that timeline -> **Timelines** -> **Import** -> **Timeline Markers from EDL...** and select the downloaded `.edl`. + - Each marker gets the color of its event type (Red for Kills, Fuchsia for Wipes, Yellow for Clutches, Cyan for Funny, Blue for Notes, Green for custom). If no marker appears, zoom out: they may be outside the visible part of the timeline. + +Since v1.6.2 the file uses Resolve's own marker EDL layout (earlier versions wrote Avid-style `* LOC:` locators). EDL titles are plain ASCII: accented letters are folded (a session named "Sesión" becomes "Sesion"). diff --git a/docs/local-testing.es.md b/docs/local-testing.es.md index 7c1b5d8..1f911d2 100644 --- a/docs/local-testing.es.md +++ b/docs/local-testing.es.md @@ -60,7 +60,7 @@ volumen `streamcollab-data`. ## 7. Export a DaVinci Resolve - [ ] Panel del director → **Exportar línea de tiempo** → **Línea de tiempo**: tu nombre (POV) → DaVinci Resolve → descargar `.edl`. -- [ ] Importar el `.edl` con tu grabación: los marcadores caen donde marcaste. +- [ ] Pon tu grabación en una línea de tiempo que empiece en 00:00:00:00 y luego, clic derecho en la línea de tiempo en el Media Pool → **Timelines** → **Import** → **Timeline Markers from EDL...**: los marcadores caen donde marcaste, con sus colores. ## 8. Varias personas sin VPS diff --git a/docs/local-testing.md b/docs/local-testing.md index d5314cc..8eaa658 100644 --- a/docs/local-testing.md +++ b/docs/local-testing.md @@ -60,7 +60,7 @@ Without Node.js, you can use the Docker image from the README (quick start, opti ## 7. Export to DaVinci Resolve - [ ] Director Panel → **Export Timeline** → **Timeline**: your name (POV) → DaVinci Resolve → download the `.edl`. -- [ ] Import the `.edl` together with your recording: the markers land where you marked. +- [ ] Put your recording on a timeline that starts at 00:00:00:00, then right-click the timeline in the Media Pool → **Timelines** → **Import** → **Timeline Markers from EDL...**: the markers land where you marked, with their colors. ## 8. Several people without a VPS diff --git a/e2e/squad-extended.spec.ts b/e2e/squad-extended.spec.ts index 21d97ce..c32cb11 100644 --- a/e2e/squad-extended.spec.ts +++ b/e2e/squad-extended.spec.ts @@ -129,8 +129,9 @@ test.describe('Export downloads validation', () => { expect(edlContent).toContain('\r\n'); expect(edlContent).toMatch(/^TITLE:\s+Export Suite Session/m); expect(edlContent).toMatch(/^FCM:\s+(NON-DROP FRAME|DROP FRAME)/m); - expect(edlContent).toMatch(/\* FROM CLIP NAME:/m); - expect(edlContent).toMatch(/\* LOC:\s+\d\d:\d\d:\d\d:\d\d\s+(Red|Fuchsia|Yellow|Blue)/m); + // DaVinci Resolve marker EDL: one-frame event, then ` |C:ResolveColor |M: |D:1` + expect(edlContent).toMatch(/^\d{3} {2}001 {6}V {5}C {8}\d\d:\d\d:\d\d:\d\d /m); + expect(edlContent).toMatch(/^ \|C:ResolveColor(Red|Fuchsia|Yellow|Blue) \|M:.+ \|D:1\r$/m); // 2. Final Cut Pro XML const xmlContent = await downloadFormat('Premiere Pro', 'xml'); diff --git a/packages/server/test/markers.test.ts b/packages/server/test/markers.test.ts index 401662e..55e5f2d 100644 --- a/packages/server/test/markers.test.ts +++ b/packages/server/test/markers.test.ts @@ -125,7 +125,7 @@ describe('Marker editing, undo and session history', () => { expect(cleared.payload.comment).toBeUndefined(); const edl = await app.inject({ method: 'GET', url: `/api/rooms/${roomId}/export/edl` }); - expect(edl.payload).toContain('Mint Triple Kill'); + expect(edl.payload).toContain('|C:ResolveColorMint |M:Triple Kill'); }); it("does not let a squadmate change someone else's marker, but the host can", async () => { diff --git a/packages/server/test/server.test.ts b/packages/server/test/server.test.ts index 68b8adf..5b901d8 100644 --- a/packages/server/test/server.test.ts +++ b/packages/server/test/server.test.ts @@ -121,7 +121,7 @@ describe('StreamCollab Server & SQLite WAL Integration', () => { expect(res.statusCode).toBe(200); expect(res.headers['content-type']).toContain('text/plain'); expect(res.payload).toContain('TITLE: Tournament Match 1'); - expect(res.payload).toContain('* LOC:'); + expect(res.payload).toContain(' |C:ResolveColor'); expect(res.payload).toContain('Sniper Headshot'); expect(res.payload.includes('\r\n')).toBe(true); }); diff --git a/packages/server/test/ws.test.ts b/packages/server/test/ws.test.ts index 7e7a75e..119c5bf 100644 --- a/packages/server/test/ws.test.ts +++ b/packages/server/test/ws.test.ts @@ -297,7 +297,9 @@ describe('WebSocket rooms, connections and timing', () => { const expectedFrames = Math.floor((expectedMs * 30) / 1000 + 1e-6); const ss = String(Math.floor(expectedFrames / 30)).padStart(2, '0'); const ff = String(expectedFrames % 30).padStart(2, '0'); - expect(edlRes.payload).toContain(`* LOC: 00:00:${ss}:${ff} Red Pov Kill`); + expect(edlRes.payload).toMatch( + new RegExp(`C {8}00:00:${ss}:${ff} \\S+ 00:00:${ss}:${ff} \\S+ {2}\\r\\n \\|C:ResolveColorRed \\|M:Pov Kill`) + ); const matrixRes = await app.inject({ method: 'GET', diff --git a/packages/shared/src/exporters/edl.ts b/packages/shared/src/exporters/edl.ts index 7ab6296..5bfe30a 100644 --- a/packages/shared/src/exporters/edl.ts +++ b/packages/shared/src/exporters/edl.ts @@ -15,6 +15,32 @@ export const CATEGORY_TO_DAVINCI_COLOR: Record = { custom: 'Green', }; +// The 16 timeline marker colors of DaVinci Resolve, as written in its marker EDLs (`ResolveColor`). +// 'White' predates this list and has no Resolve equivalent: Cream is the closest. +const RESOLVE_MARKER_COLORS = new Set([ + 'Blue', + 'Cyan', + 'Green', + 'Yellow', + 'Red', + 'Pink', + 'Purple', + 'Fuchsia', + 'Rose', + 'Lavender', + 'Sky', + 'Mint', + 'Lemon', + 'Sand', + 'Cocoa', + 'Cream', +]); + +export function toResolveMarkerColor(color: string | undefined): string { + if (color === 'White') return 'ResolveColorCream'; + return `ResolveColor${color && RESOLVE_MARKER_COLORS.has(color) ? color : 'Blue'}`; +} + /** EDL header fields are plain ASCII: fold accents ("Sesión" -> "Sesion") before dropping other symbols */ function toEdlAscii(text: string): string { return text @@ -23,8 +49,20 @@ function toEdlAscii(text: string): string { .replace(/[^\w\s-]/g, ''); } +// `|` separates the fields of a marker line and line breaks end it +function toMarkerText(text: string): string { + return text + .replace(/[\r\n|]+/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + /** - * Generate DaVinci Resolve CMX 3600 EDL format with strict CRLF line endings + * DaVinci Resolve timeline markers as an EDL: Timeline > Import > Timeline Markers from EDL. + * + * Same layout as Resolve's own "Export > Timeline Markers to EDL": every marker is a one-frame + * CMX 3600 event followed by ` |C:ResolveColor |M: |D:`, with CRLF + * line endings. */ export function exportToEdl(session: RecordingSession, markers: SquadMarker[], options?: EdlExportOptions): string { const tc = new SMPTETimecode({ @@ -34,7 +72,6 @@ export function exportToEdl(session: RecordingSession, markers: SquadMarker[], o }); const title = toEdlAscii(options?.title || session.name || 'StreamCollab Session'); - const clipName = toEdlAscii(options?.clipName || 'StreamCollab'); const fcm = tc.dropFrame ? 'DROP FRAME' : 'NON-DROP FRAME'; const lines: string[] = [`TITLE: ${title}`, `FCM: ${fcm}`, '']; @@ -48,26 +85,22 @@ export function exportToEdl(session: RecordingSession, markers: SquadMarker[], o const eventNum = String(index + 1).padStart(3, '0'); const elapsedFrames = tc.millisecondsToFrames(marker.relativeTimeMs); const startFrames = baseFrames + elapsedFrames; - const endFrames = startFrames + 1; // 1-frame edit event for marker locator + const endFrames = startFrames + 1; // 1-frame event: the marker's position const startTc = tc.framesToTimecode(startFrames); const endTc = tc.framesToTimecode(endFrames); - const color = marker.color || CATEGORY_TO_DAVINCI_COLOR[marker.category] || 'Blue'; - const cleanLabel = marker.label.replace(/[\r\n]/g, ' ').trim(); - const cleanComment = marker.comment ? ` - ${marker.comment.replace(/[\r\n]/g, ' ').trim()}` : ''; + const color = toResolveMarkerColor(marker.color || CATEGORY_TO_DAVINCI_COLOR[marker.category]); const creatorTag = marker.memberName ? ` [${marker.memberName}]` : ''; + const commentPart = marker.comment ? ` - ${marker.comment}` : ''; + const name = toMarkerText(`${marker.label}${creatorTag}${commentPart}`); - const locatorText = `${cleanLabel}${creatorTag}${cleanComment}`; - - // Standard CMX 3600 record line: - // Event Reel Track Edit In Out RecordIn RecordOut - lines.push(`${eventNum} AX V C ${startTc} ${endTc} ${startTc} ${endTc}`); - lines.push(`* FROM CLIP NAME: ${clipName}`); - lines.push(`* LOC: ${startTc} ${color} ${locatorText}`); + // Event Reel Track Edit SourceIn SourceOut RecordIn RecordOut + lines.push(`${eventNum} 001 V C ${startTc} ${endTc} ${startTc} ${endTc} `); + lines.push(` |C:${color} |M:${name} |D:1`); lines.push(''); }); - // Strict CRLF as required by CMX 3600 specification and DaVinci Resolve parser + // CRLF, as in CMX 3600 EDLs written by NLEs return lines.join('\r\n') + '\r\n'; } diff --git a/packages/shared/src/exporters/xml.ts b/packages/shared/src/exporters/xml.ts index 4c5a3e0..ef2e58d 100644 --- a/packages/shared/src/exporters/xml.ts +++ b/packages/shared/src/exporters/xml.ts @@ -103,7 +103,7 @@ export function exportToFcpXml(session: RecordingSession, markers: SquadMarker[] ${timebase} ${isNtsc} - ${escapeXml(baseTimecode)} + ${escapeXml(tc.framesToTimecode(baseFrames))} ${baseFrames} ${displayFormat} ${markersSection} diff --git a/packages/shared/test/edge-cases.test.ts b/packages/shared/test/edge-cases.test.ts index e9e127a..85c5391 100644 --- a/packages/shared/test/edge-cases.test.ts +++ b/packages/shared/test/edge-cases.test.ts @@ -92,9 +92,13 @@ describe('Edge Cases and Robustness Verification', () => { it('EDL removes newlines to protect CMX 3600 line structure', () => { const edl = exportToEdl(emptySession, maliciousMarkers); const lines = edl.split('\r\n'); - const locLine = lines.find((l) => l.startsWith('* LOC:')); - expect(locLine).toBeDefined(); - expect(locLine).not.toContain('\n'); + const markerLines = lines.filter((l) => l.startsWith(' |C:')); + expect(markerLines).toHaveLength(maliciousMarkers.length); + for (const line of markerLines) { + expect(line).not.toContain('\n'); + // A `|` in the text would start a new field: only the three field separators remain + expect(line.split('|')).toHaveLength(4); + } }); }); diff --git a/packages/shared/test/exporters-conformance.test.ts b/packages/shared/test/exporters-conformance.test.ts index 76f3fad..21ac5cb 100644 --- a/packages/shared/test/exporters-conformance.test.ts +++ b/packages/shared/test/exporters-conformance.test.ts @@ -79,14 +79,15 @@ describe('Exporters Conformance & Security Verification', () => { categories.forEach((cat) => { const expectedColor = CATEGORY_TO_DAVINCI_COLOR[cat]; - expect(edl).toContain( - `* LOC: 01:00:${String(categories.indexOf(cat) * 5).padStart(2, '0')}:00 ${expectedColor}` - ); + const tcIn = `01:00:${String(categories.indexOf(cat) * 5).padStart(2, '0')}:00`; + const event = edl.split('\r\n').findIndex((line) => line.includes(`C ${tcIn} `)); + expect(event).toBeGreaterThan(0); + expect(edl.split('\r\n')[event + 1]).toBe(` |C:ResolveColor${expectedColor} |M:Event ${cat} [Player1] |D:1`); }); }); it('allows explicit custom color overrides in markers', () => { - const customColors: DaVinciMarkerColor[] = ['Orange', 'Pink', 'Cocoa', 'Mint', 'Sand']; + const customColors: DaVinciMarkerColor[] = ['Pink', 'Cocoa', 'Mint', 'Sand', 'Lavender']; const markers: SquadMarker[] = customColors.map((color, idx) => ({ id: `custom-${idx}`, roomId: 'room-conf', @@ -104,7 +105,7 @@ describe('Exporters Conformance & Security Verification', () => { const edl = exportToEdl(baseSession, markers); customColors.forEach((color) => { - expect(edl).toContain(` ${color} Note with ${color}`); + expect(edl).toContain(` |C:ResolveColor${color} |M:Note with ${color} [Director] |D:1`); }); }); diff --git a/packages/shared/test/exporters-v162.test.ts b/packages/shared/test/exporters-v162.test.ts new file mode 100644 index 0000000..a737841 --- /dev/null +++ b/packages/shared/test/exporters-v162.test.ts @@ -0,0 +1,132 @@ +import { describe, it, expect } from 'vitest'; +import fc from 'fast-check'; +import { exportToEdl, exportToFcpXml, toResolveMarkerColor, RecordingSession, SquadMarker } from '../src/index.js'; + +const session: RecordingSession = { + id: 's-golden', + roomId: 'r-golden', + name: 'Sesión final', + startedAt: 1_700_000_000_000, + stoppedAt: 1_700_000_600_000, + baseTimecode: '00:00:00:00', + framerateNumerator: 24, + framerateDenominator: 1, + dropFrame: false, +}; + +const marker = (overrides: Partial): SquadMarker => ({ + id: 'm', + roomId: session.roomId, + sessionId: session.id, + memberId: 'mem', + memberName: '', + timestamp: session.startedAt, + relativeTimeMs: 0, + timecode: '', + frameNumber: 0, + category: 'note', + label: 'x', + color: 'Blue', + ...overrides, +}); + +// Event line of a marker EDL, as DaVinci Resolve writes it in "Timeline Markers to EDL" +// (the same pattern SubtitleEdit uses to read those files) +const RESOLVE_EVENT_LINE = + /^\d{1,6}\s+\S+\s+V\s+C\s+(\d\d:\d\d:\d\d[:;]\d\d)\s+(\d\d:\d\d:\d\d[:;]\d\d)\s+(\d\d:\d\d:\d\d[:;]\d\d)\s+(\d\d:\d\d:\d\d[:;]\d\d)\s*$/; + +describe('DaVinci Resolve marker EDL (v1.6.2)', () => { + it('matches the layout of an EDL exported by Resolve itself, byte for byte', () => { + const edl = exportToEdl(session, [ + marker({ id: 'a', relativeTimeMs: 4_709, label: 'Intro' }), // frame 113 = 00:00:04:17 at 24 fps + marker({ id: 'b', relativeTimeMs: 76_209, label: 'Erklärung zu "Bomben"', color: 'Red' }), + ]); + expect(edl).toBe( + [ + 'TITLE: Sesion final', + 'FCM: NON-DROP FRAME', + '', + '001 001 V C 00:00:04:17 00:00:04:18 00:00:04:17 00:00:04:18 ', + ' |C:ResolveColorBlue |M:Intro |D:1', + '', + '002 001 V C 00:01:16:05 00:01:16:06 00:01:16:05 00:01:16:06 ', + ' |C:ResolveColorRed |M:Erklärung zu "Bomben" |D:1', + '', + '', + ].join('\r\n') + ); + }); + + it('maps every StreamCollab color to one of the 16 Resolve marker colors', () => { + expect(toResolveMarkerColor('Fuchsia')).toBe('ResolveColorFuchsia'); + expect(toResolveMarkerColor('Cocoa')).toBe('ResolveColorCocoa'); + // Resolve has no white marker + expect(toResolveMarkerColor('White')).toBe('ResolveColorCream'); + expect(toResolveMarkerColor(undefined)).toBe('ResolveColorBlue'); + expect(toResolveMarkerColor('Orange')).toBe('ResolveColorBlue'); + }); + + it('always produces one readable event + marker line pair per marker (property)', () => { + fc.assert( + fc.property( + fc.array( + fc.record({ + relativeTimeMs: fc.integer({ min: 0, max: 20 * 3600 * 1000 }), + label: fc.string({ minLength: 1, maxLength: 80 }), + comment: fc.option(fc.string({ maxLength: 280 }), { nil: undefined }), + memberName: fc.string({ maxLength: 40 }), + }), + { maxLength: 30 } + ), + fc.constantFrom( + { num: 24, den: 1, df: false }, + { num: 30000, den: 1001, df: true }, + { num: 60000, den: 1001, df: true }, + { num: 25, den: 1, df: false } + ), + (inputs, rate) => { + const edl = exportToEdl( + { ...session, framerateNumerator: rate.num, framerateDenominator: rate.den, dropFrame: rate.df }, + inputs.map((input, i) => marker({ id: `p${i}`, ...input })) + ); + const lines = edl.split('\r\n'); + expect(edl.match(/[^\r]\n/g)).toBeNull(); // CRLF only + const events = lines.filter((line) => RESOLVE_EVENT_LINE.test(line)); + const fields = lines.filter((line) => line.startsWith(' |C:ResolveColor')); + expect(events).toHaveLength(inputs.length); + expect(fields).toHaveLength(inputs.length); + for (const line of fields) { + expect(line).toMatch(/^ \|C:ResolveColor[A-Za-z]+ \|M:.* \|D:1$/); + expect(line.split('|')).toHaveLength(4); + } + // Drop-frame timecodes use ';' before the frames, non-drop ':' + for (const line of events) { + const [, tcIn] = line.match(RESOLVE_EVENT_LINE)!; + expect(tcIn[8]).toBe(rate.df ? ';' : ':'); + } + } + ), + { numRuns: 150 } + ); + }); +}); + +describe('Final Cut Pro 7 XML (v1.6.2)', () => { + it('writes the base timecode with the drop-frame separator of the session', () => { + const xml = exportToFcpXml( + { + ...session, + framerateNumerator: 30000, + framerateDenominator: 1001, + dropFrame: true, + baseTimecode: '01:00:00:00', + }, + [] + ); + expect(xml).toContain('01:00:00;00'); + expect(xml).toContain('DF'); + + const ndf = exportToFcpXml({ ...session, baseTimecode: '01:00:00:00' }, []); + expect(ndf).toContain('01:00:00:00'); + }); +}); diff --git a/packages/shared/test/exporters.test.ts b/packages/shared/test/exporters.test.ts index 0934aab..443a13f 100644 --- a/packages/shared/test/exporters.test.ts +++ b/packages/shared/test/exporters.test.ts @@ -95,11 +95,15 @@ describe('NLE Exporters', () => { expect(edl).toContain('FCM: NON-DROP FRAME'); // Verify event 001 and 002 - expect(edl).toContain('001 AX V C 01:00:15:00 01:00:15:01 01:00:15:00 01:00:15:01'); - expect(edl).toContain('* LOC: 01:00:15:00 Red First Blood [Shroud] - Pistol headshot'); - - expect(edl).toContain('002 AX V C 01:02:00:00 01:02:00:01 01:02:00:00 01:02:00:01'); - expect(edl).toContain('* LOC: 01:02:00:00 Fuchsia Ace 1v5 [Tarik] - Insane smoke spray'); + // DaVinci Resolve's own marker EDL layout: a one-frame event, then the marker fields + expect(edl).toContain( + '001 001 V C 01:00:15:00 01:00:15:01 01:00:15:00 01:00:15:01 \r\n' + + ' |C:ResolveColorRed |M:First Blood [Shroud] - Pistol headshot |D:1\r\n' + ); + expect(edl).toContain( + '002 001 V C 01:02:00:00 01:02:00:01 01:02:00:00 01:02:00:01 \r\n' + + ' |C:ResolveColorFuchsia |M:Ace 1v5 [Tarik] - Insane smoke spray |D:1\r\n' + ); }); }); diff --git a/packages/shared/test/perMember.test.ts b/packages/shared/test/perMember.test.ts index 8bc5b3b..4f2366f 100644 --- a/packages/shared/test/perMember.test.ts +++ b/packages/shared/test/perMember.test.ts @@ -88,6 +88,6 @@ describe('Per-member (POV) timeline rebasing', () => { const markers = [marker({ id: 'a1', timestamp: session.startedAt + 10000, label: 'Entry Frag' })]; const rebased = rebaseSessionForMember(session, markers, bravoAnchor, 'Bravo'); const edl = exportToEdl(rebased.session, rebased.markers); - expect(edl).toContain('* LOC: 00:00:06:00 Red Entry Frag'); + expect(edl).toContain('00:00:06:00 00:00:06:01 00:00:06:00 00:00:06:01 \r\n |C:ResolveColorRed |M:Entry Frag'); }); }); From fbef228a96abb10c60595ffc6ad6e51741dd8678 Mon Sep 17 00:00:00 2001 From: Cristian Date: Fri, 23 Oct 2026 22:17:06 -0500 Subject: [PATCH 04/32] fix(client): per-server credentials, reconnect loops, room handling, input limits and a11y - Tokens and the member id are kept per server: a ?server= link can no longer make the page send the credentials of one server to another; older shared credentials are only reused with that server after the user accepts it in a banner naming it - OBS: a retry left by an earlier failed attempt no longer closes a good connection (reconnect loop on double Connect / React StrictMode); attempts finishing after disconnect are closed - Server socket: the backoff resets on JOINED instead of on open; deleted or unknown rooms are not rejoined on every reconnect; late events of a replaced socket are ignored; the SNTP filter re-baselines after 3 consecutive RTT spikes instead of rejecting samples forever - ?room= follows the room in use; TIMEOUT no longer replaces the banner - Inputs limited to the server's lengths (name, room code, marker text, session name); the base timecode is validated before the start dialog closes; long stored names are trimmed - A custom marker's text is no longer saved as both label and comment - Dock shortcuts ignore key repeat, modifier keys and open dialogs - Dialogs trap and restore focus; missing labels added; category translated in the timeline - Clipboard failures are caught; export file names follow the server's rule; download links URL-encode their ids; API errors are shown with a translated prefix - The Director Panel no longer shows the previous room's session history after switching rooms --- packages/client/src/App.tsx | 90 ++++- .../client/src/components/ExportModal.tsx | 26 +- packages/client/src/components/MarkerRow.tsx | 2 +- .../client/src/components/RoomSetupPanel.tsx | 4 +- packages/client/src/hooks/useDialogFocus.ts | 40 +++ packages/client/src/i18n/en.ts | 4 + packages/client/src/i18n/es.ts | 4 + packages/client/src/services/credentials.ts | 108 +++++- packages/client/src/services/obs.ts | 23 +- packages/client/src/services/serverUrl.ts | 7 + packages/client/src/services/socket.ts | 45 ++- packages/client/src/views/DashboardView.tsx | 55 ++- packages/client/src/views/DockView.tsx | 27 +- packages/client/test/audit-v162.test.ts | 339 ++++++++++++++++++ 14 files changed, 719 insertions(+), 55 deletions(-) create mode 100644 packages/client/src/hooks/useDialogFocus.ts create mode 100644 packages/client/test/audit-v162.test.ts diff --git a/packages/client/src/App.tsx b/packages/client/src/App.tsx index 05122eb..4088798 100644 --- a/packages/client/src/App.tsx +++ b/packages/client/src/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { SquadMember, Room, RecordingSession, SquadMarker, ClientKind } from '@streamcollab/shared'; +import { SquadMember, Room, RecordingSession, SquadMarker, ClientKind, LIMITS } from '@streamcollab/shared'; import { ObsService } from './services/obs'; import { StreamCollabSocketClient } from './services/socket'; import { @@ -10,6 +10,9 @@ import { saveMemberId, getStoredUserName, saveUserName, + serverAwaitingTrust, + trustCurrentServer, + distrustCurrentServer, } from './services/credentials'; import { DockView } from './views/DockView'; import { HudView } from './views/HudView'; @@ -71,6 +74,18 @@ function writeViewToUrl(view: View, preview: boolean) { window.history.replaceState(null, '', url); } +/** Keep `?room=` on the room in use, so a reload doesn't go back to the room of the invite link */ +function writeRoomToUrl(roomId: string) { + const url = new URL(window.location.href); + if (url.searchParams.get('room') === (roomId || null)) return; + if (roomId) { + url.searchParams.set('room', roomId); + } else { + url.searchParams.delete('room'); + } + window.history.replaceState(null, '', url); +} + export function App() { const { t, locale, setLocale } = useT(); // Parse initial view from query parameters, hash (legacy links) or pathname @@ -112,7 +127,8 @@ export function App() { const exitHudPreview = useCallback(() => navigateTo(returnView), [navigateTo, returnView]); const [roomId, setRoomId] = useState(initialRoom); const [userName, setUserName] = useState(() => { - const saved = getStoredUserName(); + // Names longer than the server accepts (possible before v1.6.2) would keep the dock out of every room + const saved = getStoredUserName()?.trim().slice(0, LIMITS.memberName); if (saved) return saved; // Keep the generated name, otherwise the same streamer shows up under a new name on every load const generated = `Player_${Math.floor(100 + Math.random() * 900)}`; @@ -147,15 +163,38 @@ export function App() { // Only docks represent a squad member; HUD overlays and dashboards observe const clientKind: ClientKind = currentView === 'hud' ? 'hud' : currentView === 'dashboard' ? 'dashboard' : 'dock'; + // `?server=` names a server this device has credentials for from before v1.6.2: nothing is sent to + // it (not even a join) until the user decides whether to trust it with them + // (an OBS HUD source has no way to answer and only observes, so it just goes without them) + const [serverToTrust, setServerToTrust] = useState(() => + initialView === 'hud' ? null : serverAwaitingTrust() + ); + const [memberId, setMemberId] = useState(() => { let id = getStoredMemberId(); if (!id) { id = newMemberId(); - saveMemberId(id); + // Saved once the trust question is answered (accepting may bring back the old id) + if (!serverAwaitingTrust()) saveMemberId(id); } return id; }); + const answerServerTrust = (trust: boolean) => { + if (trust) { + trustCurrentServer(); + } else { + distrustCurrentServer(); + } + let id = getStoredMemberId(); + if (!id) { + id = memberId; + saveMemberId(id); + } + setMemberId(id); + setServerToTrust(null); + }; + const member: SquadMember = useMemo( () => ({ id: memberId, @@ -197,8 +236,11 @@ export function App() { setRoomDraft(newRoom); }; + useEffect(() => writeRoomToUrl(roomId), [roomId]); + // Initialize socket and listeners useEffect(() => { + if (serverToTrust) return; const subscriptions: Array<() => void> = []; const setJoinError = (error: JoinError | null) => setJoinState((state) => ({ key: joinKey, isHost: state.key === joinKey && state.isHost, error })); @@ -331,8 +373,10 @@ export function App() { setMemberId(freshId); return; } - // Messages sent before the join completed; the dock stops sending them, and they'd hide the real error - if (error.code === 'NOT_JOINED') return; + // Messages sent before the join completed; the dock stops sending them, and they'd hide the real error. + // TIMEOUT only means this socket sat in no room (no code yet, or an unknown one): the banner + // already says why, and the client reconnects by itself. + if (error.code === 'NOT_JOINED' || error.code === 'TIMEOUT') return; setJoinError({ code: error.code, message: error.message, room: roomId }); if (error.code === 'HOST_TOKEN_INVALID' && roomId) { // Forget the bad token and join again as a viewer @@ -358,7 +402,7 @@ export function App() { subscriptions.forEach((unsubscribe) => unsubscribe()); socketClient.disconnect(); }; - }, [socketClient, roomId, member, clientKind, joinKey]); + }, [socketClient, roomId, member, clientKind, joinKey, serverToTrust]); // Legacy #/dock, #/hud, #/dashboard links typed into the address bar useEffect(() => { @@ -435,6 +479,8 @@ export function App() { if (e.key === 'Enter') e.currentTarget.blur(); }} placeholder={t('nav.namePlaceholder')} + maxLength={LIMITS.memberName} + aria-label={t('nav.nameTitle')} className="w-28 rounded-sm border border-zinc-700 bg-zinc-950 px-2 py-0.5 text-xs text-zinc-200 focus:border-indigo-500 focus:outline-hidden" title={t('nav.nameTitle')} /> @@ -448,6 +494,8 @@ export function App() { if (e.key === 'Enter') e.currentTarget.blur(); }} placeholder={t('nav.roomPlaceholder')} + maxLength={LIMITS.roomId} + aria-label={t('nav.roomTitle')} className="w-24 rounded-sm border border-zinc-700 bg-zinc-950 px-2 py-0.5 text-xs font-mono text-zinc-200 focus:border-indigo-500 focus:outline-hidden" title={t('nav.roomTitle')} /> @@ -468,6 +516,29 @@ export function App() { + {serverToTrust && ( +
+ {t('app.trustServer', { server: serverToTrust })} + + + + +
+ )} + {(joinError || !roomId) && (
@@ -481,7 +552,12 @@ export function App() { : t('app.noRoom')} {joinError && ( - )} diff --git a/packages/client/src/components/ExportModal.tsx b/packages/client/src/components/ExportModal.tsx index 925f0dd..aac2723 100644 --- a/packages/client/src/components/ExportModal.tsx +++ b/packages/client/src/components/ExportModal.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; import { RecordingSession, SquadMarker, @@ -12,6 +12,7 @@ import { } from '@streamcollab/shared'; import { apiUrl } from '../services/serverUrl'; import { useT } from '../i18n'; +import { useDialogFocus } from '../hooks/useDialogFocus'; import type { LucideIcon } from 'lucide-react'; import { Download, Copy, Check, X, FileText, Film, Table, ListVideo, Layers } from 'lucide-react'; @@ -30,6 +31,8 @@ export const ExportModal: React.FC = ({ isOpen, onClose, sessi const { t } = useT(); const [selectedFormat, setSelectedFormat] = useState('edl'); const [copied, setCopied] = useState(false); + const dialogRef = useRef(null); + useDialogFocus(dialogRef, isOpen && Boolean(session)); // '' = master session timeline; otherwise a member id for that streamer's own recording file const [povMemberId, setPovMemberId] = useState(''); @@ -41,8 +44,9 @@ export const ExportModal: React.FC = ({ isOpen, onClose, sessi if (povMemberId) params.set('memberId', povMemberId); remoteUrl = apiUrl(`/api/rooms/${encodeURIComponent(roomId)}/export/${selectedFormat}?${params}`); } - // Include the marker count so new markers trigger a fresh export - const remoteKey = remoteUrl ? `${remoteUrl}#${markers.length}` : null; + // Any added, edited or deleted marker triggers a fresh export + const markersVersion = markers.map((m) => `${m.id}:${m.category}:${m.color}:${m.label}:${m.comment ?? ''}`).join('|'); + const remoteKey = remoteUrl ? `${remoteUrl}#${markersVersion}` : null; const [remote, setRemote] = useState<{ key: string; text: string } | null>(null); useEffect(() => { @@ -106,9 +110,14 @@ export const ExportModal: React.FC = ({ isOpen, onClose, sessi if (!isOpen || !session) return null; const handleCopy = async () => { - await navigator.clipboard.writeText(content); - setCopied(true); - setTimeout(() => setCopied(false), 2000); + // navigator.clipboard is missing on plain-http pages (a LAN IP) and can be refused by the browser + try { + await navigator.clipboard.writeText(content); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + } }; const handleDownload = () => { @@ -131,7 +140,9 @@ export const ExportModal: React.FC = ({ isOpen, onClose, sessi const mime = mimeTypes[selectedFormat]; const povName = members.find((m) => m.id === povMemberId)?.name; const baseName = povName ? `${session.name}_${povName}` : session.name; - const filename = `${baseName.replace(/\s+/g, '_')}_markers.${ext}`; + // Same rule as the server's downloads: no characters Windows forbids ("Auto Session 23:07:27") + const safeName = baseName.replace(/[\r\n"\\/:*?<>|]/g, '').replace(/\s+/g, '_') || 'export'; + const filename = `${safeName}_markers.${ext}`; const blob = new Blob([content], { type: `${mime};charset=utf-8` }); const url = URL.createObjectURL(blob); @@ -155,6 +166,7 @@ export const ExportModal: React.FC = ({ isOpen, onClose, sessi return (
= ({ marker, canEdit, onSave, o ) : ( <> - {marker.category} + {t(`category.${marker.category}`)}
{marker.label} diff --git a/packages/client/src/components/RoomSetupPanel.tsx b/packages/client/src/components/RoomSetupPanel.tsx index 230da85..924b1cf 100644 --- a/packages/client/src/components/RoomSetupPanel.tsx +++ b/packages/client/src/components/RoomSetupPanel.tsx @@ -64,7 +64,7 @@ export const RoomSetupPanel: React.FC = ({ socketClient, ro setRoomName(''); onRoomChange(created.id); } catch (err) { - setError(err instanceof Error ? err.message : t('room.createFailed')); + setError(err instanceof Error ? `${t('room.createFailed')}: ${err.message}` : t('room.createFailed')); } finally { setCreating(false); } @@ -77,7 +77,7 @@ export const RoomSetupPanel: React.FC = ({ socketClient, ro await deleteRoom(roomId); onRoomChange(''); } catch (err) { - setError(err instanceof Error ? err.message : t('room.deleteFailed')); + setError(err instanceof Error ? `${t('room.deleteFailed')}: ${err.message}` : t('room.deleteFailed')); } }; diff --git a/packages/client/src/hooks/useDialogFocus.ts b/packages/client/src/hooks/useDialogFocus.ts new file mode 100644 index 0000000..a04cee3 --- /dev/null +++ b/packages/client/src/hooks/useDialogFocus.ts @@ -0,0 +1,40 @@ +import { useEffect, RefObject } from 'react'; + +const FOCUSABLE = + 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])'; + +/** + * Modal dialog focus handling (WAI-ARIA dialog pattern): focus moves into the dialog when it opens, + * Tab / Shift+Tab stay inside it, and focus returns to what had it before once it closes. + */ +export function useDialogFocus(dialogRef: RefObject, isOpen: boolean) { + useEffect(() => { + const dialog = dialogRef.current; + if (!isOpen || !dialog) return; + + const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + const focusables = () => [...dialog.querySelectorAll(FOCUSABLE)]; + focusables()[0]?.focus(); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return; + const items = focusables(); + if (items.length === 0) return; + const first = items[0]; + const last = items[items.length - 1]; + const inside = dialog.contains(document.activeElement); + if (e.shiftKey && (document.activeElement === first || !inside)) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && (document.activeElement === last || !inside)) { + e.preventDefault(); + first.focus(); + } + }; + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('keydown', handleKeyDown); + previouslyFocused?.focus?.(); + }; + }, [dialogRef, isOpen]); +} diff --git a/packages/client/src/i18n/en.ts b/packages/client/src/i18n/en.ts index 647c7d1..043d636 100644 --- a/packages/client/src/i18n/en.ts +++ b/packages/client/src/i18n/en.ts @@ -20,6 +20,10 @@ export const en = { 'error.NOT_FOUND': 'That item no longer exists (it may have been deleted).', 'error.INVALID_MESSAGE': 'The server rejected the request: {detail}', 'app.dismiss': 'Dismiss', + 'app.trustServer': + 'This page connects to {server}. Use the room and streamer credentials saved on this device with that server? Only accept if you trust it.', + 'app.trustServerYes': 'Use them', + 'app.trustServerNo': 'Not with this server', // Marker categories and default labels 'category.kill': 'Kill', diff --git a/packages/client/src/i18n/es.ts b/packages/client/src/i18n/es.ts index 9be2086..2913215 100644 --- a/packages/client/src/i18n/es.ts +++ b/packages/client/src/i18n/es.ts @@ -22,6 +22,10 @@ export const es: Messages = { 'error.NOT_FOUND': 'Ese elemento ya no existe (puede que lo hayan borrado).', 'error.INVALID_MESSAGE': 'El servidor rechazó la petición: {detail}', 'app.dismiss': 'Cerrar', + 'app.trustServer': + 'Esta página se conecta a {server}. ¿Usar con ese servidor las credenciales de sala y de streamer guardadas en este dispositivo? Acepta solo si confías en él.', + 'app.trustServerYes': 'Usarlas', + 'app.trustServerNo': 'No con este servidor', // Marker categories and default labels 'category.kill': 'Kill', diff --git a/packages/client/src/services/credentials.ts b/packages/client/src/services/credentials.ts index 08fa268..6b362f8 100644 --- a/packages/client/src/services/credentials.ts +++ b/packages/client/src/services/credentials.ts @@ -1,9 +1,17 @@ // Room credentials kept in this browser / OBS profile. Storage can be unavailable (private // windows, locked-down CEF profiles), so every access is guarded and failures fall back to "none". +// +// Credentials belong to the server that issued them. The page's own server keeps the keys used +// before v1.6.2; a server named with `?server=` gets keys of its own, so a crafted link can't make +// this page send the tokens of one server to another. Credentials saved before v1.6.2 (when every +// server shared the same keys) are only used with another server once the user trusts it. +import { getServerBase } from './serverUrl'; const HOST_TOKEN_PREFIX = 'streamcollab_host_token:'; const MEMBER_TOKEN_PREFIX = 'streamcollab_member_token:'; const LAST_ROOM_KEY = 'streamcollab_room'; +const TRUSTED_SERVERS_KEY = 'streamcollab_trusted_servers'; +const DECLINED_SERVERS_KEY = 'streamcollab_declined_servers'; function read(key: string): string | undefined { try { @@ -25,20 +33,106 @@ function write(key: string, value: string | null) { } } -export const getHostToken = (roomId: string) => read(HOST_TOKEN_PREFIX + roomId); -export const saveHostToken = (roomId: string, token: string | null) => write(HOST_TOKEN_PREFIX + roomId, token); +/** Another server than the page's own (named with `?server=`), or '' */ +function remoteServer(): string { + const base = getServerBase(); + try { + return base && base !== window.location.origin ? base : ''; + } catch { + return base; + } +} + +function trustedServers(): string[] { + try { + const list = JSON.parse(read(TRUSTED_SERVERS_KEY) ?? '[]'); + return Array.isArray(list) ? list.filter((s): s is string => typeof s === 'string') : []; + } catch { + return []; + } +} -export const getMemberToken = (memberId: string) => read(MEMBER_TOKEN_PREFIX + memberId); -export const saveMemberToken = (memberId: string, token: string) => write(MEMBER_TOKEN_PREFIX + memberId, token); +export function isServerTrusted(server: string = remoteServer()): boolean { + return !server || trustedServers().includes(server); +} + +/** The user agreed to use the credentials saved before v1.6.2 with the current `?server=` */ +export function trustCurrentServer() { + const server = remoteServer(); + if (server && !isServerTrusted(server)) { + write(TRUSTED_SERVERS_KEY, JSON.stringify([...trustedServers(), server])); + } +} + +/** The user refused: this server gets credentials of its own and is not asked about again */ +export function distrustCurrentServer() { + const server = remoteServer(); + if (server && !declinedServers().includes(server)) { + write(DECLINED_SERVERS_KEY, JSON.stringify([...declinedServers(), server])); + } +} + +function declinedServers(): string[] { + try { + const list = JSON.parse(read(DECLINED_SERVERS_KEY) ?? '[]'); + return Array.isArray(list) ? list.filter((s): s is string => typeof s === 'string') : []; + } catch { + return []; + } +} + +/** Origin of the `?server=` the user still has to decide about, when there are older credentials to reuse */ +export function serverAwaitingTrust(): string | null { + const server = remoteServer(); + if (!server || isServerTrusted(server) || declinedServers().includes(server)) return null; + try { + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) ?? ''; + const legacyToken = + (key.startsWith(HOST_TOKEN_PREFIX) || key.startsWith(MEMBER_TOKEN_PREFIX)) && !key.includes('|'); + if (legacyToken) return server; + } + } catch { + // storage unavailable: nothing to reuse + } + return null; +} + +// Pre-v1.6.2 key for the page's own server; `|` for another one +const keyFor = (prefix: string, id: string, server: string) => (server ? `${prefix}${server}|${id}` : prefix + id); + +function readScoped(prefix: string, id: string): string | undefined { + const server = remoteServer(); + const own = read(keyFor(prefix, id, server)); + if (own || !server) return own; + return isServerTrusted(server) ? read(prefix + id) : undefined; +} + +const writeScoped = (prefix: string, id: string, value: string | null) => + write(keyFor(prefix, id, remoteServer()), value); + +export const getHostToken = (roomId: string) => readScoped(HOST_TOKEN_PREFIX, roomId); +export const saveHostToken = (roomId: string, token: string | null) => writeScoped(HOST_TOKEN_PREFIX, roomId, token); + +export const getMemberToken = (memberId: string) => readScoped(MEMBER_TOKEN_PREFIX, memberId); +export const saveMemberToken = (memberId: string, token: string) => writeScoped(MEMBER_TOKEN_PREFIX, memberId, token); export const getLastRoom = () => read(LAST_ROOM_KEY); export const saveLastRoom = (roomId: string) => write(LAST_ROOM_KEY, roomId); -// This device's streamer identity (same keys as before v1.6.1, so existing docks keep their member id) +// This device's streamer identity (same keys as before v1.6.1, so existing docks keep their member id). +// A member id is bound to a token on the server that issued it, so it is kept per server too. const MEMBER_ID_KEY = 'streamcollab_member_id'; const USER_NAME_KEY = 'streamcollab_username'; -export const getStoredMemberId = () => read(MEMBER_ID_KEY); -export const saveMemberId = (memberId: string) => write(MEMBER_ID_KEY, memberId); +const memberIdKey = (server: string) => (server ? `${MEMBER_ID_KEY}@${server}` : MEMBER_ID_KEY); + +export function getStoredMemberId(): string | undefined { + const server = remoteServer(); + const own = read(memberIdKey(server)); + if (own || !server) return own; + return isServerTrusted(server) ? read(MEMBER_ID_KEY) : undefined; +} +export const saveMemberId = (memberId: string) => write(memberIdKey(remoteServer()), memberId); export const getStoredUserName = () => read(USER_NAME_KEY); export const saveUserName = (name: string) => write(USER_NAME_KEY, name); diff --git a/packages/client/src/services/obs.ts b/packages/client/src/services/obs.ts index 6e212a1..9cc8e38 100644 --- a/packages/client/src/services/obs.ts +++ b/packages/client/src/services/obs.ts @@ -68,6 +68,8 @@ export class ObsService { private keepConnected = false; private reconnectTimer: ReturnType | null = null; private reconnectDelay = OBS_RECONNECT_MIN_MS; + private attemptSeq = 0; + private attemptInFlight = false; // Last record status and when it was sampled, to extrapolate the in-file position at keypress time private lastRecordStatus: ObsRecordStatus | null = null; @@ -126,10 +128,20 @@ export class ObsService { private async attemptConnect(): Promise { if (!this.target) return false; const { address, password } = this.target; + // obs-websocket-js closes the previous socket when connect() is called again, which rejects an + // attempt still in progress and fires ConnectionClosed: only the latest attempt may react + const attempt = ++this.attemptSeq; + this.attemptInFlight = true; try { await this.obs.connect(address, password || undefined, { eventSubscriptions: EVENT_SUB_GENERAL | EVENT_SUB_INPUTS | EVENT_SUB_OUTPUTS, }); + if (attempt !== this.attemptSeq) { + // disconnect() was called meanwhile: don't leave this late connection open + if (!this.keepConnected) await this.obs.disconnect().catch(() => undefined); + return this.isConnected; + } + this.attemptInFlight = false; this.isConnected = true; this.emit('status', true); @@ -138,10 +150,12 @@ export class ObsService { this.startPolling(); void this.detectChapterSupport(); - this.reconnectDelay = OBS_RECONNECT_MIN_MS; - this.emit('retry', null); + // A retry scheduled by an earlier failed attempt would close this good connection + this.cancelReconnect(); return true; } catch (err) { + if (attempt !== this.attemptSeq) return this.isConnected; + this.attemptInFlight = false; console.warn('[ObsService] Failed to connect to OBS WebSocket:', err); this.isConnected = false; this.emit('status', false); @@ -173,6 +187,9 @@ export class ObsService { /** Disconnect on purpose: stops automatic reconnection */ async disconnect() { this.keepConnected = false; + // An attempt still in progress is now stale + this.attemptSeq++; + this.attemptInFlight = false; this.cancelReconnect(); await this.closeSocket(); } @@ -213,6 +230,8 @@ export class ObsService { this.isConnected = false; this.stopPolling(); this.emit('status', false); + // A new attempt replacing this socket handles its own outcome + if (this.attemptInFlight) return; // OBS closed or crashed: come back automatically once it is running again this.scheduleReconnect(); }); diff --git a/packages/client/src/services/serverUrl.ts b/packages/client/src/services/serverUrl.ts index 2833ad7..a0a89a1 100644 --- a/packages/client/src/services/serverUrl.ts +++ b/packages/client/src/services/serverUrl.ts @@ -29,6 +29,13 @@ export function apiUrl(pathname: string): string { return `${SERVER_BASE}${pathname}`; } +/** REST path of an export download (room, session and member ids URL-encoded) */ +export function exportPath(format: string, roomId: string, sessionId: string, memberId?: string): string { + const params = new URLSearchParams({ sessionId }); + if (memberId) params.set('memberId', memberId); + return `/api/rooms/${encodeURIComponent(roomId)}/export/${format}?${params}`; +} + /** WebSocket endpoint (ws:// or wss:// to match the server's scheme) */ export function wsUrl(): string { if (SERVER_BASE) { diff --git a/packages/client/src/services/socket.ts b/packages/client/src/services/socket.ts index 6f50cb0..e0d09fc 100644 --- a/packages/client/src/services/socket.ts +++ b/packages/client/src/services/socket.ts @@ -16,6 +16,9 @@ import { wsUrl } from './serverUrl'; // Errors after which the server did not add this socket to the room const JOIN_REFUSED_CODES = new Set(['ROOM_NOT_FOUND', 'HOST_TOKEN_INVALID', 'MEMBER_TOKEN_INVALID']); +// Consecutive RTT spikes (one ping every 5 s) after which the clock filter re-baselines +export const SNTP_REBASELINE_AFTER_SPIKES = 3; + export interface JoinedInfo { roomId: string; clientKind: ClientKind; @@ -56,6 +59,8 @@ export class StreamCollabSocketClient { private clockOffsetMs = 0; private rttMs = 0; private hasSntpSample = false; + // Spikes rejected in a row: that many means the network path itself changed (Wi-Fi to 4G, VPN...) + private sntpRejectedInARow = 0; // Current session/room context private currentRoomId = ''; @@ -143,11 +148,16 @@ export class StreamCollabSocketClient { } try { - this.ws = new WebSocket(this.serverUrl); - - this.ws.onopen = () => { + const ws = new WebSocket(this.serverUrl); + this.ws = ws; + // Late events of a socket this client already replaced must not touch the current one + const current = () => this.ws === ws; + + ws.onopen = () => { + if (!current()) return; + // The backoff only resets once the server accepts this client into its room (JOINED): + // a server that opens and then closes (join timeout, rate limit) keeps backing off this.isConnected = true; - this.reconnectAttempts = 0; this.emitConnectionChange(true); this.startSntpCalibration(); @@ -157,7 +167,8 @@ export class StreamCollabSocketClient { } }; - this.ws.onmessage = (event) => { + ws.onmessage = (event) => { + if (!current()) return; try { const msg: ServerMessage = JSON.parse(event.data); this.handleServerMessage(msg); @@ -166,12 +177,12 @@ export class StreamCollabSocketClient { } }; - this.ws.onclose = () => { - this.handleDisconnect(); + ws.onclose = () => { + if (current()) this.handleDisconnect(); }; - this.ws.onerror = () => { - this.handleDisconnect(); + ws.onerror = () => { + if (current()) this.handleDisconnect(); }; } catch (err) { this.handleDisconnect(); @@ -207,9 +218,13 @@ export class StreamCollabSocketClient { // Jitter spike outlier rejection: // Reject samples with extreme RTT spikes (> 2.5x baseline and > 100ms) // to protect the calibrated timeline from transient network congestion / bufferbloat. - if (this.hasSntpSample && currentRtt > Math.max(100, this.rttMs * 2.5)) { + // Several spikes in a row are the new normal of a changed network path: start over from it. + const spike = this.hasSntpSample && currentRtt > Math.max(100, this.rttMs * 2.5); + if (spike && ++this.sntpRejectedInARow < SNTP_REBASELINE_AFTER_SPIKES) { break; } + if (spike) this.hasSntpSample = false; + this.sntpRejectedInARow = 0; // Exponential moving average filter if (!this.hasSntpSample) { @@ -269,6 +284,8 @@ export class StreamCollabSocketClient { case 'ROOM_DELETED': this.isJoined = false; + // Never rejoin a deleted room on the next reconnect + if (msg.payload.roomId === this.currentRoomId) this.currentRoomId = ''; this.emit('onRoomDeleted', msg.payload.roomId); break; @@ -283,6 +300,7 @@ export class StreamCollabSocketClient { saveMemberToken(this.currentMember.id, memberToken); } this.isJoined = true; + this.reconnectAttempts = 0; this.emit('onJoined', info); break; } @@ -292,6 +310,10 @@ export class StreamCollabSocketClient { if (msg.payload.code && JOIN_REFUSED_CODES.has(msg.payload.code)) { this.isJoined = false; } + // An unknown room stays unknown: don't retry it on every reconnect (the user types another code) + if (msg.payload.code === 'ROOM_NOT_FOUND') { + this.currentRoomId = ''; + } this.emit('onError', msg.payload); break; @@ -500,6 +522,9 @@ export class StreamCollabSocketClient { this.isConnected = false; this.isJoined = false; this.hasSntpSample = false; + // A later connect() joins only the room it is told to (the App joins again after connecting) + this.currentRoomId = ''; + this.currentMember = undefined; this.emitConnectionChange(false); } } diff --git a/packages/client/src/views/DashboardView.tsx b/packages/client/src/views/DashboardView.tsx index beb2026..26bf06e 100644 --- a/packages/client/src/views/DashboardView.tsx +++ b/packages/client/src/views/DashboardView.tsx @@ -1,13 +1,14 @@ -import React, { useEffect, useState } from 'react'; -import { MarkerChanges, Room, RecordingSession, SessionSummary, SquadMarker } from '@streamcollab/shared'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { LIMITS, MarkerChanges, Room, RecordingSession, SessionSummary, SquadMarker } from '@streamcollab/shared'; import { StreamCollabSocketClient } from '../services/socket'; import { ExportModal } from '../components/ExportModal'; import { RoomSetupPanel } from '../components/RoomSetupPanel'; import { MarkerRow } from '../components/MarkerRow'; import { SessionHistory } from '../components/SessionHistory'; -import { apiUrl } from '../services/serverUrl'; +import { apiUrl, exportPath } from '../services/serverUrl'; import { deleteSession } from '../services/api'; import { useT, MessageKey, TranslateParams } from '../i18n'; +import { useDialogFocus } from '../hooks/useDialogFocus'; import { Play, Square, Users, Film, Download, Radio, Search, Layers } from 'lucide-react'; interface DashboardViewProps { @@ -45,8 +46,22 @@ export const DashboardView: React.FC = ({ const liveSession = activeSession || lastSession || null; // Session history: null = follow the current session; otherwise browse a past one - const [sessions, setSessions] = useState([]); - const [selectedSessionId, setSelectedSessionId] = useState(null); + // Both belong to one room: after switching rooms they read as empty until the new room answers + const [sessionList, setSessionList] = useState<{ roomId: string; list: SessionSummary[] }>({ roomId: '', list: [] }); + const sessions = sessionList.roomId === room?.id ? sessionList.list : []; + const [selection, setSelection] = useState<{ roomId: string; sessionId: string | null }>({ + roomId, + sessionId: null, + }); + const selectedSessionId = selection.roomId === roomId ? selection.sessionId : null; + const setSelectedSessionId = useCallback( + (update: string | null | ((current: string | null) => string | null)) => + setSelection((prev) => { + const current = prev.roomId === roomId ? prev.sessionId : null; + return { roomId, sessionId: typeof update === 'function' ? update(current) : update }; + }), + [roomId] + ); const [history, setHistory] = useState<{ sessionId: string; markers: SquadMarker[] } | null>(null); const viewingHistory = Boolean(selectedSessionId && selectedSessionId !== liveSession?.id); @@ -55,10 +70,11 @@ export const DashboardView: React.FC = ({ const sessionsKey = `${room?.id}|${activeSession?.id}|${lastSession?.stoppedAt}|${markers.length}|${deletedSessions}`; useEffect(() => { if (!room?.id) return; + const listRoomId = room.id; const controller = new AbortController(); - fetch(apiUrl(`/api/rooms/${encodeURIComponent(room.id)}/sessions`), { signal: controller.signal }) + fetch(apiUrl(`/api/rooms/${encodeURIComponent(listRoomId)}/sessions`), { signal: controller.signal }) .then((res) => (res.ok ? res.json() : [])) - .then((list: SessionSummary[]) => setSessions(list)) + .then((list: SessionSummary[]) => setSessionList({ roomId: listRoomId, list })) .catch(() => { // keep the previous list (network hiccup or aborted) }); @@ -96,7 +112,7 @@ export const DashboardView: React.FC = ({ ); const offSessionDeleted = socketClient.on('onSessionDeleted', (sessionId) => { setDeletedSessions((n) => n + 1); - setSelectedSessionId((current) => (current === sessionId ? null : current)); + setSelection((prev) => (prev.sessionId === sessionId ? { ...prev, sessionId: null } : prev)); }); return () => { offUpdated(); @@ -112,7 +128,9 @@ export const DashboardView: React.FC = ({ try { await deleteSession(roomId, session.id); } catch (err) { - window.alert(err instanceof Error ? err.message : t('dash.deleteSessionFailed')); + window.alert( + err instanceof Error ? `${t('dash.deleteSessionFailed')}: ${err.message}` : t('dash.deleteSessionFailed') + ); } }; @@ -140,6 +158,8 @@ export const DashboardView: React.FC = ({ // Start session form state const [showStartModal, setShowStartModal] = useState(false); + const startDialogRef = useRef(null); + useDialogFocus(startDialogRef, showStartModal); const [sessionName, setSessionName] = useState(() => t('dash.defaultSessionName')); const [baseTimecode, setBaseTimecode] = useState('01:00:00:00'); const [selectedFramerate, setSelectedFramerate] = useState('60'); @@ -534,7 +554,7 @@ export const DashboardView: React.FC = ({
= ({ = ({ = ({ = ({ = ({ {showStartModal && (
= ({ type="text" required value={sessionName} + maxLength={LIMITS.sessionName} onChange={(e) => setSessionName(e.target.value)} className="w-full rounded-sm border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs text-white focus:border-indigo-500 focus:outline-hidden" /> @@ -673,8 +695,11 @@ export const DashboardView: React.FC = ({ type="text" required value={baseTimecode} - onChange={(e) => setBaseTimecode(e.target.value)} + onChange={(e) => setBaseTimecode(e.target.value.trim())} placeholder="01:00:00:00" + // Same rule as the server: the form stays open instead of losing the input + pattern="\d{2}:\d{2}:\d{2}[:;]\d{2}" + title="HH:MM:SS:FF" className="w-full rounded-sm border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs font-mono text-white focus:border-indigo-500 focus:outline-hidden" />

{t('dash.form.baseHint')}

diff --git a/packages/client/src/views/DockView.tsx b/packages/client/src/views/DockView.tsx index 5bc15f6..38b42e6 100644 --- a/packages/client/src/views/DockView.tsx +++ b/packages/client/src/views/DockView.tsx @@ -1,5 +1,13 @@ import React, { useState, useEffect, useCallback, useRef, useEffectEvent } from 'react'; -import { SquadMarker, SquadMember, RecordingSession, Room, MarkerCategory, SMPTETimecode } from '@streamcollab/shared'; +import { + SquadMarker, + SquadMember, + RecordingSession, + Room, + MarkerCategory, + SMPTETimecode, + LIMITS, +} from '@streamcollab/shared'; import { ObsService, ObsRecordStatus, ObsVideoSettings, ObsHotkeySignal, ObsChapterSupport } from '../services/obs'; import { getBoolPreference, @@ -278,14 +286,18 @@ export const DockView: React.FC = ({ if (target && (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName) || target.isContentEditable)) { return; } + // Holding a key repeats it (one marker per press), and a dialog on top owns the keyboard + if (e.repeat || typeof e.key !== 'string' || document.querySelector('[aria-modal="true"]')) return; - if (e.key === '0' || (e.key.toLowerCase() === 'z' && (e.ctrlKey || e.metaKey))) { + const undoShortcut = e.key.toLowerCase() === 'z' && (e.ctrlKey || e.metaKey) && !e.altKey; + if (undoShortcut || (e.key === '0' && !e.ctrlKey && !e.metaKey && !e.altKey)) { e.preventDefault(); handleUndo(); return; } - const category = keyToCategory[e.key]; + // Ctrl+1 / Alt+1 are browser and OBS shortcuts, not markers + const category = e.ctrlKey || e.metaKey || e.altKey ? undefined : keyToCategory[e.key]; if (category) { e.preventDefault(); handleQuickMark(category, quickLabel(category)); @@ -381,6 +393,7 @@ export const DockView: React.FC = ({ type="text" value={obsAddress} onChange={(e) => setObsAddress(e.target.value)} + aria-label={t('dock.websocket')} className="flex-1 rounded-sm border border-zinc-700 bg-zinc-950 px-2 py-1 text-zinc-200 font-mono text-[11px]" placeholder="ws://127.0.0.1:4455" /> @@ -391,6 +404,7 @@ export const DockView: React.FC = ({ type="password" value={obsPassword} onChange={(e) => setObsPassword(e.target.value)} + aria-label={t('dock.password')} className="flex-1 rounded-sm border border-zinc-700 bg-zinc-950 px-2 py-1 text-zinc-200 font-mono text-[11px]" placeholder={t('dock.passwordPlaceholder')} /> @@ -615,7 +629,9 @@ export const DockView: React.FC = ({ onSubmit={(e) => { e.preventDefault(); if (customComment.trim()) { - handleQuickMark(selectedCategory, customComment.trim()); + // The typed text is the marker's label; it is not repeated as its comment + handleQuickMark(selectedCategory, customComment.trim(), ''); + setCustomComment(''); } }} className="mt-2.5 flex flex-wrap items-center gap-2" @@ -623,6 +639,7 @@ export const DockView: React.FC = ({ setPovMemberId(e.target.value)} + aria-label={t('export.timeline')} className="rounded-sm border border-zinc-700 bg-zinc-900 px-2 py-1 text-zinc-200" > @@ -232,7 +233,7 @@ export const ExportModal: React.FC = ({ isOpen, onClose, sessi ))} - {povMemberId && {t('export.povHint')}} + {povMemberId && {t('export.povHint')}}
)} diff --git a/packages/client/src/components/MarkerRow.tsx b/packages/client/src/components/MarkerRow.tsx index af6e2e6..3789486 100644 --- a/packages/client/src/components/MarkerRow.tsx +++ b/packages/client/src/components/MarkerRow.tsx @@ -123,7 +123,7 @@ export const MarkerRow: React.FC = ({ marker, canEdit, onSave, o )}
{marker.memberName} -

{new Date(marker.timestamp).toLocaleTimeString()}

+

{new Date(marker.timestamp).toLocaleTimeString()}

)} diff --git a/packages/client/src/components/RoomSetupPanel.tsx b/packages/client/src/components/RoomSetupPanel.tsx index 924b1cf..feb4d5c 100644 --- a/packages/client/src/components/RoomSetupPanel.tsx +++ b/packages/client/src/components/RoomSetupPanel.tsx @@ -115,7 +115,7 @@ export const RoomSetupPanel: React.FC = ({ socketClient, ro
{error && {error}} - {t('room.hostHint')} + {t('room.hostHint')} {room && ( @@ -155,7 +155,7 @@ export const RoomSetupPanel: React.FC = ({ socketClient, ro {isHost && hostToken ? (
- {t('room.hostToken')} + {t('room.hostToken')}
-
+
{new Date(session.startedAt).toLocaleString()} {t('common.markers', { count: session.markerCount })} · {formatDuration(session, t)} @@ -79,7 +79,7 @@ export const SessionHistory: React.FC = ({ {onDelete && session.stoppedAt && (
) : (